Files
Wizard/docs/03-game-engine.md
T
2026-09-27 11:26:10 +02:00

11 KiB
Raw Blame History

3. Game engine (wizard_game)

Location: wizard_server/game_logic/ (distribution name wizard-game, import name wizard_game). Pure Python ≥ 3.11, no dependencies.

It is used by

  • the server, to run games (wizard_server/app/lobby.py), and
  • the AI, to train and to decode observations (wizard_ai/).

Install it on its own with pip install ./wizard_server/game_logic (or pip install -e … while developing — see troubleshooting).

Modules

Module Contents
cards.py Suit (enum), CardKind (number/wizard/jester), Card (frozen dataclass with id, kind, suit, rank), ALL_CARDS, card_by_id, new_shuffled_deck
actions.py Bid(value), PlayCard(card), Action = Bid | PlayCard, action_to_dict, action_from_dict
rules.py stateless rule functions: total_rounds, trump_suit, lead_suit, legal_cards, trick_winner, round_score, scoring constants
state.py Phase, GameOptions, PlayedCard, TrickResult, RoundResult, the event classes, and the internal mutable RoundState
observation.py Observation: the view of one player, with lossless to_dict / from_dict
game.py WizardGame (the state machine) and IllegalActionError
__init__.py re-exports everything public, so from wizard_game import WizardGame, Bid, … works

Using it

import random
from wizard_game import WizardGame, GameOptions

game = WizardGame(num_players=4, options=GameOptions(hidden_bids=False), seed=1)
rng = random.Random(1)

while not game.is_finished:
    player = game.current_player              # whose turn it is (seat index)
    obs = game.observation(player)            # what that player may know
    action = rng.choice(obs.legal_actions)    # a Bid or PlayCard
    events = game.apply(player, action)       # advances the game

print(game.scores, game.winners())

The loop is the whole API: ask whose turn it is → look at the observation → apply one of the legal actions. Rounds advance automatically.

WizardGame public API

Member Description
WizardGame(num_players, options=None, *, seed=None, rng=None, first_dealer=None) Creates a game and immediately deals round 1. Use seed or pass your own random.Random as rng for reproducibility.
num_players, options, total_rounds constants of this game
phase Phase.BIDDING, Phase.PLAYING or Phase.FINISHED
current_player seat index whose turn it is (None when finished)
round_number, dealer current round (1-based) and its dealer
scores list of total scores per seat
round_history list of RoundResult, one per finished round
last_trick TrickResult of the most recently completed trick
hand(player) copy of a player's hand (server/training use only — it is private information)
legal_actions(player=None) legal actions of player (default: current player); empty if it is not their turn
is_legal(player, action) shortcut for action in legal_actions(player)
apply(player, action) -> list[Event] performs the action; raises IllegalActionError if it is not that player's turn, the game is over, or the action is illegal
observation(player) -> Observation the player's view (see below)
winners() seats with the highest score
clone() deep copy, e.g. for search-based AIs that simulate moves

State machine

stateDiagram-v2
    [*] --> BIDDING: __init__ deals round 1
    BIDDING --> BIDDING: Bid (not everyone has bid yet)
    BIDDING --> PLAYING: last Bid
    PLAYING --> PLAYING: PlayCard (trick not full / trick full but round not over)
    PLAYING --> BIDDING: PlayCard completes the last trick\n(round scored, next round dealt)
    PLAYING --> FINISHED: PlayCard completes the last trick of the last round
    FINISHED --> [*]

What happens inside apply (game.py):

  • _bid stores the bid. When all bids are in, the phase becomes PLAYING and the starter (round.trick_leader) is to move; otherwise the next seat.
  • _play removes the card from the hand, appends it to current_trick and to played_cards. When every player has played, _complete_trick runs:
    • determines the winner (rules.trick_winner), increments tricks_won,
    • stores a TrickResult in last_trick and emits TrickCompleted,
    • if tricks remain: the winner leads the next trick,
    • otherwise _complete_round: computes score changes, appends a RoundResult to round_history, emits RoundCompleted, and then either finishes the game (GameFinished) or deals the next round (RoundStarted) with the next dealer.

So a single PlayCard can produce up to three events, e.g. [TrickCompleted, RoundCompleted, RoundStarted].

Internal per-round state (state.RoundState)

Mutable, owned by WizardGame.round; not part of the public API.

Field Meaning
round_number, dealer
hands list of lists of Card per seat
trump_card, trump_suit the turned-up card and the resulting suit (or None)
bids int or None (not yet bid) per seat
tricks_won per seat
current_trick list of PlayedCard(player, card) in play order
trick_leader, trick_number who leads / which trick (1-based)
played_cards every card played this round, including the current trick

Observation — what a player may know

