Submissions on arcade.xaya.io are closed during the curated phase. The submissions service is switched off here — Submit is hidden and
/api/submissionsanswers 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:
| File | What it is |
|---|---|
blob/rules.wasm | Your rules blob (the judge). |
blob/rules.wasm.sha256 | Its hash — the chain pins the blob by this. |
dist/bundle.tar.gz | The UI bundle, from ./scripts/build-export.sh --bundle. |
dist/bundle.tar.gz.sha256 | The 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_IDset to that plane's sharedg/move namespace — thegameIdany game's/arcade-config.jsonserves there:xarcon the playground (test-arcade.xaya.io),arcbetaon arcade.xaya.io — not your game type:NEXT_PUBLIC_GAME_ID=xarc ./scripts/build-export.sh --bundleBuilt 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'sRuntimeConfigGateblocks 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 inarcade-platform/games-host/src/bake.tsand re-exported bypreflight.ts, so the upload gate can never be looser than the bake). - game type — your
g/wire id / registry key (the value ofGAME_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 range —
min..maxplayers, satisfying2 ≤ min ≤ max ≤ 4(MAX_SEATS, mirroring the chain'sMAX_BOARD_PLAYERSclamp — 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:
| part | kind | required | content |
|---|---|---|---|
rules | file | yes | blob/rules.wasm |
rulesSha | file | yes | its .sha256 sidecar file (a bare digest, or the sha256sum line, inside) |
bundle | file | yes | dist/bundle.tar.gz |
bundleSha | file | yes | its .sha256 sidecar file |
card | file | no | the card image; a zero-byte part is treated as absent |
meta | text field | yes | the facts above as one JSON string |
resubmitToken | text field | playground re-uploads only | the 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.
gameTypeis 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.slugis 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 inRESERVED_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.
/submitrefuses 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 orcfgSuffixchange 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):
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.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.onchain— one admin move carrying both theregrow and theblobbody. 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.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/gamesand/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 onSITE_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.verify— the games-host manifest check always runs; the game-origin and/play/<slug>checks run when the operator has wiredGAMES_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.