Three games, one bug: the SvelteKit hosting bug that broke our arcade
On the evening of July 31, 2026, three of our browser games, Junk Runner, Hellhunter, and Neonbreak, were broken on our OVH bucket in exactly the same way. Each one loaded its index.html, then loaded it again, and again: between 390 and 451 full document reloads in six seconds, one fresh navigation roughly every 13 milliseconds, and not a single console error to explain any of it. This is the postmortem, with the numbers, because the next person to hit this deserves to find it in a search result.
The symptom: a reload loop with no errors
Our release probe is a headless Chromium script that counts framenavigated events, listens for pageerror and requestfailed, and flags any response at status 400 or above. A healthy game boot produces one document navigation, then the game's internal hash router takes over. Junk Runner 0.8.0 produced 390 to 451. The loop ran about every 13ms, fast enough that the page never visually settled, and the console stayed perfectly clean because nothing was actually throwing. The game was not crashing. It was politely handing itself to the browser's native navigation, over and over, forever.
Root cause: two characters
All three games are SvelteKit apps, and all three were built with paths.relative set to true, the default when their base-path env vars (JR_BASE, HH_BASE, NB_BASE) are unset. That serializes base as "./" into the bootstrap. SvelteKit's client router strips the base from the current path with pathname.slice(base.length), and here is the bug: a two-character base eats the leading slash of the real path. From that moment no route can ever match any hosting path, so kit's boot gives up and falls back to native navigation. The browser reloads, lands on the same mismatched state, and loops. Our two working launches that week, Darklord and Polis, are plain Vite single-page apps with no kit router, which is the only reason they never hit it. Two characters, "./", took down three games.
The fix: three parts, all required
- Build with a real base path. JR_BASE=/games/junk-runner/<version> bun run build (HH_BASE and NB_BASE for the others) makes pathname.slice(base.length) yield /index.html, a string route matching can actually work with. Every future version of these games must be built this way before publish; it is now a standing rule in the runbook.
- Add a [...rest] catch-all route with prerender disabled that mounts the same screen-mount component as the home page, so a bucket path like /games/junk-runner/0.8.0/index.html matches a route at all. The game's own hash router (#menu and friends) is unaffected; kit stays in pathname mode. We rejected kit hash-routing because it would fight the game's URL-hash screen router.
- Replace every goto('/x') with goto(resolve('/x')) using resolve from $app/paths, because kit 2.x goto() does not prepend base and was full-navigating players to origin /town (an S3 403 XML page) in Hellhunter. The subtle part: a literal regex misses variable calls. Neonbreak's menu helper called goto(href), which full-navigated to /intro until the probe caught it. The sweep that works: grep -rn "goto([a-z]" src/ | grep -v "goto(resolve".
- For root-absolute static refs, use asset() from $app/paths. Hellhunter's /art/... references in TownBuilding and HeroPortraitCard were 403ing from the bucket root; asset() prepends the base and works at any route depth, which plain relative paths do not.
The second bug: our own postbuild script
One layer down we found a bug of our own making. Our rewrite-static-artifact.mjs postbuild, the step that prepares builds for bucket hosting, was relativizing bare asset literals so chunks load relative to the document. That is fine for a single-route game and broken for a multi-route one, because client-side navigation changes the document's depth and every relative literal resolves somewhere new. Neonbreak's /images and /audio literals were being relativized and broke; Hellhunter's /art literals escaped the pattern by prefix accident, which is the worst kind of working. The fix is an opt-in flag, --absolute-assets <prefix>: document-resolved literals (bare quoted paths, new URL()) become version-absolute, while import() and CSS url() stay chunk-relative because file-based resolution is depth-independent. Neonbreak's postbuild passes it; an empty value keeps legacy behavior so no other game is affected.
What verified looks like
After the fixes, all three games boot with a document-navigation count of exactly one and stay there through full menu-to-gameplay flows: Junk Runner from menu to the difficulty modal, Hellhunter from FORGE A NEW HERO through town into the dungeon with its HUD live, Neonbreak from New Operation through the intro to faction select. Zero responses at 400 or above, zero console errors. The three games are on the bucket and playable at 0.8.0, 1.0.0, and 0.9.0 respectively, though the games catalog still lists them as candidates, a deliberate quality call, not a hosting one. The publish rules and the probe recipe now live in the pipeline runbook, which we wrote up in how a game ships to the Dracon arcade.