Skip to main content

How a game ships to the Dracon arcade: build, bucket, catalog flip

Every game on the Dracon arcade gets there the same way: an eight-step runbook that ends in a single field changing in a JSON file. We run it manually, on purpose, because the steps where it breaks are exactly the steps worth watching. On July 31 we ran it five times in one day and learned where the OVH bucket, SvelteKit's base handling, and our own postbuild each wanted to fail. Here is the pipeline as we run it today, the catalog it produces at 18 entries, and what is queued behind it. If you are shipping static to object storage, this is the checklist we wish we had found.

The eight steps

  • 1. Build the game with bun run build in its repo under web/games/wip/{slug} or web/games/released/{slug}. The build output is the static artifact the hub will never serve directly — it is a staging directory for the bucket.
  • 2. Rewrite the static artifact for bucket hosting with the game's postbuild — scripts/rewrite-static-artifact.mjs — which prepares asset paths for object storage. For most games this relativizes bare literals so chunks load relative to the document; for SvelteKit games it must cooperate with the base path (see step 3). Neonbreak's postbuild now passes --absolute-assets /games/neonbreak/{version}.
  • 3. Build with a real base path for every SvelteKit game. JR_BASE=/games/junk-runner/{version} bun run build (HH_BASE for Hellhunter, NB_BASE for Neonbreak). Without this, paths.relative serializes base as "./", SvelteKit's client router strips two characters off the pathname with slice(base.length), the leading slash is eaten, no route ever matches, and the game falls back to native navigation in a loop. That loop took down three games on July 31 and got its own postmortem in three games, one bug.
  • 4. verify-release must exit 0. Each game carries its own checks; Polis's were rewritten the week of July 31 after we found them asserting a deleted Phaser architecture while the real game had moved to Svelte. A failing verify is a stop-the-line signal, not a warning.
  • 5. Publish to the OVH bucket with ovh:publish, which uploads under games/{slug}/{version}/ with a per-object public-read canned ACL on every object. There is no bucket policy to widen — OVH does not implement PutBucketPolicy, so each putObject must carry the ACL. We learned this the hard way when a stale upload cost Junk Runner its ACLs and started serving 403 AccessDenied.
  • 6. Screenshots: stage PNGs into the build BEFORE publishing, so they land at games/{slug}/{version}/screenshots/ alongside index.html, captured with a headless Chromium probe that names files 01-title.png, 02-*.png with alts in the catalog entry. The catalog's screenshots array is the source of truth for the hub's gallery.
  • 7. Catalog flip: edit web/games/libs/data/games-catalog.json — status, version, hosted_path (/games/{slug}), hosted_artifact, asset_base_url (https://dracon-master.s3.uk.io.cloud.ovh.net/games/{slug}/), play_url (https://dracon.uk/games/{slug}/play), iframe_src (https://.../games/{slug}/{version}/index.html — always naming index.html explicitly because the bucket serves no directory index), screenshots with alts, and updated_at. The canLaunch predicate the hub reads is: hosted_path && play_url && iframe_src && screenshots.length > 0. If any is missing, the Play button does not render.
  • 8. Hub rebuild and rsync deploy, then live smoke through the real URL — for example Darklord at /games/darklord/play — the iframe content renders and no asset answers 400 or above. The probe script is a headless Chromium that counts framenavigated events, listens for pageerror and requestfailed, and flags any response at 400+. A healthy SvelteKit game boots with exactly one document navigation; the July 31 bug produced 390-451 in six seconds, one every ~13ms, with zero console errors.

Bucket facts that bite

Three properties of the OVH bucket shape everything above. It serves no directory index, so /games/{slug}/{version}/ is a 403 and every reference must name index.html explicitly; the catalog's iframe_src always does. Public access is a per-object canned ACL because PutBucketPolicy returns NotImplemented on this endpoint. And root-absolute runtime references like /generated/... resolve against the bucket root, not the version prefix, which is why Darklord publishes its generated art and fonts to the bucket root as a deliberate, documented exception rather than a bug — document it or someone will 'fix' it and break darklord's art fetches.

A fourth fact we added to the runbook after July 31: SvelteKit's base semantics. When paths.relative is true (the default when the *_BASE env var is unset), SvelteKit serializes base as "./" into the bootstrap. The client router then does pathname.slice(base.length) — two characters — to strip the base, which eats the leading slash of the real path (/games/junk-runner/0.8.0/index.html becomes games/junk-runner/0.8.0/index.html without the leading slash) and from that moment no route can match. Kit gives up and falls back to native navigation, which reloads, which mismatches again, which reloads. 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, and the fix lives in three places: a real base at build time, a [...rest] catch-all route, and goto(resolve(...)).

The catalog today

StatusCountWho lives thereProbe + hosted
Released1One Million Girlfriends 0.2.15 (branching visual novel, 5 heroines, 10 endings)released — adapter-static at /games/one-mil-girls/play, bucket at games/one-mil-girls/0.2.15/
WIP — hosted (bucket smoke clean)5Junk Runner 0.8.0, Darklord 1.0.0, Hellhunter 1.0.0, Neonbreak 0.10.4, Polis 1.0.0all five are on the bucket with zero failing assets; catalog still WIP by quality call
WIP — not yet hosted9Hegemon, template-canvas2d 0.0.1, template-isometric 0.0.1, template-birds-eye 0.1.0, Doomtap 1.0.0, Football Forever 0.1.0, Hooked Forever 0.1.0, Forever Mulligan 0.1.0, Forever Feast 0.1.0local builds only, no iframe_src
Experimental3Capture Anime Girls 0.1.0, Deathrun 2.0.0, Endless Tower Defense 0.1.0concepts with source, not yet publish candidates
Total18Every entry desktop-only, DRM-free, no account — target_platform desktop in every row6 have a live iframe_src, 1 is released
Real status counts from web/games/libs/data/games-catalog.json (18 entries, updated 2026-07-29).

One honest wrinkle in that table: the five hosted WIP were published to the bucket in the July 31 run at versions 0.8.0 through 1.0.0 and passed live smoke with zero failing assets after the fixes in three games, one bug were applied. The runbook records Darklord and Polis as 'playable on bucket, quality hold' and the other three as 'bucket-playable after base fix', but the catalog status field still says wip. Flipping that field is a quality call a human makes after playing the game end to end, not a fact the pipeline asserts. The catalog is the scoreboard; the runbook is the replay. When we do flip one, the hub rebuild is a static rsync and the changelog records the move, and this article regenerates from the catalog so the table stays current.

What the probe actually checks

The headless Chromium probe is the only gate we trust after publish because it exercises the real bucket URL, not a local file. It navigates to https://dracon-master.s3.uk.io.cloud.ovh.net/games/{slug}/{version}/index.html, counts framenavigated events (healthy = 1), subscribes to pageerror and requestfailed, collects every response status, and fails the run if any asset answers 400 or above. For SvelteKit games it also walks the in-game router: Junk Runner from menu to the difficulty modal, Hellhunter from FORGE A NEW HERO through town into the dungeon with HUD live, Neonbreak from New Operation through the intro to faction select — all without a full navigation. The hub's own 401 on /api/v1/me for anonymous visitors is expected and ignored; it is the auth check for the platform shell, not the game. We keep the probe recipe in the runbook so anyone on the team can run it with one command and get the same 13ms-per-loop signal we saw on July 31 if the base regresses.

What ships next

The standing publish rules from July 31 are now in the runbook: the three SvelteKit games must be built with their base-path env vars before every publish, Neonbreak's postbuild passes --absolute-assets, screenshots land before the bucket put, and every catalog flip must include versioned iframe_src plus per-object ACLs. Next up the status ladder are the five hosted WIP, one quality gate at a time. Neonbreak and Polis were the cleanest booters in the post-fix probe; Darklord is close behind despite its bucket-root art exception; Junk Runner and Hellhunter needed the full three-part SvelteKit fix but now boot to one navigation. Behind them in WIP are Hegemon (our strategy epic, longest-running dev saga) and the experimental trio — Endless Tower Defense and Deathrun especially — which will enter the pipeline when their playable loops close. Moves get recorded in the changelog as they happen, and this article is regenerated when the catalog changes, so the table above is a snapshot, not a monument. If you want to see today's truth, open the arcade and sort by status.

The probe that catches what eyes miss

Every publish runs a headless Chromium probe that counts document navigations, listens for pageerror and requestfailed, and flags any response at 400 or above. A healthy game boot produces one navigation, then the game's hash router takes over — Junk Runner from menu to difficulty modal, Hellhunter from forge through town into the dungeon, Neonbreak from New Operation through intro to faction select. The bug that broke three games on July 31 produced 390–451 navigations in six seconds with zero console errors; the probe caught it because it counts what the console does not show. The same probe now gates every publish: one navigation, zero 400s, stable HUD, or the build does not ship. The recipe lives alongside the fix in its own postmortem — the next person to hit paths.relative should find it in a search result.

What ships and what stays candidate

Shipping to the bucket does not automatically promote a game on the arcade. The games catalog tracks readiness separately: prod, wip, candidate, experimental. A game can be hosted at /games/junk-runner/0.8.0 and still list as candidate, which is a quality call, not a hosting one. One Million Girlfriends is prod today; Darklord and Polis are wip Vite apps that never hit the SvelteKit bug; Junk Runner, Hellhunter, and Neonbreak are candidates after their fixes — playable at 0.8.0, 1.0.0, 0.9.0 respectively, with the changelog as the human-readable ship log. The distinction matters for trust: a green launch badge means someone played it end to end on the bucket, not just that the build script exited zero.

Sources & provenance