Skip to content
 
 

Latest commit

 

History

377 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Roves

Roves (an anagram of Servo) is a customized fork of the Servo web engine, repurposed as a runtime for shipping web-based games as real native applications — instead of as a browser.

What this is

Servo is a general-purpose web browser engine written in Rust. Roves takes that engine and strips out everything that exists to make it behave like a browser — the toolbar, the tab strip, the address bar, back/forward history, favicon handling, and the state that only existed to feed all of that — none of which a game needs (see CUSTOMIZATIONS.md for the full, itemized list of what was removed and why).

What's left is a lean embeddable window that loads a local web bundle (HTML/CSS/JS — Phaser, PixiJS, Three.js, or your own engine, anything that renders to a page) full-window, looking and behaving like a native application, not a browser tab.

Roves is a vendored, patched copy of a pinned Servo release, not a live Git fork or submodule — see CLAUDE.md for why, and CUSTOMIZATIONS.md for exactly what changed on top of pristine upstream Servo and how those changes are carried forward when upstream is upgraded.

Naming: "Roves" vs. "Servo" vs. servoshell

Every label a player or the OS actually sees — the window title, the taskbar/dock app identity, the Linux .desktop menu entry — says Roves, not Servo (see CUSTOMIZATIONS.md for the exact rename). The one exception: the portable bundle's own binary/bundle name (play.exe/play.app/play) is a neutral placeholder, deliberately the same generic name on every platform rather than "Roves"-branded — see "Portable vs. installable packages" below. What's still named after the upstream project:

  • The underlying engine itself is genuinely still Servo — Roves doesn't fork Servo's rendering/DOM/script internals, just the shell around it (see "What this is" above) — so places that credit the engine (not the product) intentionally still say "Servo".
  • The binary and Cargo package are, and will keep being, named servoshell (e.g. target/release/servoshell, ports/servoshell/). Renaming it to something like rovesshell was considered and deliberately decided against: it would touch the upstream Python build tooling under python/servo/ too (not just branding, and not verifiable without a real build), for no functional benefit. This is a settled decision, not a pending one — don't expect or propose a rovesshell rename later.

Goal

Ship web-based games as real, native, double-click-to-run desktop (and, eventually, console) binaries — without bundling a full general-purpose browser engine like Electron or CEF, and without paying Chromium's footprint and overhead for a game that only ever needs a canvas and a window. Servo is already lighter and written in Rust; this fork trims it down further to exactly what a game needs and nothing else.

./mach bundle (see its own --help) turns a build into that double-click-ready package per target platform — see CUSTOMIZATIONS.md for what it produces on each.

Content packing & compression

By default, ./mach bundle --content-dir dist/ does not copy your game's built web content into the release as plain, individually browsable files — the loose .html/.js/ image/audio files a bundler like Vite produces are exactly what someone poking around inside an unzipped release would otherwise find and lift straight out. Instead, --content-dir is packed into a handful of tar+zstd archives, split into two tiers:

  • A small boot set — the html file itself, plus whatever it directly references (<script src>, <link href>, modulepreload hints, etc.), plus anything matched by --content-boot-include. This gets extracted eagerly, in full, before the engine even starts — into your OS's own cache directory (~/.cache, Library/Caches, %LOCALAPPDATA% — real disk, not anywhere inside the shipped game folder, and not a RAM-backed location either, which matters once a project's assets reach the multi-GB range).
  • Everything else stays compressed and is decompressed on demand, per archive, the first time the running page actually requests a file from it — so a large game's first launch only ever pays for what that session actually touches, not the whole install. Once decompressed, a pack stays that way (including across relaunches) until the packed content actually changes.

This is about not handing out your source assets for free by default, not DRM or anti-tampering — the archives aren't encrypted, and anyone willing to run the extractor themselves (it ships right there in the bundle) gets the original files back byte-for-byte.

How the split works, so a large game doesn't turn into either one giant archive or hundreds of tiny ones: past the boot set, dist/'s own root files become one archive; each direct subfolder's own files become another; everything nested deeper than that (however many levels) is flattened into a third archive per top-level subfolder. Every archive is capped at 500 MB by default, splitting into further parts past that. Files with an already-compressed extension (images, audio, video, fonts, existing archives) skip zstd entirely instead of spending CPU for no size win.

