Game object — runtime state & simulator core
Concrete proposal for roadmap steps 3.8–3.10:
the exact PHP shape of the Game runtime state, how
setup() builds it from the data the wizard actually
saves, and the contracts for legal_actions() /
apply().
eb362133ac184632000ad893d754d6e4), authored deliberately
through the UI on 2026-08-03.
And the legal_actions/apply section below assumes one
handler per action; that is replaced by six primitives plus a composite
executor — see D1–D3 of the decisions file. Everything else here
(the Game shape, flat zone map, PRNG, redaction, replay) stands and is built.
Status
The evaluator (3.1a) is wired and tested (38 cases), trackers are
unified behind getGameTrackers() (3.2 read side), zones
are structured (3.3), and effects carry triggers (3.4). This document
is the gate before 3.8 (state + setup), 3.9 (apply),
3.10 (legal_actions). Nothing below is built.
What we keep from the model doc
Modelling a card game already made six architectural calls. All are kept as-is — they match how real engines (e.g. boardgame.io) work and how our evaluator already speaks:
| Decision | Meaning |
|---|---|
| State ≠ definition | The Game holds state and references definitions by id. Card text, catalogs, rules never get copied into it. |
| Two functions | legal_actions(game, player) and apply(game, action) are the whole engine surface. |
| Mutate in place | No immutable snapshots in PHP; the history log is the time machine. |
| Stable instance ids | Cards are c1, c2, … assigned at setup; moving a card is a splice of ids, never a copy of data. |
| Seeded determinism | All randomness through one PRNG; seed + history replays a run exactly. |
| Visibility is a function | viewFor(game, playerId) redacts; bots only ever see the redacted view. |
What this proposal changes vs. the model doc
C1 — Flat zone map (the big one)
The model doc nests owned zones inside each player
(players[].zones.hand) and keeps a separate
shared_zones map. This proposal replaces both with
one flat map of zone instances, each carrying its
owner:
zones: {
"hand:p0": { def: "hand", owner: "p0", cards: ["c12","c4"] },
"hand:p1": { def: "hand", owner: "p1", cards: ["c8"] },
"deck:p0": { def: "deck", owner: "p0", cards: ["c1","c5", ...] },
"discard:p0": { def: "discard", owner: "p0", cards: [] },
"trade_row": { def: "trade_row", owner: null, cards: ["c41","c42"] }
}
Why:
- Mirrors the authored data 1:1. Step 2 defines each zone once with a scope
(
per_player/shared). Setup expands per-player defs into one instance per player — the flat map is that expansion. - Uniform mutations. "Move card X from zone A to zone B" is one function over one map; no owned-vs-shared branching anywhere in the engine.
- Many-shared-zone games fit (Dominion-style supply = N entries, no special case).
- Whole-state iteration is trivial (validation, "count all discards", serialization).
Instance key convention: <def_id>:<player_id> for per-player zones,
bare <def_id> for shared. The nested per-player view and the evaluator's
player.zones.hand paths survive as views:
GameExpressions::resolveZoneContents() re-points at the flat map (its 38 tests pin
the behaviour), and viewFor() filters the map by owner + visibility.
C2 — Targets are enumerated, not implied
The model doc leaves player-chosen targets ("deal N damage to a target") unspecified.
Contract here: legal_actions() returns fully concrete actions —
every choice already made, including targets. A bot never fills in blanks; it only picks one
action record from the list. If a card effect has a source: "player" parameter,
legal_actions expands one action per legal target (capped; see Q5).
C3 — Triggers v1: a deterministic queue, no stack
The doc reserves both trigger_queue and resolution_stack but defines
neither. v1 rule: when an action resolves, collect fired triggers in play order
(order cards entered play), append to a FIFO queue, drain it before the action completes.
No interrupts, no priority windows, no resolution_stack field at all —
it gets added the day a designed game needs reactions, not before.
C4 — Plain array + helper functions, no Game class
PHP 7.2 (server version) has no typed properties, and the evaluator already consumes
$game as an array. So: the Game is a documented array shape;
inc-simulator-state.php ships constructor/accessor/mutator functions
(gameZone($game, $key), gameMoveCard(&$game, $cardId, $toZone), …).
A class wrapper can come later without changing stored shapes.
C5 — Own tiny PRNG
mt_rand() is global-state and version-sensitive. The engine ships a ~15-line
xorshift32 in pure PHP: state lives in $game['rng'], every draw is recorded,
replays are bit-identical on any PHP version.
The Game shape
$game = [
// ── identity ─────────────────────────────────────────────
'id' => 'run_a1b2c3',
'game_unique_id'=> '<game_info.unique_id>', // the authored design
'seed' => 12345,
'status' => 'in_progress', // | 'ended'
'result' => null, // set at end: ['winner'=>'p0','reason'=>'ending:combat','turn'=>14]
// ── config (frozen at setup) ─────────────────────────────
'config' => [
'player_count' => 2,
'seat_order' => ['p0','p1'],
'starting_hand_size' => 5,
'turn_limit' => 100, // hard cap: game ends as draw / score-out
],
// ── players (state only — zones live in the flat map) ────
'players' => [
['id'=>'p0','seat'=>0,'status'=>'active','controller'=>'bot:random',
'trackers'=>['life'=>20,'credits'=>0], 'modifiers'=>[]],
['id'=>'p1','seat'=>1,'status'=>'active','controller'=>'bot:random',
'trackers'=>['life'=>20,'credits'=>0], 'modifiers'=>[]],
],
// ── zones: THE flat map (C1) ─────────────────────────────
'zones' => [ /* see C1 */ ],
// ── shared trackers (scope=shared trackers only) ─────────
'shared_trackers' => ['round'=>1],
// ── card instances ───────────────────────────────────────
'card_instances' => [
'c1' => ['definition_id'=>'<unique_card_id>','owner'=>'p0',
'zone'=>'deck:p0','face'=>'down','counters'=>[],'modifiers'=>[]],
// 'zone' mirrors zones[...]['cards'] — the zone list is authoritative
// for ORDER, the instance field for fast lookup. gameMoveCard() keeps
// both in sync; a validator asserts the invariant.
],
// ── card definitions are NOT here ────────────────────────
// setup() loads game_cards rows once into a read-only
// $library[definition_id] passed alongside $game. Never serialized
// into the state.
// ── flow ─────────────────────────────────────────────────
'flow' => [
'turn_number' => 1,
'active_player' => 'p0',
'phase' => 'main', // id from game_meta['phases']
'phase_queue' => ['cleanup'], // rest of this turn
'trigger_queue' => [], // C3 — FIFO, drained per action
],
// ── history ──────────────────────────────────────────────
'history' => [
['seq'=>0,'type'=>'setup','seed'=>12345],
['seq'=>1,'actor'=>'p0','type'=>'play_card','card'=>'c4',
'targets'=>[],'effects'=>['deal_1_damage_to_opponent'],'rng_used'=>0],
],
// ── rng ──────────────────────────────────────────────────
'rng' => ['seed'=>12345,'state'=>12345,'draws'=>0],
];
player.trackers.X, count_cards over zones,
trigger.target context) maps onto this shape. The only
adapter needed is resolveZoneContents() reading the flat
map (C1).
setup(): authored data → Game
setup($conn, $gameUniqueId, $seed, $opts) resolves the
authored design into an initial Game + read-only library. Field by
field, with the fallback used when the designer didn't author that
piece (audited against Physoterapists Duel, 2026-08-02):
| Game field | Authored source | Fallback when missing | Real game? |
|---|---|---|---|
players[].trackers | getGameTrackers() (unified; legacy folded in) → starts_at per tracker, per-player scope | — (always resolves) | yes life=20 + credits=0 |
zones | game_meta['game_zones'] defs, expanded per scope | deck-builder default set (deck/hand/in_play/discard per player) | yes 6 zone defs |
card_instances + library | game_cards rows of the game's decks; copies per card: card_json.copies | copies = 1 (see Q2) | 6 cards, 1 deck |
| initial zone fill | game_meta['setup_spec'] (deck→zone map, hand size, starting values) | Q1 defaults: each player gets a shuffled copy of the starter deck into deck:pN, draws starting_hand_size=5 | not authored — fallback used |
flow.phase / phase defs | game_meta['phases'] | 2-phase default (main player_driven, cleanup automatic) | yes main + cleanup, well-formed |
| action vocabulary | game_meta['actions'] ∪ every phase's allowed_actions ∪ mandatory actions | — (union always non-empty) | inconsistent actions=[play_card] but phases allow buy/attack — union fixes it, validator should warn (Q3) |
| end conditions | game_meta['ending'].ends_when[] — evaluated by GameExpressions after every apply() | turn_limit only, flagged loudly in the report | yes combat: life ≤ 0 |
config.player_count | briefing min/max players; $opts may pick within range | 2 | yes |
setup() is deterministic given (game data, seed, opts). It ends by
appending the setup history record and validating
invariants (every instance in exactly one zone; every tracker id
referenced by endings exists — reusing SchemaValidator's rules
server-side).
legal_actions() and apply()
Action record (what legal_actions returns and apply consumes; fully concrete per C2):
['type'=>'play_card', 'actor'=>'p0', 'card'=>'c4', 'targets'=>['effect_0'=>'p1'], // one entry per player-sourced param 'cost'=>['credits'=>2]] // resolved cost, pre-validated
legal_actions($game, $library, $playerId) walks the current
phase's allowed_actions, asks each action handler to enumerate its legal
concrete instances (playable cards × legal targets, affordable buys, …), and always
appends end_phase when the phase is player-driven — the list is never empty.
apply($game, $library, $action) — the model doc's sequence, kept verbatim:
1 validate action is in legal_actions (cheap re-check)
2 record append history (with rng draw count before/after)
3 resolve pay costs, move cards, run the card's on_play effects
4 triggers collect fired triggers in play order → FIFO → drain (C3)
5 modifiers expire anything whose timer ran out
6 flow advance phase/turn if the action ends one; run automatic
phases (mandatory_actions) until a player-driven phase
7 end-check evaluate every ending.when via GameExpressions with
{player, opponent, trigger} context; set status/result
8 return
Effect handlers are small functions keyed by mechanic
(deal_damage, gain_resource, draw_cards,
discard_cards cover the current catalog); each mutates trackers/zones through the
state helpers, never directly. Triggers dispatch on the per-effect trigger field
shipped in 3.4.
viewFor(): redaction
viewFor($game, $playerId) returns a deep copy where, per zone definition
visibility: zones visible to all pass through; owner-visible zones
keep contents only for the viewer (others become ['count'=>N]); hidden zones
expose count only for everyone; face-down instances lose their definition_id.
History entries are passed as-is v1 (Q4). Bots receive only the view.
History & replay
Every history record carries seq, actor, the full action record, and
the rng draw counter. Guarantee: replay(gameData, seed, history) re-applies
actions 1..N and must land on a state whose hash equals the live run's — asserted in tests.
This is the debugging story for "why did run #372 loop forever".
Files & build order
| Step | File | Delivers | Acceptance |
|---|---|---|---|
| 3.8a | php-includes/inc-simulator-state.php | shape doc, state helpers, PRNG, invariant validator | helpers unit-tested; invariant checker green on hand-built states |
| 3.8b | php-includes/inc-simulator-setup.php | setup() incl. Q1 defaults | Physoterapists Duel → valid initial Game, deterministic per seed; evaluator's count_cards works against it via C1 adapter |
| 3.9 | php-includes/inc-simulator-engine.php | apply() + effect/action handlers | scripted action sequence reaches a known end state; combat ending fires when life hits 0 |
| 3.10 | same file | legal_actions() | never empty during player-driven phases; every returned action passes validate |
| 3.11 | inc-simulator-bot.php, inc-simulator-runner.php | random bot + CLI runner (php tests/run-sim.php <game> <n> <seed>) | 1 full game start→winner, no errors, replay hash matches |
| 3.12 | inc-simulator-report.php + UI button later | N-run harness: win rates, avg length, dead cards, loop detection | 1,000 runs of the real game produce a report naming its dead cards |
Engine code stays PHP 7.2-compatible and CLI-first; the web UI wraps the CLI later. Estimate: 3.8 ≈ 2–3 days, 3.9+3.10 ≈ 3–5 days, 3.11+3.12 ≈ 2–3 days.
Open questions — decide before code
| # | Question | Proposed default |
|---|---|---|
| Q1 | No setup_spec authored: how do zones get filled? | Each player receives an identical shuffled copy of the game's starter deck (deck-builder convention); draw 5. A tiny Setup section on Step 2/Anatomy later makes this explicit. |
| Q2 | Card copies: 6 unique designs ≠ a playable 10-card deck. Where does the copy count live? | v1: card_json.copies if present else 1; flag decks under 10 playable cards in the report. Longer term the frequency distribution should drive it. |
| Q3 | Enabled actions (["play_card"]) disagree with phases' allowed_actions (buy, attack). Which wins? | Union of both, plus a validator warning on the mismatch so the designer fixes the authoring. |
| Q4 | Should bots see full history (includes hidden info like what opponent drew)? | v1 yes (random bots don't read it); redact before any smart bot ships. |
| Q5 | Target explosion cap for legal_actions? | Cap enumeration at 50 concrete actions per (card, effect); if exceeded, sample deterministically via the game PRNG. |