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: choosing in secret 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. A seat publishes a hash of its choice and opens it later; nobody can change the choice in between, and nobody learns it early. The hard part is not the hash. It is the phase order that makes the opening safe, and what happens when someone never opens theirs.

Two first-party games use it, for different jobs, and you want both:

  • arcade-xayaships commits to things decided once. A 32-byte seed makes a fair coin for "who starts?", and a hash of the fleet placement and a salt keeps the fleet hidden until the end. Two seats, a handful of commitments in the whole match.
  • arcade-dungeonchannel commits to a choice every round. Each live seat commits its tile and action, every seat publishes, and only then does each disclose, in three sequential passes over the seats. Its engine seats up to four, with a commitment per seat per round.

Every path below is inside the repo named in front of it.

1. What goes in the preimage

Hash exactly what is being chosen, plus a salt, plus whatever stops the digest being lifted somewhere it was not made for.

  • Xayaships hashes LE64(position) || salt for the fleet and the bare 32-byte seed for the coin (applyPositionReveal and applySeedReveal in arcade-xayaships/rules/ships_core.cpp). Only seat 0 commits a seed hash (seed_hash_0); seat 1 sends its seed in the clear, and each fleet hash sits in its own slot. The preimage does not name the seat, which is tolerable there only because a fleet hash copied from the other seat cannot be answered for: the copier cannot answer the shots against it consistently. A choice made every round cannot lean on that, so its preimage must name the seat, as Dungeon Channel's does.
  • Dungeon Channel names the seat and the round and tags the whole thing, so a commitment can never be replayed across rounds, seats or purposes (computeCommitment in arcade-dungeonchannel/rules/dungeon_core.hpp; HIDDEN-INFORMATION.md §2 has the layout and why to tag every preimage from the first line of code).

2. Two spellings of one hash is a fork waiting to happen: pin both or delete one

The client has to build the digest the blob will check, so a naive game has the hash twice, once in C++ and once in TypeScript. That is exactly the drift WASM.md §A4 exists to catch. The two legitimate answers; pick one, and do not pick neither:

  • Delete the TypeScript copy and call a blob export, as Dungeon Channel does (HIDDEN-INFORMATION.md §3).
  • Pin the copy against vectors generated from the C++ authority, so a drift is a red test rather than a consensus bug found in production. Dungeon Channel does this for the few public folds the blob does not export (HIDDEN-INFORMATION.md §3).

Xayaships takes the second road for both of its hashes. Its client builds the fleet digest SHA256(LE64(position) || salt) (positionHashOf in arcade-xayaships/src/lib/ships/ships-arcade-channel.ts) and the seed digest SHA256(seed) itself, and arcade-xayaships/tests/lib/ships/ships-arcade-channel.test.ts asserts that the COMMIT_1 and COMMIT_2 moves it builds match, byte for byte, the trace the native core generates (arcade-xayaships/blob/tests/ships_trace.json). Whichever road you take, test your client's digest against the blob's before you rely on it: a client that gets the preimage wrong commits happily and finds out only when it can no longer open the fleet.

3. Salts: unpredictable, durable, and never reused once public

Fail closed on randomness. Take 32 bytes from the platform CSPRNG and never fall back to Math.random. A predictable salt makes the commitment openable by anyone, which silently removes the whole protocol while leaving it looking like it works. Use crypto.getRandomValues, not crypto.subtle: it is available on plain HTTP over a LAN IP, where crypto.subtle is not (ARCADE.md §3, PITFALLS.md row 58). Xayaships draws its salt and seed that way in confirmPlacement (arcade-xayaships/src/lib/ships/ships-arcade-channel.ts).

A retry within a round reuses the salt. A fresh salt on a resubmit would orphan the digest already signed, so the salt is a function of the commitment slot (the round), never of the attempt.

