Hold & Win — backend response contract
The implemented backend is authoritative, not this document. Every JSON block below is a
byte-for-byte copy of a fixture captured from the live spin orchestrator — the same code path a
player's request takes — and a drift guard fails the build if the runtime stops producing it. A
hand-written example that disagrees with handler output is worse than no example, because it reads
as a contract while being fiction.
The distinction between a live fixture and a mapper fixture is the point. A mapper fixture proves
the projection function is stable and says nothing about whether the runtime ever calls it. These
were produced by driving SpinOrchestrator.Spin against real PostgreSQL and serializing what came
back.
How these fixtures were produced
Determinism comes from a pinned seed. debug.force_seed fixes the trigger round's RNG namespace,
and every mechanic sub-seed derives from it — <roundSeed>:hold_and_win:start and
<roundSeed>:hold_and_win:step:<n> — so the whole feature is reproducible.
The one value that cannot be pinned is the round id, which is minted per operation. It is
normalised to an all-zero UUID, and because next_step_id is derived from it the same substitution
keeps that field honest rather than hiding it. Every other value below is exactly what the
runtime produced.
cd /Users/admin/kiro/backend
# Capture / verify the live fixtures (needs PostgreSQL).
TEST_DATABASE_URL='postgres://game@127.0.0.1:55432/game_engine?sslmode=disable' \
go test -tags integration ./apps/game-engine/internal/app/service \
-run TestHAWLiveResponseFixtures -count=1
# Regenerate them deliberately after an intended contract change.
UPDATE_HAW_FIXTURES=1 TEST_DATABASE_URL='…' go test -tags integration … -run TestHAWLiveResponseFixtures
# The wire and schema guards (no database needed).
go test ./apps/game-engine/internal/app/handlers -run 'HoldAndWin|OpenAPI' -count=1
The fixture game is a 3×5 pack whose two leftmost reels carry only BONUS, so the trigger grid is
fully determined at 10 locked cells. LandingChancePPM is the only parameter varied between
fixtures, because it is the one knob that steers the feature to a chosen terminal state:
Design rules this contract follows
- Every response carries the complete grid. All 15 cells, always, in normative order — not
only the per-step delta. A client that dropped a response or reconnected mid-feature must be able
to reconstruct the 3×5 grid from the response alone, with no local outcome inference. Sending
only the delta would be smaller and would break exactly the cases that matter.
- The delta is sent as well, in
newly_landed, because a reveal animation needs to know which
cells just lit up. It is always an array, never null, so a client can iterate without a
presence check.
- Each cell sends
index, column and row. Any one determines the others, but sending all
three means a client never has to know the mechanic's index convention to place a cell.
index = column × rows + row.
prize is nullable. A locked cell always carries one; an empty cell never does. A
zero-valued prize object would make "no prize" indistinguishable from "a prize worth nothing".
- Server-owned identities never appear. No durable sequence id, logical session id, config
hash, RNG algorithm, state schema version or revision. Asserted absent against the live response
by
TestHAWOrch_ResponseCarriesNoServerIdentity.
next_step_id is derived by the server and handed to the client. The mechanic accepts only
that value or the last executed one, so a stale or invented identity is refused rather than
replayed against the wrong state. That is what makes a retry safe without letting a caller name a
step it does not own.
accumulated_multiplier is not money owed. The feature pays once, at settlement. Only
settlement.award_multiplier is payable.
Request contract
A respin is a POST /game/v1/spin with no additional wager, carrying the echoed step index:
{ "bet": 1.0, "expected_hold_and_win_step_index": 0 }
bet is still required by the endpoint's schema and is not charged while a feature is open —
the orchestrator routes the request to the respin path before any wallet call, and the response
reports wallet.bet as 0.
expected_hold_and_win_step_index is the client's half of the operation key; the server supplies
the other half from its own sequence id, which never leaves the backend. Presence is significant and
distinct from the value 0, which is a valid first step: omit the field entirely (or send null)
to mean "not supplied".
The replay window is exactly one step wide, and that is a property of the storage rather than a
policy choice: the durable row holds the state after the most recent step, so it can reproduce
that step's answer and no earlier one. Returning the current row for an older index would hand a
client a grid from a step it did not ask about while calling it a replay.
Other refusals: HAW_FEATURE_QUARANTINED (409) when the durable state could not be trusted,
HAW_CONFIG_UNAVAILABLE (409) when the math the feature started on no longer resolves,
HAW_NOT_PRODUCTION_ELIGIBLE (503), and HAW_OPERATION_NOT_FOUND (404) — which is also what an
ownership mismatch looks like from outside, so a caller cannot tell "that feature belongs to
someone else" from "there is no such feature".
Lifecycle
base spin, 6+ BONUS
└─> TRIGGER response status=open, step_index=0, newly_landed=[]
│ next_step_id = "<parent_round_id>:step:0"
│ the durable feature is opened in the SAME transaction
▼
client echoes expected_step_index
│
├─> RESPIN response status=open, step_index+=1
│ newly_landed = cells this step locked (may be empty)
│ respins reset to 3 on a land, decrement on a miss
│
├─> DUPLICATE STEP the same index sent twice returns the SAME response,
│ consumes no RNG and charges no wager
│
└─> RECONNECT after a cache flush the feature is served from PostgreSQL;
nothing is lost and nothing is re-drawn
▼
respins reach 0 OR all 15 cells locked
└─> SETTLEMENT response status=completed, settlement present, no next_step_id
ONE credit intent written in the settling transaction
A respin charges no wager. The trigger spin is the only wagered round in the whole feature.
Trigger response
Ten cells locked from the trigger grid, three respins, no step taken yet.
Verbatim from testdata/hold_and_win/trigger.json:
{
"kind": "hold_and_win",
"mechanic_version": "hold_and_win/v1",
"status": "open",
"step_index": 0,
"expected_step_index": 0,
"respins_remaining": 3,
"columns": 3,
"rows": 5,
"locked_cells": 10,
"total_cells": 15,
"accumulated_multiplier": 40,
"cells": [
{
"index": 0,
"column": 0,
"row": 0,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 20
}
},
{
"index": 1,
"column": 0,
"row": 1,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 2,
"column": 0,
"row": 2,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 3,
"column": 0,
"row": 3,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 4,
"column": 0,
"row": 4,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 5
}
},
{
"index": 5,
"column": 1,
"row": 0,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 6,
"column": 1,
"row": 1,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 7,
"column": 1,
"row": 2,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 8,
"column": 1,
"row": 3,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 9,
"column": 1,
"row": 4,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 5
}
},
{
"index": 10,
"column": 2,
"row": 0,
"locked": false,
"prize": null
},
{
"index": 11,
"column": 2,
"row": 1,
"locked": false,
"prize": null
},
{
"index": 12,
"column": 2,
"row": 2,
"locked": false,
"prize": null
},
{
"index": 13,
"column": 2,
"row": 3,
"locked": false,
"prize": null
},
{
"index": 14,
"column": 2,
"row": 4,
"locked": false,
"prize": null
}
],
"newly_landed": [],
"parent_round_id": "00000000-0000-0000-0000-000000000000",
"next_step_id": "00000000-0000-0000-0000-000000000000:step:0"
}
Ordinary respin response
One cell landed at index 11. newly_landed names it, the snapshot shows it locked, locked_cells
moved from 10 to 11, accumulated_multiplier from 40 to 41, and respins_remaining
reset to 3 because something landed.
Verbatim from testdata/hold_and_win/respin.json:
{
"kind": "hold_and_win",
"mechanic_version": "hold_and_win/v1",
"status": "open",
"step_index": 1,
"expected_step_index": 1,
"respins_remaining": 3,
"columns": 3,
"rows": 5,
"locked_cells": 11,
"total_cells": 15,
"accumulated_multiplier": 41,
"cells": [
{
"index": 0,
"column": 0,
"row": 0,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 20
}
},
{
"index": 1,
"column": 0,
"row": 1,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 2,
"column": 0,
"row": 2,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 3,
"column": 0,
"row": 3,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 4,
"column": 0,
"row": 4,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 5
}
},
{
"index": 5,
"column": 1,
"row": 0,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 6,
"column": 1,
"row": 1,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 7,
"column": 1,
"row": 2,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 8,
"column": 1,
"row": 3,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 9,
"column": 1,
"row": 4,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 5
}
},
{
"index": 10,
"column": 2,
"row": 0,
"locked": false,
"prize": null
},
{
"index": 11,
"column": 2,
"row": 1,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 12,
"column": 2,
"row": 2,
"locked": false,
"prize": null
},
{
"index": 13,
"column": 2,
"row": 3,
"locked": false,
"prize": null
},
{
"index": 14,
"column": 2,
"row": 4,
"locked": false,
"prize": null
}
],
"newly_landed": [
11
],
"parent_round_id": "00000000-0000-0000-0000-000000000000",
"next_step_id": "00000000-0000-0000-0000-000000000000:step:1"
}
A miss-only step looks the same except newly_landed is [], locked_cells and
accumulated_multiplier are unchanged, and respins_remaining has decreased by exactly one.
Duplicate step (retry)
Sending the same expected_hold_and_win_step_index twice returns the identical response — the
bytes above, unchanged. The server consumes no randomness, allocates no round and charges nothing;
the durable step-operation key (sequence_id, step_index) is what turns a retry into a replay
instead of a second respin.
There is no distinguishing field, by design: a client that behaves differently on a retry is a
client that can double-count. TestHAWOrch_DuplicateStepReturnsTheExactPersistedReplay asserts the
serialized equality and that the feature did not advance.
Reconnect
The Redis copy is a cache; PostgreSQL is the source of truth. After the cache is flushed the next
request is served from the durable row — same shape, same completeness, no wager, no re-draw.
Verbatim from testdata/hold_and_win/reconnect.json, captured after clearing the cache mid-feature:
{
"kind": "hold_and_win",
"mechanic_version": "hold_and_win/v1",
"status": "open",
"step_index": 2,
"expected_step_index": 2,
"respins_remaining": 2,
"columns": 3,
"rows": 5,
"locked_cells": 11,
"total_cells": 15,
"accumulated_multiplier": 41,
"cells": [
{
"index": 0,
"column": 0,
"row": 0,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 20
}
},
{
"index": 1,
"column": 0,
"row": 1,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 2,
"column": 0,
"row": 2,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 3,
"column": 0,
"row": 3,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 4,
"column": 0,
"row": 4,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 5
}
},
{
"index": 5,
"column": 1,
"row": 0,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 6,
"column": 1,
"row": 1,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 7,
"column": 1,
"row": 2,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 8,
"column": 1,
"row": 3,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 9,
"column": 1,
"row": 4,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 5
}
},
{
"index": 10,
"column": 2,
"row": 0,
"locked": false,
"prize": null
},
{
"index": 11,
"column": 2,
"row": 1,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 12,
"column": 2,
"row": 2,
"locked": false,
"prize": null
},
{
"index": 13,
"column": 2,
"row": 3,
"locked": false,
"prize": null
},
{
"index": 14,
"column": 2,
"row": 4,
"locked": false,
"prize": null
}
],
"newly_landed": [],
"parent_round_id": "00000000-0000-0000-0000-000000000000",
"next_step_id": "00000000-0000-0000-0000-000000000000:step:2"
}
Settlement — respins exhausted
Three miss-only steps at 0 PPM. The award is the sum of the trigger prizes, below the cap.
Verbatim from testdata/hold_and_win/settlement-no-respins.json:
{
"kind": "hold_and_win",
"mechanic_version": "hold_and_win/v1",
"status": "completed",
"step_index": 3,
"expected_step_index": 3,
"respins_remaining": 0,
"columns": 3,
"rows": 5,
"locked_cells": 10,
"total_cells": 15,
"accumulated_multiplier": 40,
"cells": [
{
"index": 0,
"column": 0,
"row": 0,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 20
}
},
{
"index": 1,
"column": 0,
"row": 1,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 2,
"column": 0,
"row": 2,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 3,
"column": 0,
"row": 3,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 4,
"column": 0,
"row": 4,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 5
}
},
{
"index": 5,
"column": 1,
"row": 0,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 6,
"column": 1,
"row": 1,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 7,
"column": 1,
"row": 2,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 8,
"column": 1,
"row": 3,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 9,
"column": 1,
"row": 4,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 5
}
},
{
"index": 10,
"column": 2,
"row": 0,
"locked": false,
"prize": null
},
{
"index": 11,
"column": 2,
"row": 1,
"locked": false,
"prize": null
},
{
"index": 12,
"column": 2,
"row": 2,
"locked": false,
"prize": null
},
{
"index": 13,
"column": 2,
"row": 3,
"locked": false,
"prize": null
},
{
"index": 14,
"column": 2,
"row": 4,
"locked": false,
"prize": null
}
],
"newly_landed": [],
"parent_round_id": "00000000-0000-0000-0000-000000000000",
"settlement": {
"reason": "no_respins_remaining",
"raw_multiplier": 40,
"award_multiplier": 40,
"capped": false,
"full_grid": false,
"jackpot_counts": {
"regular": 10
}
}
}
next_step_id is absent: there is no step to take. status is completed, and one credit intent
was written in the same transaction that settled the feature.
Settlement — full grid
Every cell locked. The Grand replaces the prize sum, so a raw sum of 54 pays
500.
Verbatim from testdata/hold_and_win/settlement-full-grid.json:
{
"kind": "hold_and_win",
"mechanic_version": "hold_and_win/v1",
"status": "completed",
"step_index": 1,
"expected_step_index": 1,
"respins_remaining": 0,
"columns": 3,
"rows": 5,
"locked_cells": 15,
"total_cells": 15,
"accumulated_multiplier": 54,
"cells": [
{
"index": 0,
"column": 0,
"row": 0,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 20
}
},
{
"index": 1,
"column": 0,
"row": 1,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 2,
"column": 0,
"row": 2,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 3,
"column": 0,
"row": 3,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 4,
"column": 0,
"row": 4,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 5
}
},
{
"index": 5,
"column": 1,
"row": 0,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 6,
"column": 1,
"row": 1,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 7,
"column": 1,
"row": 2,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 8,
"column": 1,
"row": 3,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 9,
"column": 1,
"row": 4,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 5
}
},
{
"index": 10,
"column": 2,
"row": 0,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 5
}
},
{
"index": 11,
"column": 2,
"row": 1,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 2
}
},
{
"index": 12,
"column": 2,
"row": 2,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 3
}
},
{
"index": 13,
"column": 2,
"row": 3,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 1
}
},
{
"index": 14,
"column": 2,
"row": 4,
"locked": true,
"prize": {
"kind": "regular",
"multiplier": 3
}
}
],
"newly_landed": [
10,
11,
12,
13,
14
],
"parent_round_id": "00000000-0000-0000-0000-000000000000",
"settlement": {
"reason": "full_grid",
"raw_multiplier": 54,
"award_multiplier": 500,
"capped": false,
"full_grid": true,
"jackpot_counts": {
"regular": 15
}
}
}
capped is false here, and that is deliberate. The Grand replaced the sum rather than clipping
it — the award is higher than raw — so reporting this as capped would be backwards. capped is
derived from raw > award rather than stored, so it cannot disagree with the two numbers printed
beside it.
Settlement — ordinary sum above the cap
Not reachable from the fixture pack (its trigger prizes cannot sum past 500×), so no live fixture
exists for it and none is invented here. The shape is the one above with capped: true:
raw_multiplier above 500, award_multiplier exactly 500, full_grid: false. The cap itself is
covered by the mechanic's own tests in libs/mathengine/hold_and_win_test.go.
Field reference
Change control
Any change to a response field invalidates the examples above, the OpenAPI schema and the runtime
parity evidence. Three guards fail on it, in this order:
TestHAWLiveResponseFixtures — the live orchestrator output no longer matches the fixture files;
TestOpenAPIHoldAndWinExampleMatchesTheLiveFixture — the published schema example disagrees with
the fixture;
TestHoldAndWinNextStateReachesTheWireUnchanged — the handler reshapes next_state on the way
out.
Regenerate the fixtures deliberately, then update the OpenAPI example and this document from them,
and review all three together before FREEZE & HANDOFF.