observation.py. Built by WizardGame.observation(player). Immutable (frozen dataclass). This is the data structure of the project: the browser renders it and the AI decides on it.

Field Type Meaning
player int whose view this is
num_players, options
phase Phase
round_number, total_rounds, dealer int
current_player int | None
hand tuple[Card] own cards, sorted by id
hand_sizes tuple[int] number of cards each seat still holds
trump_card Card | None turned-up card of this round
trump_suit Suit | None None for wizard/jester/last round
bids tuple[int | None] None = not yet bid or hidden (hidden bids option)
has_bid tuple[bool] who has already bid (visible even when bids are hidden)
tricks_won tuple[int] this round
scores tuple[int] totals
trick_number, trick_leader int
current_trick tuple[PlayedCard] cards on the table
lead_suit Suit | None suit that must be followed right now
played_cards tuple[Card] all cards played this round (for card counting)
last_trick TrickResult | None most recent completed trick
round_history tuple[RoundResult] all finished rounds
legal_actions tuple[Action] empty unless it is this player's turn
winners tuple[int] filled when finished

Property is_my_turn is current_player == player.

Privacy guarantees (tested in game_logic/tests/test_game.py):

  • Only the observer's own hand is included.
  • With hidden bids, other players' bids are None until all bids are placed.
  • Legal actions are only present for the player to move.

Results and events (state.py)

Type Fields
PlayedCard player, card
TrickResult round_number, trick_number, cards (tuple of PlayedCard), winner, trump_card (trump card of that trick's round)
RoundResult round_number, bids, tricks, score_changes, scores (totals after the round) — all tuples indexed by seat
RoundStarted (event) round_number, dealer, trump_card
TrickCompleted (event) trick: TrickResult
RoundCompleted (event) result: RoundResult
GameFinished (event) scores, winners

Events are returned by apply and forwarded to browsers so the UI can show, e.g., the completed trick for a moment.

JSON format

Every type above has to_dict(); Observation, TrickResult, RoundResult, PlayedCard, GameOptions also have from_dict(). The JSON is what goes over the websockets. Examples:

// Card
{"id": 17, "kind": "number", "suit": "yellow", "rank": 5}
{"id": 53, "kind": "wizard", "suit": null, "rank": 0}

// Actions
{"type": "bid", "value": 2}
{"type": "play", "card": 17}

// Event
{"type": "trick_completed", "trick": {"round_number": 3, "trick_number": 2,
  "cards": [{"player": 1, "card": {…}}, …], "winner": 2, "trump_card": {…}}}

A full observation looks like this (shortened):

{
  "player": 0, "num_players": 3, "options": {"hidden_bids": false},
  "phase": "playing", "round_number": 3, "total_rounds": 20, "dealer": 2,
  "current_player": 0,
  "hand": [{"id": 4, "kind": "number", "suit": "red", "rank": 5}, …],
  "hand_sizes": [2, 2, 2],
  "trump_card": {"id": 44, "kind": "number", "suit": "blue", "rank": 6},
  "trump_suit": "blue",
  "bids": [1, 0, 1], "has_bid": [true, true, true],
  "tricks_won": [1, 0, 0], "scores": [40, 30, -10],
  "trick_number": 2, "trick_leader": 0, "current_trick": [],
  "lead_suit": null, "played_cards": […], "last_trick": {…},
  "round_history": [{"round_number": 1, …}, {"round_number": 2, …}],
  "legal_actions": [{"type": "play", "card": 4}, …],
  "winners": []
}

action_from_dict validates strictly: unknown types, missing fields, non-integer values (booleans are rejected too) and card ids outside 0–59 raise ValueError.

Randomness and reproducibility

All randomness (first dealer, shuffling) comes from one random.Random (seed= or rng=). The same seed and the same sequence of actions always produce the same game — useful for tests and for comparing AI agents on identical deals.

Tests

wizard_server/game_logic/tests/:

  • test_rules.py — each rule function (lead suit, legal cards, trick winner, trump suit, scoring, round counts).
  • test_game.py — full random games for 3–6 players with and without hidden bids; invariants checked on every round (tricks add up, bids never add up to the cards in hand, the trump card is never in a hand, completed tricks keep their trump card); JSON round trips; illegal actions; hidden bids.

Extending the engine

  • A new rule option: add a field to GameOptions (plus to_dict / from_dict), read self.options.<field> in game.py, add it to the server's SetOptions message and the lobby UI, and consider adding it to the AI feature encoding. See recipes.
  • A new action type: add a dataclass in actions.py, handle it in action_from_dict, WizardGame.legal_actions and apply, and extend ActionSpace in wizard_ai/wizard_ai/model/encoding.py and the TypeScript Action type.