ADR-0019 — Authoritative Stake Doubling
Status: Accepted for implementation; the public capability remains reserved until the rollout gate.
Date: 2026-09-03
Decision owner: dicechess-play-api
Context
Section titled “Context”The engine can advise whether a bot should offer or accept a double, and the practice client has a dormant x2 flow. Neither is authoritative for live play. The server must own the order of decisions, clocks, dice reveal, reconnect state, persistence, and settlement. Copying the practice state machine would leave those boundaries undefined and would make a browser authoritative over a stake.
The existing draw implementation already establishes one important invariant: a decision that can end the game is resolved before the next dice are revealed. Stake doubling extends that pre-roll gate, but it differs from a draw response because the responder to a double is not the player whose turn will continue after an acceptance.
The contract is designed here before any room, route, runtime, client, wallet, or analytics behaviour is
enabled. The canonical machine-readable draft and examples live under
docs/public/contracts/stake-doubling/v1/; the live OpenAPI and AsyncAPI documents do not advertise the
new operations until an implementation serves them.
Decision
Section titled “Decision”Live play-api games will support stake doubling, initially for explicitly opted-in catalog
human-vs-bot games and direct bot challenges. The v1 stake is a positive integer amount of
PLAY_CREDIT: a closed-loop, non-purchasable and non-redeemable game credit. The protocol is not a
money, cryptocurrency, token, prize, or wallet-transfer API. Supporting anything with external value is
a separate product, security, and compliance decision and is outside this ADR.
stake always means one seat’s exposure, not the combined pot. At game creation both seats authorize
and reserve their maximum possible exposure:
maximum exposure per seat = initialStake * maximumMultiplierThe amount is reserved before the room starts and released or settled exactly once by game id. The public protocol carries amounts and the fixed currency name, but never wallet ids, balances, reservation ids, credentials, or settlement implementation details. Pre-reserving the maximum avoids mid-game balance checks, affordability leaks, and an involuntary drop caused by insufficient funds.
Staked games are always casual (rated = false) and require durable PostgreSQL-backed operation. A bot
qualifies only through an active webhook registration that selects doubling: capabilities exist
nowhere else in the protocol, so poll-only bots and the static house roster, which cannot register
webhooks, cannot be seated in a staked game. The REST actions and stream events remain available to
admitted webhook bots as an alternative channel; they are not a way in. Requests combining a stake with
rated = true, an anonymous participant, an unsupported creation surface, unavailable persistence, an
unreserved seat, or a bot without the doubling capability are rejected rather than silently
downgraded.
Supported admission surfaces
Section titled “Supported admission surfaces”The first protocol version permits explicit opt-in on:
POST /lobby/play-botfor a signed-in human and a catalog bot whose active webhook registration selectsdoubling. The liveguestIdmember keeps its meaning and is ignored when a session seats the player; a staked request without a session is rejected;POST /bot/challengefor two registered bots. The challenge stores the complete stake offer. The challenger reservation is held while the challenge is pending; the target reservation and currentdoublingcapability are checked atomically on acceptance. Decline or expiry releases the held reservation.
The stake member is required as a whole and has no partial defaults:
{ "amount": 10, "currency": "PLAY_CREDIT", "maximumMultiplier": 64}Omission means an ordinary non-staked game. amount is a positive integer,
maximumMultiplier is one of 2, 4, 8, 16, 32, 64, and checked multiplication must fit the
platform’s persisted integer amount type. The API rejects any other currency or multiplier.
Friend-by-link POST /games, open seeks, ladder games, and the showcase table remain non-staked in
v1. Friend links do not know the second participant at creation, open seeks need a durable reservation
lifecycle of their own, and automated surfaces must never opt players into stakes implicitly.
Cube and settlement semantics
Section titled “Cube and settlement semantics”- The cube starts centered:
cubeValue = 1,cubeOwner = null. currentStake = initialStake * cubeValueat every committed version.- Before rolling on its own turn, a seat may offer only when the cube is centered or owned by that
seat, no draw or double response is pending, and
cubeValue < maximumMultiplier. - An offer proposes
currentStake * 2. The current stake does not change merely because it was offered. - Acceptance doubles
cubeValue, changescurrentStake, and transferscubeOwnerto the responder. - Explicit decline ends the game immediately. The responder loses the current pre-offer stake; the unaccepted proposed amount is never settled.
- A king capture, ordinary resignation, or clock timeout settles the current accepted stake. A draw settles zero net credits. A technical abort releases both reservations with zero net settlement.
- No rake or asymmetric payout is part of this protocol. The winner’s net change is
+currentStakeand the loser’s is-currentStake.
Authoritative phases
Section titled “Authoritative phases”GameRoom remains the sole writer. A staked turn moves through these phases:
stateDiagram-v2
[*] --> drawResponse: prior turn offered draw
[*] --> doubleOpportunity: no pending draw
drawResponse --> [*]: accept draw
drawResponse --> doubleOpportunity: decline draw
doubleOpportunity --> diceRevealed: roll
doubleOpportunity --> doubleResponse: offer double
doubleResponse --> [*]: decline / response timeout
doubleResponse --> diceRevealed: accept; original turn resumes
diceRevealed --> turnComplete: move or forced pass
turnComplete --> drawResponse: outgoing draw offered
turnComplete --> doubleOpportunity: no draw offer
The complete order is:
- Finish the previous turn and commit its
TurnPlayedevent. - If that turn offered a draw, resolve the draw first. No doubling decision exists while a draw is
pending. In a staked game every draw-decline path (an explicit
RespondDraworPOST /bot/game/{id}/draw/decline, anacceptDraw: falsewebhook answer, and the server’s automatic decline for a bot withoutdraws) continues into step 3 instead of the classic immediate reveal; the automatic decline therefore triggers adoubleOpportunitydelivery when the decliner is eligible. - If the turn owner is eligible to offer, enter a
doubleOpportunitydecision before deriving or revealing any dice. The turn owner chooses either roll or offer. If it is ineligible because the cube belongs to the opponent or is at the multiplier cap, no decision is created and the existing automatic roll path continues. On the opening turn the client-seed window closes first (both seeds or the seed grace); only then does the first opportunity open, the seed-grace force-start enters the opportunity instead of rolling, and the turn owner’s clock starts when the opportunity opens. - On an offer, commit
DoubleOffered, pause the turn owner’s clock, and make the opponent the active decision seat. The board position does not change. - On acceptance, charge the responder’s decision time, commit
DoubleAccepted, update the cube and stake, restore the original turn owner, and continue directly to the roll. Ownership has moved to the responder, so the original turn owner cannot immediately re-offer. - Only an explicit roll choice or an ineligible automatic path asks
DiceSourcefor the roll, commitsDiceRolled, and opens the move phase. A forced pass is processed after reveal exactly as today.
activeSeat identifies the actor currently required to answer. During doubleResponse that is the
responder. The active-colour field of dfen remains the actual chess side to move, and
doubling.turnSeat names that same seat throughout the out-of-turn response. Runtime consumers use the
explicit decision seat when evaluating the responder’s perspective; the server never fabricates a
different board turn. Move counters, castling state, en-passant state, and history do not change.
Clocks and absence
Section titled “Clocks and absence”The seat named by activeSeat pays for its own decision:
- the turn owner’s ordinary clock runs during
doubleOpportunityand the move phase; - creating an offer charges elapsed time to the turn owner, then pauses that clock;
- the responder’s clock runs during
doubleResponse; acceptance charges that elapsed time without a Fischer increment, then the original turn owner’s remaining time resumes; - for
PerMove, the response gets one per-move budget and does not consume the responder’s future turn budget; forUnlimited, the existing anti-abandonment deadline applies; - the first of clock expiry and disconnect grace wins. A responder timeout/drop loses at the current
stake, but retains its factual
Timeout/Resigntermination rather than being mislabeled as an explicit cube decline; - an offerer that resigns or disconnects while waiting loses normally at the current stake. The unaccepted offer is not turned into a responder decline.
Webhook transport failures never become an immediate financial decision, and the two deliveries fail
differently because delivery is single-attempt. For doubleOpportunity the only non-financial answer
is to roll: a missing, malformed, oversized, late, non-200, or wrong-kind response is treated exactly
like offerDouble: false and the roll follows, mirroring the failed-drawDecision precedent. For
doubleDecision no transport outcome is ever read as an acceptance or a decline: the authoritative
decision stays pending and the responder’s clock decides. The runtime’s explicit defaults
(offerDouble: false, acceptDouble: false) are used only when the signed callback was successfully
invoked.
Idempotency, retries, and reconnect
Section titled “Idempotency, retries, and reconnect”Every doubling episode has one opaque decisionId, minted when the opportunity is created and shared
by its opportunity, offer, and response steps; doubling.decision.kind names the step currently
awaiting an answer. The id is persisted before publication and remains stable across snapshots, stream
reconnects, webhook redelivery after process restart, and client retry. Each answered step retains its
action and outcome with the snapshot.
- Repeating the same action for the same
decisionIdand step returns the original outcome and emits no event. - A different action for an already answered step, an unknown id, a decision for another seat, or an
action whose kind does not match the current step returns
409without changing state. The outcome’sversionis the version the action committed and isnullwhen nothing was applied, as on move verdicts. - At most one
DoubleOfferedorDiceRolledtransition can result from the offer step of a decision id, and at most oneDoubleAcceptedorDoubleDeclinedfrom its response step. - A reconnecting client receives a
Snapshotwith the exact pending decision and acts from that state; it does not infer a decision from the absence of dice.
Public state and transport contract
Section titled “Public state and transport contract”Classic games omit doubling or send it as null; clients must treat both forms identically. Staked
games always send the complete object. The schema describes the pre-roll shape as
StakedDecisionSnapshot: the live PublicGameState with doubling present, dicePending: false,
legalMoves: null, and nullable clocks (null on Unlimited, as today). Classic and post-roll
snapshots keep the live PublicGameState shape, which gains the optional nullable doubling member.
The schema fixes these fields:
currency,initialStake,currentStake;cubeValue, nullablecubeOwner, andmaximumMultiplier;mayOfferDouble, computed for the current decision actor;turnSeat, which survives the out-of-turn response phase;- nullable
decision, tagged asofferorresponse, with its stable id, actor, and proposed stake.
The public Bot API page contains the exact JSON examples and endpoint mapping. In code the transport- neutral command vocabulary becomes:
{ "RequestRoll": { "decisionId": "double_01K4F4Y7M8R2" } }{ "OfferDouble": { "decisionId": "double_01K4F4Y7M8R2" } }{ "RespondDouble": { "decisionId": "double_01K4F4Y7M8R2", "accept": true } }The event vocabulary becomes DoubleOpportunity, DoubleOffered, DoubleAccepted, and
DoubleDeclined. Each cube event carries the authoritative clocks snapshot ({ white, black } or null
on Unlimited). A declined event carries a machine-readable reason (declined, timeout, disconnect,
or resign). When the responder resigns during a pending offer, DoubleDeclined{reason: "resign"} is
emitted immediately before GameEnded to terminate the cube episode cleanly on stream and socket.
GameEnded.termination remains the factual game termination and gains one additive member,
DoubleDeclined, used only for an explicit decline; timeout, disconnect, and resignation keep Timeout
and Resign. Adding the member is an additive change to the live AsyncAPI GameEnded enum and to the
OpenAPI GameHistory.termination vocabulary (double_declined), delivered with the transport and
persistence work; clients must already tolerate unknown termination values. The Bot API exposes
equivalent synchronous REST actions and two signed webhook delivery types:
doubleOpportunity->{ "decisionId": "...", "offerDouble": false }(false means roll; optionalresign: trueto forfeit immediately, optionalarmDrawOffer: trueto arm the standing draw-offer flag for the roll);doubleDecision->{ "decisionId": "...", "acceptDouble": false }(optionalresign: trueto resign during the response phase).
Both contexts are dice-free. yourTurn is delivered only after DiceRolled and continues to use the
existing move response. The runtime maps the two new deliveries to independent optional strategy
methods, DoubleOfferAction and DoubleResponseAction, each defaulting to false.
Capability and compatibility
Section titled “Capability and compatibility”The canonical doubling capability stays reserved and unselectable while this document and its
schemas land. Making it available is a separate rollout action after server state, durable storage,
runtime, analytics, and at least one end-to-end consumer are deployed.
Legacy safety is admission-time, not a financially meaningful auto-action:
- absence of
stakecreates the existing classic game with byte-compatible behaviour; - a bot without
doublingcannot enter a staked game; - capability support is snapshotted into the admitted game. Removing or deleting a webhook later does not rewrite the game contract; delivery stops and the ordinary clock/disconnect rules apply;
- unknown new stream events, the optional
PublicGameState.doublingmember, and the additiveDoubleDeclinedtermination remain additive for spectators, but old playing clients are never admitted to a game they did not explicitly request; - the server does not switch
doublingto available or admit a staked game merely because schema components exist.
Persistence, history, and analytics
Section titled “Persistence, history, and analytics”The durable game snapshot must contain the stake agreement, cube value and owner, turn seat, pending decision, resolved-decision outcomes, reservation references (private), and the ordered doubling event history. Staked rooms use required durability. A state version is published only after its snapshot is committed.
The terminal snapshot transaction also commits one idempotent settlement instruction and the existing
archive/analytics outbox entries before GameEnded is published. Applying the instruction may be
asynchronous, but replaying it by game id cannot transfer credits twice.
The existing analytics wire vocabulary is retained with three additions (stake_currency gains the
value PLAY_CREDIT, DOUBLE_DECLINE payloads gain settled_stake, and play-api starts emitting the
double_declined termination the practice client already uses) and one ambiguity resolved: for
play-api, initial_stake_amount and final_stake_amount are one seat’s exposure, matching the current
dicechess-play producer. They are not a two-seat pot. Existing analytics prose that calls these
columns a pot must be corrected by the ingest follow-up. The legacy *_money_delta column names carry
closed-loop play-credit changes for this source and do not imply money.
| Result | Analytics projection |
|---|---|
| staked game | mode = "x2", stake_currency = "PLAY_CREDIT" |
| creation | initial_stake_amount = initialStake |
| terminal | final_stake_amount = currentStake |
| offer | DOUBLE_OFFER, actor is offerer, payload bank is proposed stake |
| accept | DOUBLE_ACCEPT, actor is responder, payload bank is the new current stake |
| explicit/implicit drop | DOUBLE_DECLINE, actor is responder, payload bank is proposed stake and also carries settled_stake plus reason |
| explicit decline | termination = "double_declined" |
| timeout/disconnect | factual timeout/resign termination; decline reason preserves the cube outcome |
| draw | zero net play-credit deltas and draw_agreement termination |
| technical abort | zero-net ledger release; retained in play-api audit/history and not ingested as a sporting game |
Each event also records the offer id, cube value/owner, both clocks, and the one-based turn number of the pre-roll phase. Analytics must exclude non-voluntary reasons such as timeout or disconnect from cube-choice quality metrics. An offer cancelled because the offerer ended the game can remain unpaired; it is not fabricated into a responder decision.
Verification scenarios
Section titled “Verification scenarios”Implementation is not complete until tests pin all of these sequences:
| Scenario | Required result |
|---|---|
| roll without offer | one opportunity and one dice reveal; a retry reveals no second roll |
| accepted offer | stake doubles, ownership moves to responder, original turn resumes without dice leakage |
| explicit decline | responder loses current stake; proposed stake is never settled |
| response timeout | responder loses current stake with Timeout, not explicit-decline termination |
| disconnect | first of decision deadline and disconnect grace wins and settles exactly once |
| duplicate response | identical response replays the verdict; conflicting response is 409 |
| reconnect/restart | same decision id and phase are restored from the durable snapshot |
| pending draw | draw resolves before any double opportunity |
| forced pass | no precomputed dice; after roll the ordinary forced-pass path runs |
| multiplier cap / wrong owner | no opportunity is created; the ordinary automatic roll path continues; an unsolicited offer is rejected |
| game end during offer | only the factual terminal action settles; no fabricated acceptance |
failed doubleOpportunity delivery |
answered as roll; no stake changes and no fabricated offer |
failed doubleDecision delivery |
decision stays pending; the responder’s clock decides; no fabricated decline |
| opening turn | the client-seed window closes before the first opportunity; force-start enters the opportunity, not the roll |
Unlimited staked game |
snapshots carry clocks: null; the anti-abandonment deadline governs both decision phases |
poll-only or house bot |
cannot be admitted: no webhook registration can select doubling |
| legacy bot/client | cannot be admitted to a staked game; classic behaviour is unchanged |
Rollout and rollback
Section titled “Rollout and rollback”The rollout is closed by default and ordered:
- Merge this ADR and the reserved schema fixtures;
doublingremains unselectable. - Implement durable play-credit reservation and idempotent settlement.
- Implement room state, persistence, history, REST/stream/webhook transport, and contract tests behind disabled admission and capability flags.
- Implement the accepted contract in
dicechess-bot-runtime, then migrate representative bots. - Implement analytics ingest/projections and the authenticated play client.
- Exercise offer, take, drop, timeout, disconnect, retry, reconnect, restart, draw, forced-pass, and terminal settlement sequences in staging.
- In a human-controlled rollout, make
doublingselectable, re-register compatible bots, then enable new-game admission.
Rollback disables new staked-game admission first. Existing staked games retain their snapshotted contract and are allowed to settle; disabling callbacks or removing their capability mid-game is not a safe rollback. Releases, feature-flag changes, registrations, migrations, and production cutover remain human-only.
Implementation issue graph
Section titled “Implementation issue graph”The GO decision is decomposed into independently mergeable work:
- play-api #60 — durable play-credit reservation and settlement primitives; a sub-issue of the existing wallet Epic;
- play-api #61 — authoritative room state;
- play-api #62 — REST, stream, snapshot, and webhook transport after #61;
- play-api #63 — required persistence, history, settlement instruction, and analytics outbox after #60 and #61;
- analytics #27 — accepted play-credit ingest semantics;
- runtime #15 — the already-tracked typed bot decision contract;
- play #68 — live client integration after #60 and #62, under the existing x2 Epic;
- play-api #64 — the human-only rollout gate, natively blocked by every required server, runtime, analytics, client, release, and compatible bot predecessor.
Addendum: Owner requirements, HvH staked admission, and protocol refinements (2026-09-08)
Section titled “Addendum: Owner requirements, HvH staked admission, and protocol refinements (2026-09-08)”Following the initial acceptance of ADR-0019, cross-repository design work in ADR 006 (fortemate-internal
content/decisions/adr-006-resign-draw-and-doubling-protocol.md, issue #108) and HvH offer/accept
coordination (play-api#8, ADR 007) mapped open questions and owner requirements into normative
addenda. The additions below refine the v1 contract:
1. Roll-button policy (Policy A)
Section titled “1. Roll-button policy (Policy A)”Two roll policies were evaluated for turns where the active player cannot double:
- Policy A (accepted): The pre-roll decision step opens only when the turn owner is eligible to
offer (
mayOfferDouble = true). If the seat is ineligible—because the cube belongs to the opponent or has reachedmaximumMultiplier—the server automatically rolls and proceeds immediately toDiceRolled. Clients render this directly fromdoubling.decision: when no decision is present, the interface displays a non-blocking indicator (e.g. “Cube belongs to opponent, rolled automatically”). - Policy B (rejected): An explicit pre-roll step on every turn requiring manual roll confirmation.
Rejected because it creates an artificial participant-type bifurcation in the room state machine and
violates the schema invariant
kind: offer => mayOfferDouble: true.
A future client-side preference (“always roll manually in staked games”) may sit on top of Policy A without changing the server state machine or wire protocol.
2. Human-versus-Human (HvH) staked admission via offer/accept
Section titled “2. Human-versus-Human (HvH) staked admission via offer/accept”Staked games require that both seats belong to authenticated accounts (or registered bots with the
doubling capability), and that both participants hold pre-reserved exposure
(initialStake * maximumMultiplier) before the room exists.
- Classic friend links remain un-staked: The existing friend-by-link flow (
POST /games), where the creator claims one seat and holds the second seat for an anonymous claim, remains classic-only. - Strict offer/accept progression: Staked HvH games enter exclusively through the offer/accept
protocol designed in
play-api#8/ ADR 007, staged in the following order:- Direct challenge to a specific authenticated account, and rematch between the same two participants (inheriting time control, resetting the cube to 1 centered and resetting stake to base);
- Link invitation with a known, authenticated second participant;
- Open seeks with stake.
- Reservation and acknowledgement: Both seats reserve maximum exposure before room instantiation. The
accepting participant must echo the full
stakeobject; any missing or mismatched stake is rejected with409 stake acknowledgement required. Anonymous accepters are rejected with403 Forbidden.
3. Bounded time control for human seats
Section titled “3. Bounded time control for human seats”To prevent indefinite capital lockup in staked games:
- Human seats in staked games require a bounded time control (
Fischer,SuddenDeath, orPerMove). Unlimitedtime control is restricted to registered bot-versus-bot challenges.
4. Clocks on cube stream events
Section titled “4. Clocks on cube stream events”To prevent client-side clock drift during decision actor transitions, all four doubling stream events
carry the authoritative clocks snapshot ({ white, black } or null for Unlimited):
DoubleOpportunity: includesclocksas of the opportunity opening.DoubleOffered: includesclockswith elapsed offer time charged to the turn owner, while the responder’s clock remains untouched.DoubleAccepted: includesclockscharging elapsed response time to the responder (without increment), leaving the turn owner’s clock paused.DoubleDeclined: includesclocksreflecting elapsed response time charged to the responder.
5. Responder resignation during double response (DoubleDeclined{reason: "resign"})
Section titled “5. Responder resignation during double response (DoubleDeclined{reason: "resign"})”When a responder resigns while a doubleResponse step is pending:
- The responder forfeits at the pre-offer
currentStake, matching standard resignation settlement. - Before publishing the terminal
GameEnded(Resign, ...), the room emitsDoubleDeclined{reason: "resign", clocks}to cleanly close the open cube episode on stream and socket. - The machine-readable decline
reasonenum is["declined", "timeout", "disconnect", "resign"].
6. Answer members: resign and armDrawOffer
Section titled “6. Answer members: resign and armDrawOffer”Webhook delivery responses and client decision commands support additional action members:
resign: true: Accepted on bothdoubleOpportunityanddoubleDecisionresponses, triggering an immediate resignation.armDrawOffer: true: Accepted ondoubleOpportunityresponses. When returned by an eligible seat, the server arms the standingdrawOfferArmedflag for the upcoming turn before rolling. This guarantees that webhook bots in staked games can offer draws even across forced passes without needing extra out-of-band requests.
7. Poll-only bot decision visibility (GET /bot/games)
Section titled “7. Poll-only bot decision visibility (GET /bot/games)”To eliminate blind spots for poll-only bots during doubling episodes:
GET /bot/gamesreports the caller’s active decision via thedecisionmember:null,{"kind": "drawResponse"},{"kind": "doubleOffer", "id": "double_..."}, or{"kind": "doubleResponse", "id": "double_..."}.- Poll-only bots observe pending opportunities and offers directly without inferring state from dice absence.
8. Fixture hygiene and capability reservation
Section titled “8. Fixture hygiene and capability reservation”- Staked decision state fixtures render authenticated human participants with explicit nicknames
(
name: "alice"), resolving the previous guest mask artifact (name: null). - The canonical
doublingcapability remainsreservedand unselectable. No live routes, admission surfaces, or room state transitions are enabled until the human-only rollout gate (play-api#64).
Consequences
Section titled “Consequences”The design adds a pre-roll actor transition and requires stricter durability than classic games. In return, dice secrecy, financial state, retries, and reconnects have one authority, bots receive enough information without wallet data, and every unsupported participant fails before a stake exists.
The practice client remains source material only. Its useful cube/analytics vocabulary is retained, but its client-side balance mutation and insufficient-funds drop are deliberately not copied.