The template is a working game. Turning it into yours is a checklist, not a rewrite — the SDK, the channel protocol, and the platform plumbing do not change. There are two starting points, and the first is a complete, registerable game on its own.
Path A — re-identify only (a reskin)
Keep the template's rules, blob, and channel wiring exactly as they are; give it a
new name, game key, and storage prefix. Keeping the template rules is a valid
finished state. The facts you keep (and register): seat range 2..3 and
cfgSuffix 0200000001000000 — the suffix is PROD_CFG_SUFFIX in
e2e/match-cfg.ts, the single file that owns the channel-config contract, pinned by
e2e/onchain-scenarios.test.ts, and both are written up in the template's
README.md (Make your own game from this) and blob/MANIFEST.md.
A game's identity is single-sourced, so the core of this is a small, closed edit — but the surface is larger than that section's checklist: a full re-identification reaches every place the template's own name is baked in. The nine edits below are the ones that fail silently or weirdly if you miss them, which is why they get a table; the rest fail loudly the first time you run the ladder (see Testing). Expect the ladder — not this list — to be what tells you when you are done.
| Edit | What to change |
|---|---|
src/app-identity.ts | GAME_KEY, TITLE, STORAGE_PREFIX — the one source of every id. The adapter, configureApp(), the page <title>, the whole e2e suite, the on-chain smoke gate, and the wager fixture all import from here. MOVE_NS derives itself; leave it. |
package.json | "name" (plus description/repository for tidiness). |
package.json's arcadeGameLibDir | Declares your game's lib dir — "arcadeGameLibDir": "src/lib/xayaman" in the template. Rename it in the same commit as the directory below: e2e/imports.test.ts reads this field and uses it as its sweep root, and a stale or missing value hard-FAILs that gate. (Before SDK 0.7.0 this slot was a #sdk-proto imports map pointing at a two-file sdk-proto{,.node}.ts twin; the isomorphic @xayaarcade/sdk/core entry removed the need for both, and they are deleted.) |
src/lib/<game>/ | The template's game directory is src/lib/xayaman/ — channel, packed codec, board render, converter, bot. Rename it and sweep every import of it. |
tests/repo/copyright-guard.test.ts | The self-identity pin block at the top — three literals (pkg.name, GAME_KEY, STORAGE_PREFIX). It is deliberately an independent copy that catches a half-finished rename: update it to your values. Everything below it — the forbidden-title sweep — stays untouched. |
blob/tests/run-tests.sh | The test image tag. The script hardcodes xayaman-testenv:local and only rebuilds when docker image inspect fails — so two forks on one machine silently share one stale image and you debug a battery that is not testing your code. Derive the tag from your GAME_KEY. |
package-lock.json | The package name appears twice — the root name and the packages[""] entry. Edit both by hand; no reinstall and no registry access is needed. |
docker/Dockerfile.frontend, docker/frontend.env.example, scripts/build-frontend-image.sh | The NEXT_PUBLIC_GAME_ID=xbm default (the g/ namespace the deployment posts to). Set it to your game key or always pass it explicitly. The image tag auto-derives from the package name — no edit there. |
The gate that reads it
e2e/imports.test.ts does not hardcode the template's game directory. It reads
package.json's arcadeGameLibDir and uses that directory as its sweep root — so a
fork that renames src/lib/<game>/ and that field together needs no edit here. Its
ALLOW set contains only the gate file itself: no source file is exempt, because the
/core entry needs no browser-twin exception.
It fails closed: a sweep root with no tracked .ts/.tsx files is
a hard FAIL that names the root and tells you to update package.json's
arcadeGameLibDir.
Rename only the directory and this gate stops you instead of quietly shrinking to the
e2e/ files and printing a green PASS. Still glance at the
file count in its PASS line (N shared/e2e files): a number that drops after a rename
means something left the sweep.
What else the ladder will tell you
- Only tracked files exist to the gates. The import gate and the copyright guard both
read
git ls-files, so a new directory that is not yetgit added reports as "no tracked files under<dir>" rather than as a rename. Stage new files before running the ladder. - The template's name is refused anywhere. Once your three pins differ from the
template's, the guard's template-identity sweep fails any tracked file outside
vendor/,.claude/skills/,rules/andblob/that still contains the template's name or uses its game key as a word — a README link to the template repository included. Credit it without spelling its name, or not at all. - Include every header you use in
rules/. The wasm build and the native test build resolve transitive includes differently, so a type that arrives for free under wasi-sdk (size_twithout<cstddef>) fails the native battery; a greenbuild-blob.shdoes not prove the C++ tests compile. - The first
blob/tests/run-tests.shneeds the network — it builds the test image, and that build downloads the pinned wasmtime C API. The image derives from the libxayagame image for the heritage oracle's dependencies; a fork that has deletedrules/heritage/needs only a C++ toolchain,libjsoncpp-devand wasmtime, and can base it on a slim Debian instead.
Then rebuild the frontend image and hand over under your new key — see Submitting your game.
Path B — new rules (a different game)
Do everything in A, then replace the game itself:
- Replace
rules/with your C++ core. Keep thearcade_*ABI and the zero-import discipline (see The rules blob).rules/heritage/leaves when and only whenrules/does — it is the independent oracle for two blob-battery tests, meaningful only against the rules it was ported from. Delete both together (plus theboard/packed-coreMakefile targets); on a reskin, touch neither. - Decide your channel config and therefore your
cfgSuffix(or none). Record it inblob/MANIFEST.md, in the register command, and ine2e'sPROD_CFG_SUFFIX(pinned by a test). - Build the blob in the pinned container (
bash blob/build-blob.sh) and commit it with its.sha256. The builder image (arcade-blob-builder:local) is a game-agnostic pinned toolchain, built for you on first run. - Write the adapter (rules + channel + renderer + input) and the app — see
The SDK. Keep the root rules in
src/app/globals.css(100dvh,overflow: hidden,overscroll-behavior: contain): they are what makes the game fit the frame the arcade gives it instead of scrolling inside it, and the SDK'ssquareBoardframe sizes a square board to that box for you. The game shape is spread wider than the adapter:src/lib/games/xayaman-adapter.tsand its exported symbol; theregisterAdapter(GAME_KEY, …)call insrc/bootstrap.ts(the registry key isGAME_KEY, not the move namespace); the board components and their CSS module names;src/hooks/use-xayaman-input.ts; the presentation constant insrc/lib/arcade/presentation.ts; the game-shaped unit tests undertests/lib/,tests/hooks/,tests/components/, which rung 1 of the ladder runs; and a second game directory,src/game/(types.ts,sudden-death.ts), which is separate fromsrc/lib/<game>/and just as game-shaped —e2e/harness.tsimports from both (its@/lib/<game>/…and@/game/typesimport lines sit side by side). - Rewrite
e2e/, which carries far more game shape thanharness.tsalone.e2e/suite.tshas a scenario built on the template's mechanics and board fields (blast,bombs) your game will not have;e2e/match-cfg.tsownsPROD_CFG_SUFFIXand the cfg builder — this is the file to edit for a new cfg, and the rest import from it;e2e/onchain-scenarios.test.tspins the literal cfg hex;e2e/onchain-scenarios.smoke.test.tsimportsprodCfgand feeds the resultingChannelConfigstraight tojudge.initialState(2, cfg)— since SDK 0.6.0initialStatetakes aChannelConfigvalue, so cfg bytes are never hand-assembled and a hand-rolledUint8Arraydoes not compile;e2e/packed-determinism.tscarries its own cfg builder, field names and trace filenames;e2e/selfplay.tsdrives the template's heuristic bot through the harness.e2e/app-config.tsusually needs no edit — it reads@/app-identitylike everything else. - Keep the guards: the copyright guard (with your pins from A), the three
determinism legs — native, wasmtime and V8, run by
blob/tests/run-tests.shandnpm run e2e:determinism— and the five CI jobs (guard,blob,cpp,app,e2ein.github/workflows/ci.yml).
The identity fields
export const GAME_KEY = 'xbm'; // GSP game-type / registry key — frozen wire constant
export const MOVE_NS = process.env.NEXT_PUBLIC_GAME_ID?.trim() || GAME_KEY;
export const TITLE = 'Xayaman'; // display name in shared screens and the page title
export const STORAGE_PREFIX = 'xayaman'; // localStorage prefix (also gates <prefix>_debug)
MOVE_NS auto-derives from GAME_KEY; only a namespace-isolated fork deployment
overrides it via NEXT_PUBLIC_GAME_ID, which must then match the GSP's
--game_id.
STORAGE_PREFIXmust be yours, and this one can break someone else's game. Games are served at<games-origin>/g/<slug>/— one origin, separate from the shell's but shared with every other game — andlocalStorageis scoped to the origin, not the path. The prefix is the only thing namespacing the keys the SDK owns: the session keys,_dev_wallet,_name,_active_game,_channel_proof_<id>,_left_channels,_debug, all built fromappConfig().storagePrefixin the vendored SDK and fed fromsrc/bootstrap.ts. Ship the template'sxayamanand you are writing over a live game's keys, not just your own.
The one rule that trips everyone:
adapter.gameIdmust equalappConfig().moveNamespace. Both come fromapp-identity.tsfor exactly this reason — if they drift, lifecycle moves and gameplay moves land in two differentg/namespaces and the channel silently splits in half. A bootstrap test pins the invariant; keep it.
The serving slug and the library listing are not in your repo — you supply
them in the upload form, which today is the playground's
/attach — submissions on arcade.xaya.io are
closed during the curated phase. The serving path is not yours either: this
arcade runs games-host in path mode (GAMES_MODE=path), so every game
is mounted at /g/<slug>/ on the plane's games origin — on both public planes the
shell's own hostname, under the operator's first-party declaration described in
Overview — baked in from the /__arcade_base__ placeholder at
acceptance. You never
choose, see or hardcode it.
(The manifest still records a port per game — that is the other serving mode, and
nothing a builder on this arcade ever uses; see
Hosting & registration.)
Toolchain notes (either path)
next buildrewritestsconfig.jsonandnext-env.d.ts(the Next TypeScript plugin reflows both). That churn is not your edit — rungit checkout -- tsconfig.json next-env.d.tsbefore committing.npm run selfplay -- --games=100is the one slow rung (15+ min, single-threaded; 100 is also the default,e2e/selfplay.ts). Everything else in the ladder runs in seconds. Run it deliberately, not in a tight loop.- Work in a real git repository. Two rungs shell out to
git ls-files— the copyright guard, whose scope is the tracked file list (tracked()intests/repo/copyright-guard.test.ts), and the imports gate (itslshelper ine2e/imports.test.ts) — andblob/check-blob.sh --rebuildbuilds fromgit archive HEAD, not your working tree (the rebuild-and-compare leg at the foot ofblob/check-blob.sh), so an uncommitted file the build needs makes it fail. If you started from a downloaded copy rather than a clone,git initand commit once first. Outside a repository at all,git ls-filesexits128and both gates die on the spot; inside one with nothing yet tracked it returns an empty list, and both gates fail closed on it — the copyright guard's first case asserts the tracked set is non-empty ("a guard over an empty set proves nothing",tests/repo/copyright-guard.test.ts), and the imports gate refuses an empty sweep root by name. Neither state is a green ladder.
The warning that saves a day
The seat range and the cfgSuffix in the registry are trusted as-is — nothing
cross-checks them against your blob. A wrong maximum player count admits a channel
your blob cannot seed; a wrong cfgSuffix wedges every channel part-filled. The
machine check (requireRegistry()) catches a pin mismatch, not a bad suffix — so
get these two right yourself. The rules blob explains the config
format, and Hosting & registration covers the
registry side.