Commit-reveal

Simultaneous moves on a strictly turn-based protocol, at N seats — the commitment, the reveal window, and the seat that never opens theirs.

Raw markdown →

Commit-reveal: simultaneous moves on a strictly turn-based protocol

Read SKILL.md §4 first. It teaches turn-interleaving — P0's input is stored as pending_input, P1's submission applies both together — and that is the right pattern for a physics batch, where the second submitter gains nothing from seeing the first. This file is about the case it cannot express.

The limit, stated plainly: P1 can read P0's stored input out of the state before choosing. Every participant holds the full signed state (see HIDDEN-INFORMATION.md §1). So turn-interleaving is fair exactly when the choice is not the information — and broken the moment it is. A thrown attack, a bid, anything rock-paper-scissors shaped: the second mover simply looks.

The fix is a commitment. Every seat publishes a hash of its choice; only once every seat has committed may anyone reveal. EXAMPLES.md covers the simpler cousin — commit-reveal as a one-shot randomness beacon for "who starts?" (arcade-xayaships). This file is the harder one: commit-reveal as a per-round move protocol at N seats, where the hard part is what happens when someone never opens theirs.

The reference implementation is arcade-vector-sumo (private during the curated phase — access on request). Every path below is inside that repo.

1. The preimage, domain-separated and pinned both ways

39 bytes, and every field is load-bearing:

SHA-256( "VSM1" || u8 seat || u8 action || u8 direction || salt[32] )

COMMIT_TAG, SALT_BYTES and COMMIT_PREIMAGE_BYTES in rules/crypto/commitment.hpp fix the tag, the 32-byte salt and the total length; rules/crypto/commitment.cpp builds it; src/lib/vector-sumo/commitment.ts is the TypeScript twin. The seat byte inside the preimage is what stops a digest being lifted from one seat and replayed by another.

2. Two implementations of one hash is a fork waiting to happen — pin them

Because the client must build the same digest the blob will check, this game has the hash twice. That is exactly the drift WASM.md §A4 exists to catch, and the answer here is a golden-vector file: blob/tests/commitment_vectors.json pins both sides against the same cross-language vectors, plus the published FIPS/NIST known answers. A drift becomes a red test rather than a consensus bug found in production.

HIDDEN-INFORMATION.md §3 documents the other legitimate answer — delete the TypeScript copy and call a blob export instead. Pick one. Do not pick neither.

3. Salt generation must fail closed

32 bytes from the platform CSPRNG, and an explicit InsecureRandomnessError rather than a Math.random fallback (src/lib/vector-sumo/commitment.ts), with an availability probe so the UI can disable the lock-in button with a reason instead of throwing on the click. A predictable salt makes the commitment openable by anyone, which silently removes the entire protocol while leaving it looking like it works.

Note the primitive: getRandomValues, not crypto.subtle. That choice is deliberate and worth copying — getRandomValues is available on plain HTTP over a LAN IP, where crypto.subtle is not (PITFALLS.md row 58), and it is the only randomness this protocol actually needs.

4. The two-phase turn machine

