11 KiB
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):
_bidstores the bid. When all bids are in, the phase becomesPLAYINGand the starter (round.trick_leader) is to move; otherwise the next seat._playremoves the card from the hand, appends it tocurrent_trickand toplayed_cards. When every player has played,_complete_trickruns:- determines the winner (
rules.trick_winner), incrementstricks_won, - stores a
TrickResultinlast_trickand emitsTrickCompleted, - if tricks remain: the winner leads the next trick,
- otherwise
_complete_round: computes score changes, appends aRoundResulttoround_history, emitsRoundCompleted, and then either finishes the game (GameFinished) or deals the next round (RoundStarted) with the next dealer.
- determines the winner (
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
Noneuntil 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(plusto_dict/from_dict), readself.options.<field>ingame.py, add it to the server'sSetOptionsmessage 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 inaction_from_dict,WizardGame.legal_actionsandapply, and extendActionSpaceinwizard_ai/wizard_ai/model/encoding.pyand the TypeScriptActiontype.