Content root & client-side routing
Why your game boots at game://content/, and a known PixiJS asset-loading gotcha that comes with it.
Roves serves your bundled content from a fixed virtual origin, game://content/ — not the
raw file://<absolute path on the player's disk>/index.html you might expect from a plain
desktop wrapper. Your app also boots by requesting the root (game://content/), not
game://content/index.html directly.
Why
A file:// document's location.pathname is the real OS path (e.g.
/C:/Users/.../dist/index.html), not a root-relative one — so any client-side history router
(React Router, TanStack Router, Vue Router in "history" mode, ...) can never match its own
routes against it, and falls back to its own "not found" page immediately, both at boot and on
every pushState/history.pushState navigation. Root-absolute asset references
(<script src="/assets/foo.js">) have the same problem, but that one's easy to miss: a
root-level route loader (data preloading, asset prefetch) still runs even when no leaf
route matches, so network requests keep firing normally while the actual visible page is
nothing but the router's own unstyled "Not Found" fallback the whole time.
game://content/ is a real, distinct origin (with its own authority, content, standing in
for what an actual HTTP deployment's domain would be) — the same idea
Tauri itself uses (tauri://localhost/ / https://tauri.localhost/) to
avoid this exact class of problem. location.pathname at boot is simply /, and
pushState("/about") stays same-origin and same-scheme, so the History API allows it and your
router's own routes match normally.
A direct navigation to a sub-route (a hard reload while on game://content/about, or
location.href = "/about") has no about.html file to serve — same as any static host
serving a history-mode SPA needs a fallback rule (nginx's try_files, Vite's own dev-server
historyApiFallback). Roves serves your bundle's entry HTML for exactly this case, but only
for a real navigation — a genuinely missing asset still surfaces as a real 404, not silently
becomes HTML.
Boots at the root, not index.html
Booting at game://content/index.html instead would serve identical bytes, but a router
matches its own root route against /, not against a literal /index.html — same as it would
never match a hard-coded /about.html. Every history-mode router would be unable to match
anything at boot, immediately falling back to its own "not found" page.
Known gotcha: PixiJS root-absolute asset paths
A custom, non-http(s) scheme isn't always transparent to every JS library — some hardcode
http/https recognition into their own URL-handling code instead of relying on the
browser's real, spec-compliant URL resolution. PixiJS is one of them.
If your manifest references an asset with a root-absolute path —
{
alias: "background_main_menu",
src: "/main-menu.png",
}— and you call Assets.init({ manifest }) with no further options, loading that asset fails
under Roves with an error like:
[Loader.load] Failed to load game://main-menu.png.
TypeError: Network error: game: only serves the "content" hostWhy: PixiJS's own internal path.toAbsolute()/path.rootname() utilities only recognize
http:/https: as a "real" URL with a host (their path.isUrl() check is a plain
/^https?:/ regex). For any other scheme with an authority — game://content/ included —
rootname() stops at just the scheme prefix ("game://"), dropping the host ("content")
entirely. When it then rejoins your root-absolute /main-menu.png against that truncated
root, you get game://main-menu.png instead of game://content/main-menu.png — a URL with no
host at all in the place content should be, since main-menu.png slides into the host
position instead. This isn't a Roves bug: the browser's own native URL resolution of a
root-absolute reference against game://content/ is correct, host and all; the bug is purely
inside PixiJS's own reimplementation of that logic.
Fix: set both resolver.rootPath and basePath, built from location.protocol/
location.host (not location.origin — that reports the literal string "null" under
game://content/'s opaque origin, since location.protocol/location.host are read straight
off the document's real URL and stay correct regardless):
const origin = `${location.protocol}//${location.host}/`;
Assets.resolver.rootPath = origin; // must be set before init() — see below
await Assets.init({ manifest, basePath: origin });basePath alone is not enough
It's tempting to pass only basePath and assume rootPath follows from it — it doesn't.
Resolver.toAbsolute() only uses rootPath directly when you set it yourself; left unset,
it falls back to deriving one from basePath via that exact same isUrl() check, so the bug
resurfaces identically. rootPath also isn't an Assets.init() option at all (only the
Resolver class exposes it) — set it straight on Assets.resolver, and before calling
Assets.init(), since init() already resolves every manifest asset's URL as part of adding
the manifest. Setting it afterward is too late: by then every asset's src has already been
(mis)resolved once.
This resolves every root-absolute asset reference correctly under Roves (game://content/)
and under a normal web deployment (https://yourgame.com/) without any special-casing —
location.protocol/location.host are simply always correct, and outside Roves this is a
harmless no-op (PixiJS's own isUrl() already recognizes http/https and gets the right
answer on its own; verified live against a plain vite preview web build alongside the Roves
build for this same page).
Not limited to PixiJS
Any other library that resolves URLs by hand-rolling scheme/host recognition instead of the
browser's own URL/fetch resolution could hit the same class of bug under a custom scheme.
If an asset or resource fails to load with a game://<something-that-looks-like-a-filename>
URL (no content host), suspect this pattern first.