Builder docs

The rules blob

The wasm judge contract: the arcade_* ABI, the pinned limits, and a reproducible build.

Your rules.wasm is the game's judge — the one component the chain adjudicates. It is a freestanding, zero-import WebAssembly reactor that exports the frozen arcade_* ABI. The host GSP is game-agnostic: it loads your blob by its sha256 and calls these exports and nothing else.

The authority for every fact on this page is the judge itself: arcade-platform/engine/judge/wasm_judge.cpp (WasmJudge::HasArcadeAbi and the per-call limits) and the frozen arcade-platform/docs/ARCADE-ABI.md. Where a house lint disagrees with the judge, the judge wins. (/repos says what every xaya/* repository is and whether you can clone it today; #builders on the Xaya Discord is where to ask about one.)

The export surface

The chain requires exactly these, and each must be present and of the right kind:

  • 12 consensus arcade_* functions (each a real exported function): arcade_alloc, arcade_free, arcade_parse_state, arcade_release, arcade_is_valid, arcade_whose_turn, arcade_turn_count, arcade_is_finished, arcade_winner, arcade_apply_move, arcade_initial_state, arcade_resolve_timeout.
  • _initialize — the reactor entry point (-mexec-model=reactor synthesizes it).
  • An exported linear memory named memory (wasm-ld exports it by default).

A same-named export of the wrong kind (a global or table called arcade_winner, a function called memory) fails the gate. Extra exports are harmless — the host's own comment says so. Two optional additive exports are recognised when present and safely ignored when absent (they are not required):

  • arcade_share_weights — a per-seat share-weight vector for multi-survivor payouts.
  • arcade_ejected_mask — a bitmask of seats ejected by an on-chain timeout.

The hard limits

  • Zero imports. Any import at all is a reject — it is a determinism and dependency risk. An undefined symbol is already a hard link error, so the zero-import discipline is enforced at build time too.
  • Fuel: 66,300,000 per metered call (kFuelCap, engine/judge/wasm_judge.hpp). Fuel is reset to the full budget before every call, so each arcade_* invocation is metered separately. A call that exceeds it traps, which is a reject.
  • Memory: 64 MiB. A blob that tries to grow past 64 * 1024 * 1024 bytes (kMemoryLimitBytes, engine/judge/wasm_judge.cpp) is refused deterministically — a reject, not a host out-of-memory. The same store limiter pins instances = 1 and memories = 1 (wasm_judge.cpp).
  • Pinned deterministic config, applied identically at register-time and runtime so a blob that passes the gate compiles the same way when it runs: fuel on, NaN-bit canonicalization on, bulk-memory on; SIMD, relaxed-SIMD, reference-types, multi-memory, memory64, threads, GC, function-references, and tail-call all off.

Reject, never crash

Every failure is mapped to the export's reject value — the host never crashes on a bad blob. A trap, an out-of-fuel, a memory-limit hit, a missing export, a wrong return kind, or a bad handle all resolve to reject: arcade_parse_state returns handle 0, arcade_apply_move / arcade_initial_state / arcade_resolve_timeout return a negative length, the boolean queries return their safe default. Write your blob to return a reject value, not to trap, but know that a trap is caught either way.

arcade_alloc — the conservative contract

The host copies input bytes (a move, a config) into your linear memory by calling arcade_alloc(len) and treats a returned offset of 0 as failure0 is the null / out-of-memory sentinel. It re-reads the memory pointer after the alloc, since your malloc may have grown and relocated memory.

Two things blob authors get wrong:

  • Never return 0 from arcade_alloc for a buffer you actually allocated — reserve 0 for failure only.
  • Both judges ask for a zero-length buffer at its true length. The wasmtime host and, since SDK 0.16.0, the browser judge (sdk/src/lib/wasm/packed-judge.ts) call arcade_alloc(0) for an empty move, state or cfg, and both treat 0 as failure. So arcade_alloc(0) must return a valid non-zero offset and must not trap; the registration gate makes exactly that call and refuses a blob that answers 0. (A bundle on SDK 0.15.5 or older asks for arcade_alloc(len || 1) instead, which lets a naive malloc(0) pass in the browser and fail on chain.)

The host also allocates its output buffer through your arcade_alloc (an 8192-byte cap for apply / initial-state / resolve-timeout — kCap in wasm_judge.cpp, the authority for that number; the template's test host blob/tests/wasm_host.hpp hands the blob the same cap), so arcade_alloc(8192) must succeed too.

participants is authoritative, and 1 is a legal value

arcade_parse_state and arcade_initial_state each take the seat count as an explicit argument, and that argument is the authority: your blob must reject state bytes whose own participant field does not echo it, because byte-equality of a state is only canonical once the echo is pinned (arcade-platform/docs/ARCADE-ABI.md).

The count you are handed is not always the count you registered. Because a submission's seats.min is at least 2 (arcade-platform/submissions/src/preflight.ts), compiling the seat count in looks safe — the blob builds, its own unit tests pass, and it clears the register gate. Then the first caller that wants a smaller world gets a reject.

That caller is not the chain. A freshly created channel is stored with an empty state — HandleCreateChannel calls Reinitialise(meta, "") (arcade-platform/engine/gsp/logic.cpp) — so the one-seat "waiting for opponent" board is never a state your blob produces; and every count the referee does pass to arcade_initial_state is clamped into your registered min..max before the channel can open at all (ParseCreateChannelMove, same file). See the note at the end of this section.

In the template that caller is the e2e harness. e2e/harness.ts seeds every scenario with judge.initialState(numPlayers, cfg) and throws when the answer is null, and scenario 7 builds its world with buildWorld(1, 42) (e2e/suite.ts). So a hard-coded seat count does not fail an assertion — it kills the scenario at setup, reported as THREW: initialState rejected (numPlayers=1, seed=42). And if what you hard-coded is your maximum rather than 2, the other eleven scenarios (all buildWorld(2, …)) die the same way in the same run.

Accept every count in [1 .. seats.max], and answer the one-seat board honestly: nobody has the turn, no progress has been made, and it is a valid state. The template spends three lines of rules/arcade_core.cpp on it — whoseTurn returns NO_TURN when n == 1, turnCount returns 0, and isValid returns true, commented "1-participant 'waiting' placeholder".

Both shipped runtimes happen to short-circuit the empty pre-start state before it ever reaches the blob — the on-chain referee answers NO_TURN, turn count 0, not-finished and no-winner itself (arcade-platform/docs/ARCADE-ABI.md), and the template's browser-side rules return the SDK's PlaceholderBoardState for a zero-length state (src/lib/xayaman/xayaman-packed-board-rules.ts, the class itself SDK-owned since 0.9.0). That is the behaviour of two call sites you do not control, not a property of the ABI. Answer for 1 anyway.

arcade_free and arcade_release must be real implementations

Both are in the required export set, and the host GSP never calls either: each parsed handle owns its own fuelled wasm instance, and the instance is dropped whole (arcade-platform/docs/ARCADE-ABI.md). So an empty arcade_free and an arcade_release that just returns will pass the register gate and every on-chain test you can run, and nothing on the platform side will ever tell you otherwise.

The SDK's in-browser judge does call both, against one long-lived instance shared by every handle. It frees each arcade_alloc'd input and output buffer on every path, success or reject (arcade-platform/sdk/src/lib/wasm/packed-judge.ts), and releases each handle as soon as it is done with it — a discipline that only holds if your half of it is real. Stub them and the leak is client-only: the game plays correctly for a while, then degrades in the browser as linear memory grows match after match.

The template's are one line and five (rules/arcade_abi.cpp). Write yours properly the first time, then go play your own game in a browser on the playground before you submit — production has no self-serve update path.

At more than two seats, a timeout becomes a reinit

At two seats a timeout settles the channel — and the host asks your blob what that settlement is, with one documented exception you have to design around: on the 2-seat dispute-expiry path a finished state reporting arcade_winner == -2 (draw) with no share weights is treated as unusable and settles winner-take-all to the seat that did not time out, because honouring -2 there would route a unilateral timeout into the registry's draw policy and could pay the whole pot to the operator. So spell a 2-seat draw as [5000, 5000] in arcade_share_weights, which splits it between the players; weights outrank the arcade_winner readout on every close path (arcade-platform/docs/ARCADE-ABI.md §2).

At three or four, when a dispute expires and your arcade_resolve_timeout output is not finished, the host installs those bytes as the channel's new reinitialisation basis — not another state proof (arcade-platform/engine/gsp/logic.cpp, the UpdateMetadataReinit + Reinitialise arm). The reinit id rotates, which kills every pre-ejection signature, and the required signers become the participants of the parsed reinit state minus the bits of your arcade_ejected_mask.

That puts obligations on your blob the host does not validate:

  • Never return a negative for the on-clock seat. The seat you are passed is the disputed state's own arcade_whose_turn answer; rejecting it leaves the dispute pending forever.
  • The post state must be finished or expose a live, non-ejected whose_turn. A not-finished NO_TURN state can never be disputed again — the channel wedges permanently.
  • arcade_turn_count must not regress across the resolution.
  • Carry the ejection mask through arcade_apply_move. The signer set is re-read from every state; a move that drops a set bit re-adds a seat that never signs again.

Survivors can always continue even without the mask — a reinit-anchored proof needs no signature coverage at all — but without it proofs can never be pruned: they grow one transition per move forever, and a pruned proof is rejected on chain. The full contract is in arcade-platform/docs/ARCADE-ABI.md §§2-3.

Registration-time checks

When the blob is registered on chain (run automatically when your submission is accepted; the blob admin move carries {sha256, z} where z is base64(compress(rules.wasm))), the GSP stores it only if all of these hold:

  1. z base64-decodes.
  2. It decompresses within the size cap of 8 MiB (kMaxBlobBytes = 8 * 1024 * 1024 of decompressed wasm, arcade-platform/engine/gsp/wagering.hpp — a zip-bomb guard).
  3. Its sha256 (of the decompressed bytes) matches the declared hash.
  4. It passes the ABI gate, WasmJudge::HasArcadeAbi: it compiles under the pinned config, imports nothing, and exports the full surface above.

HasArcadeAbi is the chain's verdict. Note it compiles and inspects the module — it does not instantiate or run it — so a blob that passes the gate can still be a "dead game" if a signature is wrong; that fails deterministically at runtime, never as a consensus fork.

Build it reproducibly

The blob is content-addressed, so the toolchain is part of the artifact. Build it in the pinned container — nothing on your host is used:

bash blob/build-blob.sh          # → blob/rules.wasm + blob/rules.wasm.sha256

The container pins wasi-sdk 24.0 by version and sha256 (x86_64-linux, blob/Dockerfile.blob-builder:16-23) and builds with clang++ -O2 -std=c++17 -fno-exceptions -fno-rtti -mexec-model=reactor, then strips debug info with a single fixed llvm-strip --strip-debug pass. Three things fix the output hash: the source list order, the strip command, and the output basename (rules.wasm is recorded in the wasm name section — never rename it). Full details are in blob/MANIFEST.md.

check-blob.sh is a house lint, not the spec

bash blob/check-blob.sh            # structural gate (fast, no docker)
bash blob/check-blob.sh --strict   # + exports outside the known set are FATAL (what CI runs)
bash blob/check-blob.sh --rebuild  # + clean-tree container rebuild reproduces the hash

check-blob.sh sorts exports into three tiers and treats them differently, so you can copy it into your own game without editing it:

tierexportsmissing one is
consensusthe 12 arcade_* functions + memory + _initializea FAILURE — the host rejects the blob
ABI-optionalarcade_share_weights, arcade_ejected_maska NOTE naming the fallback you just chose
housearcade_scripted_move (a helper export the chain never calls; nothing in the template's gates invokes it either — the determinism legs replay the committed moveHex of each trace)a NOTE

Only a missing consensus export fails. Exports outside all three tiers are export bloat: a WARN by default, fatal only under --strict. So a third-party game that ships none of the optional or house exports is fully conformant on chain, and this gate says so instead of stopping it. Do not read check-blob.sh as the spec. The spec is the frozen arcade_* ABI plus HasArcadeAbi.

Appendix: the exact signatures

Verified line by line against arcade-platform/docs/ARCADE-ABI.md §§1-3 and engine/judge/wasm_judge.cpp — wasm core types (i32/u32); every failure resolves to the reject value shown, per the "Reject, never crash" section above:

// §1 memory, handles, init
arcade_alloc(len: u32) -> ptr: i32                 // 0 = allocation failure
arcade_free(ptr: i32, len: u32) -> ()              // free an arcade_alloc'd region
arcade_release(handle: i32) -> ()                  // destroy a parsed-state handle
_initialize() -> ()                                // reactor init; called once, before anything else

// §2 parse — STRUCTURAL decode only; arcade_is_valid is the separate semantic answer
arcade_parse_state(participants: i32, ptr: i32, len: u32) -> handle: i32
//   handle >= 1, or 0 = reject. Reject on truncation, on trailing bytes, on an
//   over-cap repeated-field count, and on a participant count inside the bytes
//   that does not echo the `participants` argument.

// §3 queries (all take a live handle)
arcade_is_valid(handle: i32)    -> i32             // 1 valid / 0 invalid
arcade_whose_turn(handle: i32)  -> i32             // seat index, or -1 = NO_TURN
                                                   //   (finished game, or bad handle)
arcade_turn_count(handle: i32)  -> i32             // monotonically non-decreasing;
                                                   //   0 for a bad handle
arcade_is_finished(handle: i32) -> i32             // 1 / 0
arcade_winner(handle: i32)      -> i32             // -1 undecided, -2 draw, else seat

// §4 transitions (out = arcade_alloc'd by the host, cap = 8192)
arcade_apply_move(handle: i32, mv: i32, mvLen: u32, out: i32, cap: i32) -> i32
arcade_initial_state(participants: i32, cfg: i32, cfgLen: u32, out: i32, cap: i32) -> i32
arcade_resolve_timeout(handle: i32, timed_out_seat: i32, out: i32, cap: i32) -> i32
//   >= 0 : bytes written to out[0 .. cap)
//   -1   : rejected — no turn / wrong seat / bad move shape / bad cfg / bad handle
//   -2   : out buffer too small
//   Any answer outside 0..8192, INCLUDING -2, is terminal: the host never retries
//   with a bigger buffer.

// optional additive (recognised when present; omitting one is fully valid)
arcade_share_weights(handle: i32, out: i32, cap: i32) -> i32
//   writes one LE uint16 basis-point weight per seat — exactly 2*participants bytes —
//   and returns that length. Its buffer is 1024, not 8192 (wasm_judge.cpp, ShareWeights).
//   Any other length, or a negative, is read as "no weights": plain winner/draw settlement.
arcade_ejected_mask(handle: i32) -> i32
//   bit i set = seat i was ejected by an on-chain timeout; negative = "no mask".

The template's rules/arcade_abi.cpp is a complete worked implementation of this surface — start there.

Next: build your UI on The SDK.