Skip to content

Build a game

A game is a spec: one object implementing the GameSpec protocol from turngames.core. The kernel's StateMachine owns event sequencing, logging and replay; the spec owns legality, transitions, scoring, and visibility.

class GameSpec(Protocol[StateT, ConfigT]):
    id: str

    def initial_state(self, seed: str, config: ConfigT) -> StateT: ...
    def seats(self, state: StateT) -> tuple[Seat, ...]: ...
    def acting_seat(self, state: StateT) -> str | None: ...
    def observe(self, state: StateT, seat_id: str) -> Observation: ...
    def legal_actions(self, state: StateT, seat_id: str) -> tuple[dict, ...]: ...
    def validate(self, state: StateT, seat_id: str, action: Action) -> Ruling: ...
    def step(self, state: StateT, seat_id: str, action: Action) -> StepTransition: ...
    def handle_invalid(self, ...) -> StepTransition: ...
    def is_terminal(self, state: StateT) -> bool: ...
    def score(self, log: EventLog, state: StateT) -> dict: ...

The load-bearing three: observe (per-seat visibility — hidden-information games are projections), validate/handle_invalid (illegal moves are game events with costs, not crashes — that's what lets flawed models play unattended), and score (the verifiable reward, computed from the full event log).

State is immutable; step returns the next state plus events to append. The event log is the match — replays, the broadcast feed, and RL rollouts all derive from it.

Register and run

One entry in turngames/registry.py is the whole integration surface:

GAMES["yourgame"] = GameEntry(
    id="yourgame",
    title="Your Game",
    spec_factory=YourGameSpec,
    config_factory=lambda labels: YourGameConfig(seat_labels=labels),
    seat_ids=("red_1", "red_2", "blue_1", "blue_2"),
    baseline_factory=lambda seed: BaselineYourGameActor(seed=seed),
    prompter_factory=YourGamePrompter,
)

scripts/play_codewords_demo.py runs a whole match in the terminal — the fastest way to watch a new spec breathe.