Builder docs

Submitting your game

What a hand-over is made of — the artifacts, the facts, moderation, and what Accept automates — and where to rehearse it today.

Submissions on arcade.xaya.io are closed during the curated phase. The submissions service is switched off here — Submit is hidden and /api/submissions answers 503 — and the five first-party games on the shelf were registered by the operator. What this page describes is the flow the playground runs at /attach today: prove your game there, then get in touch in #builders on the Xaya Discord with your repository and your four registration values.

Submitting a game is self-serve through a form, behind a moderation gate. You upload a small, fixed set of artifacts and facts; your submission joins a queue; a moderator reviews it and presses Accept, and the go-live steps run automatically. The form is the hand-over — this page is what goes in it. Two of the facts you type into it are permanent, so there is a step before it: dry-run the whole submission on the playground first.

The artifacts

Four files, each with its detached sha256 sidecar — uploaded, not attached:

FileWhat it is
blob/rules.wasmYour rules blob (the judge).
blob/rules.wasm.sha256Its hash — the chain pins the blob by this.
dist/bundle.tar.gzThe UI bundle, from ./scripts/build-export.sh --bundle.
dist/bundle.tar.gz.sha256The bundle hash, so the moderator can verify the bytes.

Build the bundle for the namespace of the plane it will run on. The bundle must be built with NEXT_PUBLIC_GAME_ID set to that plane's shared g/ move namespace — the gameId any game's /arcade-config.json serves there: xarc on the playground (test-arcade.xaya.io), arcbeta on arcade.xaya.io — not your game type:

NEXT_PUBLIC_GAME_ID=xarc ./scripts/build-export.sh --bundle

Built without it, the compiled move namespace silently falls back to your own GAME_KEY. The bundle uploads fine, passes the hash pre-flight, and is accepted — then refuses to boot: the SDK's RuntimeConfigGate blocks the namespace mismatch with a clean refusal screen and no console error.

The bundle must come from the current template. Pre-flight streams index.html out of the archive and refuses any bundle that does not reference the placeholder base path /__arcade_base__/ (checkBundlePlaceholder, arcade-platform/submissions/src/preflight.ts), because registration rewrites that token into your real /g/<slug> mount. It also refuses a bundle whose script chunk mentioning arcade-config.json does not carry the same token in the same file (checkBundleConfigChunk) — that shape means a pre-0.5.2 SDK that fetches its runtime config from the origin root and could never find it under /g/<slug>/. build-export.sh --bundle with the vendored SDK satisfies both and refuses an override — see Overview.

The bundle must be playable by touch, and must say so. Declare touchControls or presentation.fullFrameOnTouch on the adapter and ship the arcade-manifest.json that states it at the bundle root — the only route an uploaded bundle has to say so; see The SDK. No pre-flight refuses a bundle over it, because the arcade would rather list your game than hide it: what it does instead is label a game that has not said Desktop only (for now) on its card and warn a player on a phone before they start or join a match in it. The playground's attach page reports the same thing back to you after an accept, so you find out there first. Test it at a phone-width viewport before you hand the game over — see Testing.

The form runs a client-side sha256 check of each artifact against its sidecar and warns you inline before the upload — a convenience only. The moderation service re-verifies every hash and is the authority; rules.wasm must be ≤ 8 MiB raw and bundle.tar.gz ≤ 64 MiB (RAW_WASM_MAX_BYTES / BUNDLE_MAX_BYTES, preflight.ts) — the same registration caps a manual hand-over meets.

You may also upload an optional card image of at most 2 MiB (CARD_IMAGE_MAX_BYTES, preflight.ts), recognised as PNG / JPEG / WEBP by its magic bytes rather than its name; no dimension is enforced anywhere. Omit it and the library shows the built-in vector art for your slug.

The facts