PHASE_COMMIT = 0 / PHASE_REVEAL = 1 (the phase enum in rules/arcade_core.hpp). In PHASE_COMMIT the commits list must be a strict prefix of the rotation, in rotation order; in PHASE_REVEAL the commits cover the whole rotation and reveals is filling the same way (the State struct's documentation in rules/arcade_core.hpp, enforced in rules/arcade_core.cpp). That prefix rule is what makes the half-finished states representable and checkable rather than merely conventional.

5. Phase pinning is the whole security property

Both directions are rejected in applyMove (rules/arcade_core.cpp), and the comment there names the stake: an early reveal — before every commitment is fixed — is the exact attack this protocol exists to stop, and a late commit would be a second choice made after seeing an opening.

A commit must also leak nothing: its payload is the 32-byte digest and action / direction must both be zero, or it is rejected (the MV_COMMIT branch of applyMove). The last commitment in the rotation is what flips the state to PHASE_REVEAL — "every choice is now fixed, so and only so it is safe to start opening them" — and the reveal half restarts at the top of the same rotation.

6. The check the protocol rests on

Recompute the digest from what was just revealed and require equality against what that seat locked in (the computeCommitment / digestsEqual check in applyMove). A wrong action, a wrong direction, a wrong salt, or a digest lifted from another seat all fail there. The comparison is constant-timedigestsEqual in rules/crypto/commitment.cpp OR-accumulates the differences rather than returning early.

7. Resolution and tie-breaking

The last reveal resolves the whole round at once (applyMove calls vsumo::resolveRound in rules/game/update.cpp), after which commits and reveals are cleared and the phase returns to COMMIT. Resolving on the last reveal rather than incrementally is what makes the round genuinely simultaneous: no partial application can be observed.

Tie-breaking at the round cap is decideBoutOnRoundCap() (rules/game/update.cpp) — nearest to centre, then fewer edgeRounds, then a drawn bout. Have an explicit answer for the cap; a simultaneous-move game that can loop forever will.

8. When a seat commits and never reveals — the section that must not be skipped

resolveTimeout() (rules/arcade_core.cpp). Read it in full; the summary:

  • The in-flight round is ABANDONED, whichever half it was in. Every commitment and every reveal is discarded and the phase returns to COMMIT. The reason is stated in the source and is the whole point: a round that never resolved leaks nobody's hidden choice. If you instead resolved the round with what you had, the honest players who already revealed would have their choices exposed by being the only ones standing.
  • No round is consumed — the round counters are untouched.
  • sequence still increments. A timeout resolution is a protocol step like any other, and the state it produces must order strictly after the one it replaced, or a peer holding the pre-timeout state could out-rank it. This is why the packed state carries sequence explicitly: the host requires arcade_turn_count not to regress across a timeout reinit (PITFALLS.md row 18).
  • The timed-out seat is ejected and no longer standing. ≤ 1 live seat ends it — that seat wins, or a draw if none are left.

9. The client must refuse to fabricate

If the salt is missing, or does not open the commitment the state holds for this seat, the channel does not invent a reveal. There is none it could invent that the judge would accept, and guessing only burns the turn. It surfaces a typed failure — missing-secret, commitment-mismatch, no-commitment, no-randomness — to the HUD and returns null, letting the round stall into the ordinary dispute/timeout recovery (src/lib/vector-sumo/vector-sumo-channel.ts).

10. Salts must survive a reload, and must never be reused

A page reload between committing and revealing is not an edge case — it is a tab refresh, a phone locking, a browser reclaiming memory. Lose the salt and the seat cannot reveal at all. So it is persisted, keyed by (channelId, seat, round, ejected-epoch) (src/lib/vector-sumo/round-secret-store.ts, whose header explains each part).

The epoch term is the subtle one and the reason this section exists: a timeout restarts a round under the same round number, so (channel, seat, round) alone is not unique across a timeout. If this seat had already revealed before the timeout, its choice for that number is public — and reusing the salt would republish a digest every opponent can already open. The epoch changes on ejection and on nothing else, so a retry or a reload within one round keeps the same salt, which is required (a fresh salt there would orphan the digest already on chain).

11. A rung-out seat still plays

A seat with nothing left to do must keep committing with a real salt, and may only reveal an inert acknowledgement (the inertAck check in applyMove). Otherwise the wire leaks who is still alive: a seat that stopped committing would be identifiable without anyone opening anything.

12. Where this connects

  • SKILL.md §4 — turn-interleaving, and the limit that sends you here.
  • HIDDEN-INFORMATION.md — the same machinery used to hide state rather than a move.
  • EXAMPLES.md — the coin-flip cousin (arcade-xayaships), and why both rows exist.
  • ARCADE.md §6 — what a timeout settles at each seat count, which is where §8 lands.
  • PITFALLS.md rows 58, 61, 62 and 63.

Authority for this file: arcade-vector-sumo/{rules/arcade_core.hpp, rules/arcade_core.cpp, rules/crypto/commitment.hpp, rules/crypto/commitment.cpp, rules/game/update.cpp, blob/tests/commitment_vectors.json, blob/MANIFEST.md, src/lib/vector-sumo/{commitment.ts,round-secret-store.ts,vector-sumo-channel.ts,sha256.ts}}. The commitment format is a consensus surface: read the source before you fix yours, because changing it on a live game costs a new gameType.