The salt must survive a reload. A tab refresh, a phone locking or a browser reclaiming memory between committing and revealing is not an edge case, and a lost salt is a commitment nobody can open. Two shipped answers:

  • Store the secret before you commit to it, and commit only if it reads back. Xayaships writes the fleet, salt and seed to localStorage keyed by channel and player name, and persistSecret reports false unless the entry reads back exactly, in which case no commit is built (arcade-xayaships/src/lib/ships/ships-secret-store.ts). HIDDEN-INFORMATION.md §1 says why the key includes the seat.
  • Derive every salt from one persisted seed, so only the seed and the plaintext path need to survive a reload. Dungeon Channel's salt_r comes from the seed and the round (deriveSalt in arcade-dungeonchannel/rules/dungeon_core.hpp), but it still keeps a per-round path log, which no derivation can rebuild, so that log must persist too. secret.ts also offers deriveSeedFromRecoveryMaterial, which rebuilds the seed from something the player can reproduce; the shipped game does not pass recovery material, so there a cleared tab still forfeits the match.

It must also last until the channel's close is final, not only until the close is mined: a reorg can take the close out and put the channel back at an older state, where the secret is still owed. The adapter's optional channelStorage has its forget(id) called once the close is final (ARCADE.md §3 step 3, "Saved moves and the saved record").

A salt that has been published is spent. Revealing salt_r must say nothing about salt_{r+1}, which is why the salts are derived through a hash and only individual ones are published. If an event makes the seed itself public mid-match, the next commitments need a new secret: Dungeon Channel folds its master through epochMaster (dch_epoch_master) at a restart, so that nothing published before it opens anything after it (arcade-dungeonchannel/rules/dungeon_core.hpp). Reusing a salt for a choice whose digest everyone already holds republishes the choice.

Refuse to fabricate. When the secret is missing, or does not open the commitment the state holds, the client must not invent a reveal: there is none the judge would accept, and guessing only burns the turn. Xayaships' auto-move returns null in every phase that needs the secret and finds none (maybeAutoMove in arcade-xayaships/src/lib/ships/ships-arcade-channel.ts), so the match stalls into ordinary dispute and timeout recovery rather than submitting a move the blob will reject. Surface the failure to the player instead of leaving a silent stall.

4. Phase pinning is the whole security property

An opening is only safe once every commitment it could be chosen against is already fixed, so the rules must reject each move outside its own phase, in both directions.

  • Xayaships derives its phase from which fields are populated (getPhase in arcade-xayaships/rules/ships_core.cpp), never from a stored byte, and every transition begins with a phase check: the seed is revealed only after both seats have committed (applySeedReveal), and the fleet only once play is under way (applyPositionReveal).
  • Dungeon Channel runs PHASE_COMMIT, then a flight pass, then PHASE_DISCLOSE, and applyMove answers REJ_WRONG_PHASE to a commit outside the commit pass and to a disclosure outside the disclose pass; the disclose pass also takes a challenge or a dispute filed in a disclosure's place (arcade-dungeonchannel/rules/dungeon_core.cpp). The header comment of rules/dungeon_core.hpp states the stake: a seat that commits later in the pass learns nothing from the seats before it, and a seat that discloses later cannot adapt its position to what it has seen, because the position was already committed. It can only lie, and the audit turns a lie into a loss.