The registry facts your repo cannot supply for you — all fields on the form, and every bound below is arcade-platform/submissions/src/preflight.ts unless it says otherwise:

  • slug — your serving path segment: players visit /play/<slug> and your bundle is mounted at /g/<slug>/. Must match ^[a-z0-9-]+$ and be at most 32 chars (SLUG_RE / SLUG_MAX, defined in arcade-platform/games-host/src/bake.ts and re-exported by preflight.ts, so the upload gate can never be looser than the bake).
  • game type — your g/ wire id / registry key (the value of GAME_KEY). Must match ^[a-z0-9_-]{1,32}$ (GAME_TYPE_RE).
  • title — the display name shown in the games-host and the library, ≤ 60 chars (TITLE_MAX).
  • seat rangemin..max players, satisfying 2 ≤ min ≤ max ≤ 4 (MAX_SEATS, mirroring the chain's MAX_BOARD_PLAYERS clamp — the chain drops an out-of-range seat range with only a warning, so it is caught here instead).
  • cfgSuffix — an explicit choice: no suffix (null), or lowercase hex of even length and at most 128 chars (CFG_SUFFIX_MAX). The form makes you pick, because an empty suffix handed to a blob that expects one wedges every channel part-filled — the single most common registration mistake, and now impossible to make by omission.
  • GitHub repository URL — the one repo you forked from the template; must be https://. Plus a "repository is private" checkbox (tick it and the moderator will ask for read access).
  • listing copy — tagline ≤ 120 (TAGLINE_MAX), description ≤ 2000 (DESCRIPTION_MAX), how-it-works ≤ 2000 (HOW_IT_WORKS_MAX), at most 6 tags (TAGS_MAX) of ≤ 24 chars each (TAG_MAX), and an optional credit line ≤ 120 (CREDIT_MAX).

The same contract, without the form

The form is a convenience over an open endpoint: a multipart POST to /api/submissions takes the identical submission, which is how an agent uploads — and how the playground's own page does it. The part names, from arcade-platform/submissions/src/handler.ts:

partkindrequiredcontent
rulesfileyesblob/rules.wasm
rulesShafileyesits .sha256 sidecar file (a bare digest, or the sha256sum line, inside)
bundlefileyesdist/bundle.tar.gz
bundleShafileyesits .sha256 sidecar file
cardfilenothe card image; a zero-byte part is treated as absent
metatext fieldyesthe facts above as one JSON string
resubmitTokentext fieldplayground re-uploads onlythe token your previous upload returned (below)

All four artifacts are file parts, the sidecars included. A part counts as a file only when it carries a filename, so a text rulesSha=<hex> is refused with missing required file field(s): rulesSha even when the digest is right. meta is the opposite, a text field — read it from a file rather than writing it inline, because a ; inside an inline -F meta={...} ends the value at that character and the server answers meta is not valid JSON. The whole invocation against the playground:

curl -sS -X POST https://test-arcade.xaya.io/api/submissions \
  -F "rules=@blob/rules.wasm" \
  -F "rulesSha=@blob/rules.wasm.sha256" \
  -F "bundle=@dist/bundle.tar.gz" \
  -F "bundleSha=@dist/bundle.tar.gz.sha256" \
  -F "meta=<meta.json" \
  -F "resubmitToken=$RESUBMIT_TOKEN"        # re-uploads of a slug you already claimed there

--form-string "meta=$META" posts the same text field from a shell variable, with no @ or < interpretation. meta carries exactly the facts listed above, as one object: slug, gameType, title, seats: { min, max }, cfgSuffix, repoUrl, repoPrivate, tagline, description, howItWorks, tags, credit. Only repoPrivate and credit are optional, and cfgSuffix must be present even when it is null — a blob that takes no cfg bytes sends "cfgSuffix": null, never "" (validateMeta, preflight.ts). A full queue answers 429 before a byte of your upload is buffered (handler.ts) — back off and retry, do not loop.

What is permanent

Names are checked for the first time at upload, by pre-flight (arcade-platform/submissions/src/preflight.ts). There is no availability endpoint and no way to ask in advance; nothing reserves a name for you, on either plane.

  • gameType is burned forever once a submission is ACCEPTED. Accept sends the on-chain registry move, and from then on pre-flight refuses any submission carrying a game type the GSP registry already holds (preflight.ts). It can never be reassigned — not to you, not to anyone.
  • slug is a one-shot claim. It is refused if a live game, the site's content rows, or another non-rejected submission in the queue holds it (preflight.ts + handler.ts), and the first-party slugs in RESERVED_SLUGS (preflight.ts — the list grows as first-party games ship, so read it there) are refused outright with no exemption anywhere.
  • A rejected submission releases both. The claim checks skip rejected rows (crossPendingIdentity, arcade-platform/submissions/src/store.ts), so a decline is not a dead end: fix what the reason says and re-upload under the same slug and game type. Only acceptance is terminal.
  • There is no self-serve update path. /submit refuses a slug or game type that is already live, so there is no way for you to ship v2 of an accepted game: a fix after acceptance costs a new slug and a new game type. Changing a shipped game is an operator action, and a blob or cfgSuffix change is only sanctioned at zero open channels — Hosting & registration §5.

Dry-run it on the playground first

The cheapest place to discover all of that is not here. Build the four artifacts, choose the facts, then upload them to test-arcade.xaya.io/attach — a disposable public plane that auto-accepts: the same pre-flight, the same bake and a real on-chain registration run against your bytes, with no moderator and no queue, and in seconds your game is live there twice over — at /play/<slug> inside that plane's arcade shell, and bare at /g/<slug>/. What that plane is — and what it cannot prove (a real wallet, a real name, the chat lobby) — is Overview and Testing; what matters here is the mechanics of the upload itself.

The page at /attach is the upload form (that plane's /submit redirects to it). It asks for slug, game type, seats, cfgSuffix, a repo URL and the four files. The repo URL is required — the pre-flight rejects anything that is not an https URL, so there is no honest default to leave it out — and that format check is all that happens to it: it is recorded as the listing's repository link and never fetched, so attaching does not wait on your source being published. Everything else in the listing — title, tagline, description — is filled in for you, plainly labelled as a playground attach and not a listing, because it now shows on that plane's /games card. A collision on either name comes back in seconds instead of after a moderator's queue.

The upload response is your receipt. The 201 carries id, statusUrl (/api/submissions/<id>, the poll target), slug, gameType, bundleSha256 and rulesSha256 — the hashes of the bytes the platform actually stored — and GET /api/submissions/<id> repeats them, so you can verify them against your own build's sidecars at any time. The id is the whole handle on your row; nothing looks a submission up by slug. The 201 is the upload, not the go-live: poll statusUrl until status is accepted or a step reports failed, typically well under a minute. And bundleSha256 is not the hash the shelf shows: accept rewrites /__arcade_base__ to /g/<slug> inside the bundle and registers the result, so the sha256 the games list carries for your slug (/api/games) is the baked bundle's — different by design, not a mismatch. rulesSha256 is the same on both sides.

Keep the resubmitToken it returns — out of the repository and out of anything you share, because it is the credential that replaces your game on that plane. It comes back in the upload response, is printed on the page, and is stashed in that browser under pg_token_<slug>; on your next upload you send it back as a multipart field named resubmitToken (arcade-platform/submissions/src/handler.ts). It is the only thing that lets the same (slug, gameType) be uploaded to the playground again after a fix — without it the three collision checks (slug in use, game type claimed by another queued submission, game type already on that GSP) are hard errors and your fix-and-resubmit loop ends on iteration one. Lose the browser profile and you lose the token, with no recovery short of a wipe. It exempts those three collisions and nothing else: the reserved slugs, the size caps, the wasm ABI walk, the sha256 verification and the card check are identical to production (preflight.ts), which is the whole point of a dry run. The field exists only where auto-accept is on; the real door never reads it.

Then keep those exact bytes for the hand-over. Run sha256sum dist/bundle.tar.gz blob/rules.wasm and compare both against what you dry-ran. If either differs you are handing over something you never tested — and the browser loads the registered blob, so a bundle built against a different one boots, joins a channel, and then renders garbage.

And the playground reserves nothing. It is a different chain: a slug or game type that was free there is not held for you here, and nobody will tell you if it goes between your dry run and your upload.

Moderation and what Accept does

Your submission moves through pending → accepted, or rejected (with a reason) or failed (an Accept step erred; the operator can retry). A clean upload returns a status URL, /submit/<id>, and that id is your entire recovery surface: there is no login, no listing and no lookup by slug, so losing it loses the submission while the queue keeps holding your names. The status page shows the pre-flight results, your queue position, any rejection reason, and the live step log while Accept runs.

When the moderator presses Accept, the go-live pipeline runs automatically and names each step in that log (arcade-platform/submissions/src/accept.ts) — the same steps an operator once did by hand (Hosting & registration is their manual reference):

  1. preflight — every upload check again, with the game-type collision upgraded from a warning to a hard error, so a submission that sat in the queue while the world changed cannot slip through stale.
  2. slots, archive, bake — cheap local checks, deliberately ordered before anything irreversible: a free games-host slot, an archive that actually opens, and the /__arcade_base__ placeholder rewritten to your real /g/<slug> mount. The baked bundle is the serving identity from here on; the sha256 you uploaded stays on the submission as provenance.
  3. onchain — one admin move carrying both the reg row and the blob body. A mined transaction is not acceptance: the step polls the GSP registry every 2 s for up to 120 s until the row appears (chain.ts), because the GSP validates the blob and the seat/cfg constraints itself and drops a registration it rejects with only a node-side warning.
  4. register, content — games-host serves the baked bundle at /g/<slug>/, and the shell content row is written into the site's runtime registry so /games and /play/<slug> show your game with no site rebuild. Both live planes run a shell, so both write the row: an accepted game on the playground is live at /g/<slug>/ and /play/<slug>, exactly as on the arcade. The step is gated on SITE_INTERNAL — a plane that names no shell skips it, because the row's only consumer is a shell, and there the play URL is the bare /g/<slug>/ mount.
  5. verify — the games-host manifest check always runs; the game-origin and /play/<slug> checks run when the operator has wired GAMES_HOST_INTERNAL / SITE_INTERNAL, so all three are checked before you are marked live.

Accept makes your game live for free play. Wagering is not part of Accept — it stays a separate, manual operator action (Hosting & registration §4), matching free-play-first.

Standards the submission must meet

  • Naming. The first-party games are Xayaman, Xayaships, Xayatrails, Dungeon Channel and Vector Sumo. Legacy copyrighted titles must never appear anywhere — the repos carry a copyright guard that sweeps every tracked file (path and content, including binaries and vendored tarballs). Keep it; a leak fails CI.
  • No wallet in the game. The shell is the only signer. Your game holds no wallet, makes no contract calls, and does no signing — it sends moves over the bridge and the shell signs them. See The SDK.
  • Free play first. A complete game is playable with no stake. Wagering is additive, operator-enabled, and never a prerequisite — your game code stays wager-free.
  • The guards stay. The copyright guard, the three determinism legs, and the CI jobs ship with the template for a reason. Do not remove them. See Testing.

Ready? Attach it on the playground, play a full match at every seat count you mean to declare, then raise it in #builders.