How small the boot set stays depends on your own bundler's code-splitting: a bundler that statically imports (or modulepreload-hints) most of the app from the entry HTML ends up with most of the app in the boot set too — same as a plain browser would eagerly fetch it. Structure anything not needed immediately (a later level, optional content) behind a dynamic import() to keep it lazy.

Tune or disable all of this with flags on ./mach bundle:

Flag Description Default
--content-compress <auto|none> Pack --content-dir into tar+zstd archives (auto), or copy it in as loose, uncompressed files with none of the above (none). auto
--content-compression-level <N> zstd compression level used by --content-compress=auto. Low favors speed. 1
--content-max-pack-size <SIZE> Max size per content archive (e.g. 500M, 1G) before splitting into further parts. 500M
--content-exclude <GLOB> Leave files matching this glob (relative to --content-dir) loose/uncompressed instead of packing them — e.g. a save-data or user-config subfolder that shouldn't sit inside a read-only archive. Repeatable. unset
--content-boot-include <GLOB> Force files matching this glob (relative to --content-dir) into the eager boot set, beyond the html file and whatever it directly references — e.g. a splash image. Repeatable. unset

See the "Pack game content into compressed archives" and "Split packed content into an eager boot set + lazy, on-demand extraction" entries in CUSTOMIZATIONS.md for the full design (archive naming, the manifest format, the extraction cache, the file: handler, and why tar+zstd) and for what's deliberately out of scope today (per-file integrity hashes, real encryption). A native, Roves-branded boot splash is shown throughout both extraction and your page's own initial load — see the "Native boot splash" and later boot-splash entries in CUSTOMIZATIONS.md for how it works.

Supported platforms

Device Status Compatibility library
Windows (x64) ✅ Implemented native (Rust std)
macOS (Apple Silicon and Intel) ✅ Implemented native (Rust std)
Linux (x64) ✅ Implemented native (Rust std)
Android 🚧 Native WebView APK, mach bundle --android, real release signing Android WebView
iOS/iPadOS 🚧 Native WKWebView, mach bundle --ios, real release signing WKWebView
Nintendo Switch 🚧 In development nx
Nintendo 3DS 🚧 In development ctru-rs
PlayStation Portable (PSP) 🚧 In development rust-psp
PlayStation Vita 🚧 In development vitasdk-rs
PlayStation 4 🚧 In development — (official SDK, NDA-gated; no public Rust crate)
PlayStation 5 🚧 In development — (official SDK, NDA-gated; no public Rust crate)
Xbox One 🚧 In development — (official SDK, NDA-gated; no public Rust crate)
Xbox Series X|S 🚧 In development — (official SDK, NDA-gated; no public Rust crate)

Desktop targets (Windows, macOS, Linux) use Servo. Mobile games use the system WebView and do not ship or compile Servo. See mobile packaging for Android APK and iOS app instructions — both platforms run edge-to-edge (status bar and system navigation/home indicator hidden) and are built the same way as desktop, via mach bundle --android/--ios, no separate Xcode- or Gradle-only workflow needed. --android-release/--ios-release produce a real, signed release build instead of the default debug/simulator one — see support/MOBILE.md for the required signing credentials (an Android keystore you can generate yourself, or an Apple Distribution certificate + provisioning profile from your own Apple Developer Program account for iOS). CI builds a native WebView smoke-test APK on Android and an unsigned iOS Simulator build on macOS, plus a real signed-release verification for Android. Console targets remain on the roadmap.

Embedding

Your web content has no build-time way to know it's running inside Roves rather than a regular browser (or, for that matter, Tauri). A build-time signal is still an option if you want one (the parent pixi-vn-react-template project this fork ships with exposes a process.env.ROVES_BUILD value, set by whoever builds specifically for Roves — see its vite.config.ts — a convention of that project, not something Roves itself provides). For a genuine runtime check instead, Roves injects window.__ROVES__ = true into every page as soon as <head> exists, before the page's own scripts run (see the "Inject a window.__ROVES__ marker" entry in CUSTOMIZATIONS.md) — use @drincs/roves-api/core's isAvailable() (see below) to read it rather than checking window.__ROVES__ directly: false in a plain browser, true only when actually running under Roves, no build step required, no async wait needed.

Talking to native APIs