A commit must also leak nothing: whatever it carries besides the digest must not reveal the hidden choice. Anything else in the payload has to be independent of the choice or blinded, and any slot meant for the choice itself must be required to be zero. (Xayaships' cleartext seed_1 is fine because the seed is not the secret.)

The same goes for the pattern of commits. Where whether a seat still has a move is itself hidden, a seat with nothing left to do should keep committing, with a real salt: a seat that stopped would show the wire who is still alive without anyone opening anything. Where it is public state anyway, nothing is hidden by it: in a play pass a dead Dungeon Channel seat may only acknowledge (REJ_SEAT_DEAD).

5. The check the protocol rests on

Recompute the digest from what was just revealed and require it to equal what that seat locked in. A wrong value and a wrong salt both fail there, and so does a digest lifted from another seat, provided the preimage names the seat (§1). Xayaships does it twice: applySeedReveal hashes the revealed seed against seed_hash_0, and applyPositionReveal hashes LE64(position) || salt against the revealing seat's committed hash. A mismatch rejects the move; it is not a loss, so a client bug cannot cost the match by itself. Dungeon Channel checks an immediate opening against the round's commitment on the spot and answers REJ_OPENING_MISMATCH (arcade-dungeonchannel/rules/dungeon_core.cpp).

One more rule travels with it: the absent value must not be a legal opening. Xayaships uses zero for "this seat has not revealed", so a fleet of zero would end the game without advancing the turn count; applyPositionReveal rejects it, and a seat that committed to the empty board can only lose on the clock.

6. Resolution

Decide what the reveal does before you decide how to commit. Xayaships' seed reveal resolves the coin from both seeds, each fixed before anyone could use the result (applySeedReveal; the formula is in EXAMPLES.md). Dungeon Channel resolves a round only when the last live seat completes the disclose pass, so no partial application of a round can be observed (arcade-dungeonchannel/rules/dungeon_core.hpp, "THE ROUND"). A game that can loop forever needs an explicit end for that too.

7. When a seat commits and never reveals: the section that must not be skipped

A commitment scheme is only sound if refusing to open is punished (HIDDEN-INFORMATION.md §7). Nothing in the platform decides that for you. The two shipped answers:

  • Xayaships: the clock settles it. A seat that will not open runs out of time, and resolveTimeout in arcade-xayaships/rules/ships_core.cpp names the other seat the winner. That is why the coin is safe even though seat 0 reveals last and can compute the result first: walking away from a coin it dislikes forfeits the whole game rather than re-rolling it.
  • Dungeon Channel: a staller is ejected, and a live one abandons the play pass it stalls. resolveTimeout in arcade-dungeonchannel/rules/dungeon_core.cpp ejects and convicts the timed-out seat. When that seat was still alive in a play pass, the in-flight pass is abandoned and never replayed: replaying it would let a seat that already saw the round's published material commit again against it. The abandoned round is sealed with a salt-free marker every party can compute, so no honest seat's chain is left a link short. A dead seat that times out abandons nothing: in a play pass it owes only a forced, data-free acknowledgement, so the pass advances as if that had landed (in the disclose pass, the round then resolves from the disclosures in hand; "A CORPSE'S WITHHELD ACK ABANDONS NOTHING").

Do not resolve a timed-out round from the reveals already in hand when the staller still had a hidden choice in it: abandon it instead. Resolving it publishes the choices of the seats that revealed, and lets the staller pick silence after seeing them, while its own choice stays hidden. The converse holds just as firmly: a seat that owed nothing hidden must not be able to cancel the round by staying silent, or an expendable seat can veto a round going against its side and hand a partner a fresh commit against what the honest seats just disclosed.

Whatever your rules choose, the turn count must not regress across the timeout (PITFALLS.md row 18), and a timeout must discharge any reveal the removed seat still owed, or one silent seat wedges the channel and the pot with it. Write the payoff table down before you implement: what a silent seat forfeits, and what the timeout path pays each of the others. ARCADE.md §6 covers what a timeout settles at each seat count.

8. 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 single choice.
  • EXAMPLES.md: the two repos side by side, and the coin flip in a paragraph.
  • ARCADE.md §6: what a timeout settles at each seat count, which is where §7 lands.
  • PITFALLS.md rows 58, 61, 62 and 63.

Authority for this file: arcade-xayaships/{rules/ships_core.hpp, rules/ships_core.cpp, src/lib/ships/ships-arcade-channel.ts, src/lib/ships/ships-secret-store.ts, tests/lib/ships/ships-arcade-channel.test.ts, blob/tests/ships_trace.json} and arcade-dungeonchannel/{rules/dungeon_core.hpp, rules/dungeon_core.cpp, rules/dungeon_abi.cpp, src/lib/dungeon/{secret.ts,golden-vectors.json}, tests/gen_vectors.cpp}. 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.