Skip to main content

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. It is also the companion to how a game ships to the Dracon arcade, which documents the pipeline that surfaced the bug; this post is the bug.

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 — #menu, #town, #battle, all without touching the document. On July 31, Junk Runner 0.8.0 produced 390 to 451 navigations in a six-second window. Hellhunter and Neonbreak were the same. 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. The bucket logs said 200 for every index.html fetch, which made it look healthy if you only checked status codes. The giveaway was the navigation count: one is healthy, 400 is a router that has given up.

We first noticed it because screenshots were empty. The probe stages PNGs into the build before publishing so they land at games/{slug}/{version}/screenshots/, and the screenshot step was capturing a white page that was mid-reload. A manual open of https://dracon-master.s3.uk.io.cloud.ovh.net/games/junk-runner/0.8.0/index.html in a real browser showed the same: the address bar flickered, the devtools network panel filled with repeated index.html fetches, and the console remained empty. The two games that had shipped the same day and were fine — Darklord at 1.0.0 and Polis at 1.0.0 — are plain Vite single-page apps with no SvelteKit router, which is why they were spared. That split — three SvelteKit games broken, two Vite games fine — was the first real clue.

Root cause: two characters

All three broken 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, on boot, 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. If the hosted path is /games/junk-runner/0.8.0/index.html and base is "./" (length 2), slice(2) yields games/junk-runner/0.8.0/index.html without the leading slash. From that moment no route can ever match any hosting path, because routes are matched against a leading-slash pathname and the stripped pathname no longer has one. Kit's boot gives up and falls back to native navigation. The browser reloads, lands on the same mismatched state, and loops. Two characters, "./", took down three games at once.

We confirmed this by inspecting the built bootstrap. With JR_BASE unset, the generated app.html contained base:"./" and the client runtime did indeed slice two characters. With JR_BASE=/games/junk-runner/0.8.0, the same build emitted base:"/games/junk-runner/0.8.0" and pathname.slice(base.length) correctly yielded /index.html, a string the router could match. The SvelteKit docs describe paths.relative as 'whether the base path should be relative to the current directory', which is useful for file:// previews but wrong for bucket hosting where the base is a versioned prefix that must be absolute. The fix is to never rely on the default: every future build of these games must set its *_BASE env var before bun run build. It is now a standing rule in the runbook, not a suggestion.

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. Verify by grepping the built kit manifest for base:"/games/.
  • 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 (router: { type: 'hash' }) because it would fight the game's existing URL-hash screen router and require rewriting every screen transition.
  • 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), where href was a prop, which full-navigated to /intro until the probe caught it. The sweep that works: grep -rn "goto([a-z]" src/ | grep -v "goto(resolve". If it matches, fix it.
  • For root-absolute static refs, use asset() from $app/paths. Hellhunter's /art/... references in TownBuilding and HeroPortraitCard were 403ing from the bucket root because /art resolves against origin, not the version prefix; asset('/art/hero.png') prepends the base and works at any route depth, which plain relative paths (../../art/) do not once client-side navigation changes the document depth.

A note on why all four were required. The base fix alone stops the reload loop but leaves navigation broken for any goto that does not resolve. The [...rest] route alone makes the bucket path match, but without the base the match still fails because the slash is missing. goto(resolve(...)) alone fixes in-app navigation but does not fix the initial boot mismatch. asset() alone fixes art fetches but does not fix routing. We tried applying them singly and watched the probe still flag — only the set together produced one navigation and zero 400s. The games catalog still lists these three as wip, not because the bucket is still broken, but because the quality gate after a fix is a human playthrough, not a probe pass. That playthrough is what flips status to released.

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 to ../images and broke on the second navigation; Hellhunter's /art literals escaped the relativizer by prefix accident (/art did not match the /images|/audio pattern), which is the worst kind of working — it passes by luck and fails when someone adds a matched prefix.

The fix is an opt-in flag, --absolute-assets {prefix}: document-resolved literals (bare quoted paths like "/images/foo.png" and new URL("/audio/bar.mp3", document.baseURI) patterns) become version-absolute — /games/neonbreak/0.10.4/images/foo.png — while import() and CSS url() stay chunk-relative because file-based resolution is depth-independent and already correct. Neonbreak's postbuild now runs node scripts/rewrite-static-artifact.mjs --absolute-assets /games/neonbreak/{version}; an empty prefix keeps legacy relativizing behavior so no other game is affected. We chose an explicit flag over a global switch because Darklord deliberately publishes to the bucket root (games/darklord/generated/... lives at the bucket root, not under /games/darklord/{version}/generated/) and a global absolute rewrite would have broken its documented exception. The runbook now lists which games pass the flag and which do not, with the reason beside each.

What verified looks like

After the four 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, zero requestfailed events. The three games are on the bucket and probe-clean at 0.8.0, 1.0.0, and 0.10.4 respectively, though the games catalog still lists them as wip — a deliberate quality call, not a hosting one. The publish rules and the probe recipe now live in the pipeline runbook at docs/strategy/GAMES-PIPELINE-2026-07-31.md, which we wrote up for player-visible consumption in how a game ships to the Dracon arcade.

If you are shipping SvelteKit to object storage, the checklist we now run before every publish is: base env var set, bootstrap grepped for base:"/games/, [...rest] route present, no bare goto( without resolve, no bare /art or /images without asset(), postbuild flag correct for this game's hosting shape, per-object ACLs on every put, iframe_src naming index.html, probe showing one navigation. The July 31 run failed four of those and the probe counted 390-451 reloads at ~13ms. The August runs pass all of them. The difference is visible in the changelog when a catalog flip lands — until then, the honest status is WIP with a clean bucket, and the honest play buttons are the five WIP-hosted plus the one released on the arcade.

Sources & provenance