Web content can't call into Rust directly — there's no Tauri-style invoke() runtime built in. Instead, Roves lets native code register custom URL schemes (ProtocolHandlers — see ports/servoshell/desktop/protocols/) that respond to ordinary fetch() calls from page JS. One is shipped today:

  • roves: (protocols/roves.rs) — a small, generic "control this app" surface: window/ process lifecycle (exit/close_window) and is_available (see below).

@drincs/roves-api is the JS package wrapping it, deliberately shaped to feel familiar if you already know @tauri-apps/api (though it's a real, independent implementation, not a shim over Tauri's runtime):

  • @drincs/roves-api/core — the generic invoke(cmd, args), talking to roves:, plus isAvailable() — a genuine, synchronous runtime "is this page running inside Roves" check — and systemInfo(), host OS/engine diagnostics (OS type/version, bitness, architecture, the running Servo build's own version) for bug reports and graphics-compatibility triage.
  • @drincs/roves-api/process — exit(), built on core.
  • @drincs/roves-api/saves — save-game storage; see "Save data" below.

See the "Roves' own general-purpose invoke() bridge" entry in CUSTOMIZATIONS.md for how the Rust side is wired up.

Content root & client-side routing

Your bundled content is served from a fixed virtual origin, game://content/, not the raw file://<absolute path>/index.html you might expect — and the app boots by requesting the root (game://content/), not game://content/index.html directly. Both exist for the same reason: under a real file:// path, location.pathname is the actual OS path and never matches /, so any client-side history router (React Router, TanStack Router, Vue Router in "history" mode, ...) falls back to its own "not found" page immediately, at boot and on every pushState navigation. game://content/ sidesteps this the same way Tauri's own tauri://localhost/ does — a real, root-relative origin your router's own routes match against, so History API navigation "just works". A direct navigation/reload on a sub-route (no matching file on disk) falls back to serving your bundle's own entry HTML, the same way a static host's SPA fallback (nginx's try_files, Vite's historyApiFallback) does. See the "Virtual content root (game: protocol)" entry in CUSTOMIZATIONS.md for the full design.

A custom, non-http(s) scheme like game: isn't always transparent to every JS library — some hardcode http/https in their own URL handling. PixiJS is one: it needs both resolver.rootPath (set before Assets.init()) and basePath set to `${location.protocol}//${location.host}/`, or a root-absolute asset reference (/foo.png) fails to load. See the wiki for the exact snippet, why basePath alone isn't enough, and other framework-specific gotchas.

Steam

Steam support is opt-in at build time: passing --features steam (e.g. ./mach build --features steam) compiles in a second, dedicated custom protocol, steam: (protocols/steam.rs), rather than routing it through roves: — Steamworks is a large, self-contained SDK surface, not a generic app command. When the feature isn't enabled — the default — the steam: scheme doesn't exist in the binary at all.

It wraps the steamworks crate, which binds the official Steamworks SDK (achievements, stats, DLC checks, the overlay, the store page). If the feature is enabled but the app wasn't actually launched through Steam, every command degrades to a harmless default instead of failing (is_available reports false, writes are silent no-ops).

From JS, use @drincs/roves-api/steam — a full Steamworks wrapper (achievements, stats, DLC, overlay, store) talking to steam: directly. See the "steam: protocol bridge" entry in CUSTOMIZATIONS.md for how the Rust side is wired up.

You don't need to build this yourself just to get Steam support: every platform's published release (see "Getting started" below) also ships a prebuilt _steam-suffixed shell variant, which Roves Packmaster can download and bundle your game into directly, App ID and all — no Rust/Python toolchain needed.

Save data

@drincs/roves-api/saves is an async, origin-scoped key/value store for player save data (shaped like IndexedDB, backed by real files), talking to its own dedicated saves: protocol (protocols/saves.rs). Roves picks the on-disk location for you — a saves/ folder next to the game when it's running portably, the OS cache directory when installed via --msi/--dmg/--deb (distinguished at runtime by a marker file the installer build writes, since nothing else tells those two cases apart) — and, when built with --features steam and a Steam client is running, transparently mirrors every write/delete to Steam Cloud — and getMostRecent() can tell which save is newest, Cloud-only saves included, without downloading anything. See the "Save-game storage API" entry in CUSTOMIZATIONS.md for the full design, and the wiki for player/game-dev-facing docs.

Separately, letting a player export/import a save as a plain file (<a download> on a Blob, <input type="file">) works out of the box with no roves-api call needed — Roves intercepts both transparently on every platform (a native "Save As"/"Open" dialog on desktop, a player-visible folder and native document picker on Android/iOS). See the wiki's Save-game storage page for the pattern. Desktop also intercepts programmatic anchor.click() downloads and retains the original Blob before Servo can try to navigate to its opaque-origin blob: URL.

Getting started

Prebuilt, versioned engine shell builds (no game content bundled — see below) are published on the Releases page as a portable zip for Windows, macOS, and Linux, starting with v0.1.0. Each platform is published twice — the plain default build (roves_shell_<platform>.zip) and a Steam-enabled one (roves_shell_<platform>_steam.zip, see the "Steam" section above) — pick whichever your game needs. Grab one of those if you just want to try the shell; the rest of this section is for building from source instead (e.g. to bundle your own game's content, or to work on Roves itself).

If you're iterating on your own game's content, though, you likely don't need any of this at all: the Roves VS Code extension (source) downloads a prebuilt shell for you and runs your already-built dist/ folder directly from your editor, with no toolchain install and nothing added to your project — see the wiki for details.

An AI coding agent working in this repo (or any project that uses Roves) can also load .claude/skills/roves — a Claude Code skill covering what Roves is and how to test a game with it, wire up the VS Code extension, or add a roves-action GitHub Actions workflow, without re-deriving any of that from scratch.

These are the same build steps as upstream Servo — Roves is a source-level fork, not a different build system. For deeper background, see the Servo Book's own Getting the Code and Building Servo pages; where this fork's behavior actually differs from what's described there, that's covered in CUSTOMIZATIONS.md instead.

macOS

  • Download and install Xcode and brew.
  • Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh
  • Install rustup: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
  • Restart your shell to make sure cargo is available
  • Install the other dependencies: ./mach bootstrap
  • Build: ./mach build
  • Package a runnable bundle: ./mach bundle (add --dmg to wrap it in an installable disk image instead of the default self-contained play.app — see "Portable vs. installable packages" below)

Linux

  • Install curl:
    • Arch: sudo pacman -S --needed curl
    • Debian, Ubuntu: sudo apt install curl
    • Fedora: sudo dnf install curl
    • Gentoo: sudo emerge net-misc/curl
  • Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh
  • Install rustup: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
  • Restart your shell to make sure cargo is available
  • Install the other dependencies: ./mach bootstrap
  • Build: ./mach build
  • Package a runnable bundle: ./mach bundle (add --deb for an installable Debian/Ubuntu package instead of the default self-contained play binary — see "Portable vs. installable packages" below)

Windows

  • Download uv, choco, and rustup
    • Be sure to select Quick install via the Visual Studio Community installer
  • In the Visual Studio Installer, ensure the following components are installed:
    • Windows 10/11 SDK (anything >= 10.0.19041.0) (Microsoft.VisualStudio.Component.Windows{10, 11}SDK.{>=19041})
    • MSVC v143 - VS 2022 C++ x64/x86 build tools (Latest) (Microsoft.VisualStudio.Component.VC.Tools.x86.x64)
    • C++ ATL for latest v143 build tools (x86 & x64) (Microsoft.VisualStudio.Component.VC.ATL)
  • Restart your shell to make sure cargo is available
  • Install the other dependencies: .\mach bootstrap
  • Build: .\mach build
  • Package a runnable bundle: .\mach bundle (add --msi for an installable Windows package instead of the default self-contained play.exe — see "Portable vs. installable packages" below; requires the WiX Toolset's candle/light on PATH)

Portable vs. installable packages

By default, on every platform, ./mach bundle produces a portable bundle — something a player downloads, unzips, and runs directly, no install step, no admin/root privileges. Each platform also has an installable package alternative, matching the shape of what a bundler like Tauri's own bundler offers (msi/nsis on Windows, dmg/app on macOS, deb/rpm/appimage on Linux) — Roves supports one format per platform today, the ones with existing, reusable packaging logic in this fork's Servo lineage (./mach package's own WiX/hdiutil code, see CUSTOMIZATIONS.md):

Platform Portable (default) Installable
Windows play.exe + a few DLLs, GStreamer plugins in lib/ --msi: a real .msi, via WiX
macOS play.app --dmg: that same .app, wrapped in a .dmg disk image
Linux play + .so deps, flat --deb: a real, installable .deb

An installable package always wraps the exact same content the portable bundle would have had (same --content-dir, same packing settings) — it isn't a second, different build. --package-name/--package-version (defaults roves/0.0.0) name and version whichever one you asked for; nothing else about ./mach bundle's other flags changes based on portable-vs-installable. nsis/rpm/appimage aren't implemented yet — see CUSTOMIZATIONS.md if you're adding one.

This repo's own smoke-test CI, .github/workflows/test.yml, exercises both variants on every platform on every push (a package_mode matrix axis of portable/msi/dmg/deb, one job per platform per mode) — not a manual toggle, since its whole job is proving mach bundle still works in every mode it supports, not just the default. It doesn't stop at a successful build either: after assembling each bundle, it actually launches the resulting binary for a few seconds and fails the job if the process doesn't stay up — a plain build success was once mistaken for "the bundle works," which cost real debugging time (see CUSTOMIZATIONS.md's launch.json entries) before this check existed. If you're building your own release pipeline around mach bundle (or roves-action, see below), pick whichever mode(s) you actually want to ship, the same way that workflow's per-mode BUNDLE_ARGS do.

Diagnosing a launch that appears to do nothing

mach bundle --diagnostic-script ships a diagnose.bat (Windows) / diagnose.sh (macOS/Linux) next to the game binary — in the installable packages too, not just the portable one (not --deb, where a terminal already shows the same output directly). Running it instead of the game launches the same binary from a console that stays open afterward, printing the exit code and the newest roves.log's contents inline, so a tester who hits "I double-clicked it and nothing happened" can just run this and paste the result into a bug report. Off by default — a real release has no reason to carry debug tooling players never asked for. See the wiki's Diagnosing a silent launch page for the full story (roves.log's location per platform, and a real incident this was built to catch).

Repeatable performance fixtures

The bundled diagnostic page also exposes deliberately minimal comparison workloads through ?perf=blank, ?perf=pixi-static, and ?perf=pixi-animated. Run the same built page, window size, warm-up and sampling interval in Roves and Chrome. The static PixiJS fixture renders one frame and stops its ticker; unlike the normal diagnostic page, these fixtures omit gamepad polling, FPS probes, Steam checks and other page-level timers that would contaminate an idle measurement. The measurement plan and current findings are tracked in ROVES_PERFORMANCE.md.

An experimental ./mach build --features mimalloc swaps the global allocator for mimalloc (default: system allocator on Windows, jemalloc elsewhere) for allocator A/B measurements. The perf-ab.yml workflow publishes Windows release builds of both to the perf-ab pre-release.

For shell-side counts, launch a desktop build with ROVES_PERF_LOG_INTERVAL_MS=10000 (or another positive interval). The normal roves.log then receives aggregate [roves-perf] lines for SDL events/timeouts, redraw requests and coalescing, WebView paints, and final window presents, plus [roves-perf-time] lines with total/max microseconds spent in WebView paint, egui update, shell paint and window present. The diagnostic is disabled by default and does not create a sampling timer or background thread.

Desktop builds also offer the experimental fast path A with ROVES_DIRECT_PRESENT=1 (off by default; only the exact value 1 enables it). With one full-window WebView and no dialogs, status overlay, egui focus, active AccessKit or pending egui textures, it skips egui tessellation and paint while retaining Servo's off-screen rendering, blit and window present. Any failed condition uses the composed path in the same frame. Pair it with ROVES_PERF_LOG_INTERVAL_MS=1000 to observe direct_presents; see the rendering plan.

Relationship to upstream Servo

Roves tracks a pinned upstream Servo release plus a small set of targeted patches — see CUSTOMIZATIONS.md for the itemized list, and CLAUDE.md for how upgrades to a newer Servo release are meant to happen. All credit for the underlying engine goes to the Servo Project and its contributors; issues specific to this fork's own customizations should stay local to this repository rather than upstream. The SDL3 virtual gamepad CI probe and remaining hardware checks are documented in SDL3_WINDOWING_TESTING.md. On macOS, gamepad support is off by default and can be tried experimentally by launching with ROVES_MACOS_GAMEPAD=1; the test page's Input section shows every key, mouse button and gamepad event the page receives.

About

Roves is a customized fork of the Servo web engine, repurposed as a runtime for shipping web-based games as real native applications — instead of as a browser.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages