A deterministic Small World board game engine in Python. Implements the complete base game rules with 14 races, 20 special powers, procedural map generation, and an AI-friendly observation API. All randomness is seeded for perfect replay and reproducibility.
Version: 0.1.0 License: MIT Python: >= 3.12 Dependencies: Standard library only (no third-party runtime dependencies)
- Quick Start
- Project Structure
- Architecture Overview
- Core Modules
- Engine (
engine.py) - Game State (
state.py) - Phase Machine (
phase_machine.py) - Actions (
actions.py) - Conquest (
conquest.py) - Scoring (
scoring.py) - Observation API (
observation.py) - Map & Map Generator (
game_map.py,map_generator.py) - Combo Row (
combo.py) - Data Loader (
data_loader.py) - Seeded RNG (
rng.py) - Enums (
enums.py) - Errors (
errors.py)
- Engine (
- Ability System
- Turn Flow & Phase Transitions
- Conquest Algorithm
- Scoring Breakdown
- Map Generation
- Design Decisions
- Data Files
- Testing
- Rules Reference
- Key Constants & Configuration
uv pip install -e .Note: This project uses uv for fast, reliable package management. Install it with:
pip install uvfrom smallworld import SmallWorldGame, GameConfig
# Create a 3-player game with seed 42
config = GameConfig(num_players=3, seed=42)
game = SmallWorldGame(config)
# Game loop
while not game.is_game_over():
player_id = game.get_current_player()
observation = game.get_observation(player_id)
legal_actions = game.get_legal_actions()
# Pick an action (your agent logic here)
action = legal_actions[0]
result = game.submit_action(player_id, action)
# Get final results
results = game.get_results()
print(f"Winner: Player {results['winner']}")
print(f"Scores: {results['scores']}")# Replay a game from its config and action log
replayed_game = SmallWorldGame.replay(config, game._state.action_log)uv run pytestOr if you prefer to activate the environment:
uv pip install pytest
pytestsmallworld/
├── pyproject.toml # Build config (setuptools, Python >=3.12)
├── README.md # This file
├── assets/
│ ├── races.json # 14 race configurations (tokens, decline rules)
│ └── powers.json # 20 power configurations (tokens, decline rules)
├── rules/
│ ├── 00_rules_overview.md # Game overview
│ ├── 01_setup.md # Setup rules
│ ├── 02_turn_structure.md # Turn structure rules
│ ├── 03_conquest.md # Conquest rules
│ ├── 04_decline.md # Decline rules
│ ├── 05_scoring_endgame.md # Scoring and endgame rules
│ └── 06_glossary.md # Game terminology glossary
├── smallworld/
│ ├── __init__.py # Public API: SmallWorldGame, GameConfig
│ ├── engine.py # Game orchestrator (~1070 lines)
│ ├── state.py # Dataclasses for all game state (~158 lines)
│ ├── enums.py # Terrain, Feature, Marker, Phase, ActionType, etc.
│ ├── actions.py # Action validation & legal move enumeration (~1138 lines)
│ ├── conquest.py # Conquest cost, execution, displacement (~342 lines)
│ ├── scoring.py # Per-turn scoring logic (~98 lines)
│ ├── phase_machine.py # Turn phase state machine (~318 lines)
│ ├── game_map.py # Immutable map graph structure (~122 lines)
│ ├── map_generator.py # Procedural hex-grid map generation (~340 lines)
│ ├── combo.py # Race+Power combo selection row (~83 lines)
│ ├── observation.py # Player observation API for agents (~146 lines)
│ ├── data_loader.py # JSON asset loader with caching (~109 lines)
│ ├── rng.py # Seeded RNG wrapper (~51 lines)
│ ├── errors.py # Custom exception hierarchy (~19 lines)
│ └── abilities/
│ ├── __init__.py # AbilityHook base class & registry (~140 lines)
│ ├── races.py # 14 race ability implementations (~225 lines)
│ └── powers.py # 20 power ability implementations (~310 lines)
└── tests/
├── __init__.py
├── conftest.py # Fixtures (game_config, game, game_state) & helpers
├── test_engine.py # Engine integration tests
├── test_actions.py # Action validation tests
├── test_conquest.py # Conquest mechanic tests
├── test_combo.py # Combo row tests
├── test_scoring.py # Scoring tests
├── test_map_generator.py # Map generation tests
├── test_determinism.py # Determinism / replay verification
└── test_full_simulation.py # Full game simulation tests
┌─────────────────────────────────────────────────────────────────┐
│ SmallWorldGame │
│ (engine.py:68) │
│ │
│ Public API: │
│ submit_action() ─── validate_action() ─── _execute() │
│ get_observation() get_legal_actions() _auto_advance() │
│ get_current_player() get_phase() replay() │
│ is_game_over() get_results() │
└──────────────────────────┬──────────────────────────────────────┘
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌─────────────┐ ┌────────────────┐
│ actions.py │ │ conquest.py │ │ phase_machine │
│ Validation │ │ Execution │ │ .py │
│ & Legal │ │ & Displace │ │ Phase FSM │
│ Move Enum │ │ -ment │ │ │
└──────┬─────┘ └──────┬──────┘ └───────┬────────┘
│ │ │
└───────┬───────┘ │
▼ │
┌─────────────┐ │
│ GameState │◄───────────────────┘
│ (state.py) │
│ │
│ Single │
│ source of │
│ truth │
└──────┬───────┘
│
┌────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌───────────┐
│MapGraph│ │ ComboRow │ │PlayerState│
│(frozen)│ │ │ │RegionState│
└────────┘ └──────────┘ └───────────┘
Cross-cutting: abilities/ (hook-based system for race/power behavior)
scoring.py (end-of-turn coin awards)
observation.py (agent-facing API)
rng.py (deterministic randomness)
- Agent calls
game.submit_action(player_id, action_dict) - Engine normalizes the action type (
engine.py:48) - Action is validated by
actions.py:validate_action() - Action is dispatched to the appropriate handler via
_ACTION_HANDLERSdispatch table - Handler mutates
GameStatein place - Action is logged to
GameState.action_log _auto_advance()runs automatic phases (SCORE, TURN_END)- Result dict is returned with new phase, current player, and game-over flag
File: smallworld/engine.py
The top-level game orchestrator. Contains the SmallWorldGame class and all action handler functions.
| Method | Line | Description |
|---|---|---|
__init__(config) |
75 | Initialize game: create map, regions, players, combo row |
get_observation(player_id) |
117 | Build JSON-serializable player observation |
get_legal_actions() |
121 | Enumerate all legal actions for current player |
get_current_player() |
125 | Return current player ID |
get_phase() |
129 | Return current game phase |
is_game_over() |
133 | Check if game has ended |
get_results() |
137 | Final scores, rankings, and winner |
submit_action(player_id, action) |
155 | Validate and execute an action, returns result dict |
replay(config, action_log) |
268 | Static method: replay a game deterministically |
All handlers take (gs: GameState, action: dict) and return a result dict:
| Handler | Line | Triggered By |
|---|---|---|
_handle_pick_combo |
280 | PICK_COMBO |
_handle_expand |
~320 | EXPAND |
_handle_decline |
~340 | DECLINE |
_handle_conquer |
~400 | CONQUER |
_handle_skip_conquest |
~500 | SKIP_CONQUEST |
_handle_final_conquest |
~520 | FINAL_CONQUEST |
_handle_redeploy |
~580 | REDEPLOY |
_handle_use_dragon |
~640 | USE_DRAGON |
_handle_use_sorcerer |
~700 | USE_SORCERER |
_handle_place_bivouacs |
~760 | PLACE_BIVOUACS |
_handle_place_fortress |
~790 | PLACE_FORTRESS |
_handle_place_heroes |
~810 | PLACE_HEROES |
_handle_choose_diplomat_ally |
~830 | CHOOSE_DIPLOMAT_ALLY |
_handle_stout_decline |
~845 | STOUT_DECLINE |
_handle_opponent_redeploy |
~855 | OPPONENT_REDEPLOY |
The engine automatically processes phases that require no player input:
- SCORE: Awards victory coins via
scoring.py - TURN_END: Fires turn-end hooks, advances to the next player
- Empty post-redeploy phases: Skipped when player has no valid actions
- OPPONENT_REDEPLOY: Auto-resolves when displaced players have no regions
File: smallworld/state.py
All mutable game state lives in dataclasses. GameState is the single source of truth.
Mutable occupancy state of a single map region.
region_id: int
owner: int | None # Player who controls this region
tokens: int # Number of race tokens
is_decline: bool # Whether tokens belong to a decline race
decline_race: RaceName | None
decline_power: PowerName | None
markers: list[Marker] # MOUNTAIN, TROLL_LAIR, FORTRESS, ENCAMPMENT, etc.
has_lost_tribe: bool # Whether a lost tribe occupies this regionTracks a player's currently active race and per-turn bookkeeping.
race_name: RaceName
power_name: PowerName
tokens_in_hand: int # Tokens available for conquest/redeployment
total_tokens: int # Total tokens for this race
first_turn: bool # True on the turn the combo was picked
regions_conquered_this_turn: list[int] # Region IDs conquered this turn
non_empty_conquests_this_turn: int # Count of conquests against occupied regions
opponents_attacked_this_turn: set[int] # Player IDs attacked (for Diplomat)
sorcerer_targets_this_turn: set[int] # Regions converted by Sorcerer
dragon_used_this_turn: bool # Whether dragon was used
berserk_die_result: int | None # Last Berserk die resultplayer_id: int
coins: int = 5 # Victory coins (starts at 5)
active: ActiveRaceState | None # Current active race
decline: DeclineRaceState | None # Primary decline race
decline_spirit: DeclineRaceState | None # Second decline (Spirit power only)
displaced_tokens: int # Tokens awaiting redeploymentImmutable configuration. Validates player count (3, 4, or 5) and derives max_turns.
num_players: int # 3, 4, or 5
seed: int # RNG seed for game events
map_seed: int | None # Separate seed for map generation (defaults to seed)Complete mutable game state. Holds the map, all region states, all player states, combo row, phase, turn counter, RNG, action log, and special state for displacements, diplomat immunity, and ghoul conquests.
Key query methods:
player_regions(player_id, decline)(line 116) -- all regions owned by a playerplayer_active_regions(player_id)(line 135) -- active (non-decline) regionsplayer_decline_regions(player_id)(line 139) -- decline regionstokens_on_map(player_id, decline)(line 151) -- count tokens on the map
File: smallworld/phase_machine.py
A pure-function state machine that determines phase transitions. Does not mutate state -- only reads it.
| Function | Line | Description |
|---|---|---|
get_legal_action_types(game_state) |
20 | Legal action types for current phase |
get_post_redeploy_phases(game_state, player_id) |
104 | Extra phases between REDEPLOY and SCORE |
advance_phase(game_state, action_type) |
126 | Compute next phase given current phase + action |
advance_to_next_player(game_state) |
240 | Move to next player, increment turn, check game-over |
File: smallworld/actions.py
Handles both validation (is this action legal?) and enumeration (what are all legal actions?).
validate_action(game_state, player_id, action)-- dispatches to per-type validators
get_legal_actions(game_state)-- returns a list of all legal action dicts for the current player/phaseget_conquerable_regions(game_state, player_id)-- returns all regions the player can conquer
Each action type has a _validate_* function and an _enum_* function. Redeploy enumeration has both a compact mode (for large state spaces) and an exhaustive mode for smaller cases.
File: smallworld/conquest.py
| Function | Line | Description |
|---|---|---|
compute_base_conquest_cost(region_id, game_state) |
48 | Raw cost: 2 + defenders + markers + lost tribe |
compute_conquest_cost(region_id, game_state, player_id) |
74 | After race/power discounts (min 1) |
can_conquer_region(region_id, game_state, player_id) |
106 | Full legality check (ownership, immunity, water, diplomat, tokens) |
execute_conquest(region_id, game_state, player_id, tokens_used) |
188 | Perform conquest: displace, clear markers, install attacker, fire hooks |
handle_displacement(game_state, defender_id, tokens, is_decline) |
314 | Queue active tokens for redeployment; decline tokens are lost |
File: smallworld/scoring.py
| Function | Line | Description |
|---|---|---|
compute_player_score(game_state, player_id) |
18 | Compute total coins earned this turn |
apply_scoring(game_state, player_id) |
87 | Compute score and add to player's coins |
File: smallworld/observation.py
Builds a JSON-serializable dict for AI agents. Designed for information asymmetry: opponent coins are "HIDDEN", RNG state is never exposed.
build_observation(game_state, player_id)(line 96) -- returns a dict with sections:game-- turn number, max turns, num players, current player, phasemap-- all regions with terrain, features, owner, tokens, markers, adjacencycombo_row-- visible combos with race, power, total tokens, coins on comboself-- player's coins, active race (full detail), decline racesopponents-- other players' public info (coins hidden)legal_actions-- all legal actions for the requesting player
Files: smallworld/game_map.py, smallworld/map_generator.py
Frozen dataclass representing a map node. Properties: is_border, is_coastal, has_mine, has_magic_source, has_cavern, is_water, is_land.
Immutable undirected graph. Never modified during gameplay.
| Method | Line | Description |
|---|---|---|
get_region(id) |
67 | Lookup region by ID |
neighbors(id) |
71 | Adjacent region IDs |
border_regions() |
75 | All border region IDs (cached) |
coastal_regions() |
83 | All coastal region IDs (cached) |
cavern_regions() |
91 | All cavern region IDs (cached) |
land_regions() |
99 | All non-water region IDs |
water_regions() |
103 | All sea/lake region IDs |
regions_by_terrain(terrain) |
107 | Filter by terrain type |
regions_with_feature(feature) |
111 | Filter by feature |
Procedural hex-grid generation with retry. Entry point: generate_map(num_players, rng) (line 313).
The generator:
- Builds a hex grid sized for the player count
- Removes 2-4 random edge cells for irregular shape
- Places sea on edges, lakes in interior
- Assigns weighted terrain types to land cells
- Assigns features (mines, magic sources, caverns)
- Computes border and coastal regions
- Validates: land connectivity (BFS), minimum border regions, minimum feature counts
- Places lost tribes on ~40% of land regions
- Retries up to 100 times on failure
File: smallworld/combo.py
A race+power pairing. total_tokens = race.base_tokens + power.bonus_tokens.
The 6-visible combo selection mechanism.
- Constructor (line 27): Shuffles race and power decks, draws 6 combos
visible(line 44): Currently visible combos (index 0 is top/free)pick(index, player_coins)(line 49): Select combo at index N. Costs N coins, which are placed one each on combos 0..N-1. Returns(chosen_combo, net_coin_change).
File: smallworld/data_loader.py
Loads race/power configurations from JSON files in assets/. Uses lazy caching -- data is loaded from disk on first access and cached globally.
| Function | Line | Description |
|---|---|---|
load_races(path) |
39 | Parse races.json into dict[RaceName, RaceConfig] |
load_powers(path) |
57 | Parse powers.json into dict[PowerName, PowerConfig] |
get_race_config(name) |
79 | Cached single race lookup |
get_power_config(name) |
87 | Cached single power lookup |
get_all_races() |
95 | Cached full race dict |
get_all_powers() |
103 | Cached full power dict |
name: RaceName
base_tokens: int
persists_in_decline: bool | strname: PowerName
bonus_tokens: int
persists_in_decline: bool | strFile: smallworld/rng.py
All game randomness flows through SeededRNG (line 9), a wrapper around random.Random. This guarantees deterministic replay when given the same seed.
| Method | Line | Description |
|---|---|---|
roll_reinforcement_die() |
20 | Returns 0, 0, 0, 1, 2, or 3 (weighted: 50% chance of 0) |
shuffle(items) |
24 | In-place list shuffle |
random_int(lo, hi) |
28 | Random integer in [lo, hi] |
random_float() |
32 | Random float in [0.0, 1.0) |
choice(items) |
36 | Random element from list |
sample(items, k) |
40 | k unique random elements |
get_state() / set_state() |
44, 48 | Serialize/restore RNG state |
The reinforcement die faces are defined at module level: REINFORCEMENT_DIE_FACES = [0, 0, 0, 1, 2, 3] (line 6).
File: smallworld/enums.py
| Enum | Line | Values |
|---|---|---|
Terrain |
4 | FARMLAND, HILL, FOREST, SWAMP, MOUNTAIN, SEA, LAKE |
Feature |
15 | MINE, MAGIC_SOURCE, CAVERN, BORDER, COASTAL |
Marker |
24 | MOUNTAIN, LOST_TRIBE, TROLL_LAIR, FORTRESS, ENCAMPMENT, HOLE_IN_THE_GROUND, DRAGON, HERO |
Phase |
44 | 15 phases (see Turn Flow) |
ActionType |
63 | 15 action types |
RaceName |
82 | 14 races (string values, e.g. "Amazons") |
PowerName |
100 | 20 powers (string values, e.g. "Dragon Master") |
DEFENSIVE_MARKERS (line 36): frozenset({MOUNTAIN, TROLL_LAIR, FORTRESS, ENCAMPMENT}) -- markers that add +1 to conquest cost.
File: smallworld/errors.py
SmallWorldError (base)
├── IllegalAction -- Action violates game rules
├── InvalidPhase -- Wrong action for current phase
├── GameOver -- Game already finished
└── InvalidConfig -- Bad GameConfig parameters
Files: smallworld/abilities/__init__.py, smallworld/abilities/races.py, smallworld/abilities/powers.py
AbilityHook (line 17 in __init__.py) is the base class for all race and power abilities. Every method has a default no-op implementation, so subclasses only override the hooks they need.
| Hook | Default | Purpose |
|---|---|---|
modify_conquest_cost() |
0 | Return discount amount for a conquest |
get_extra_adjacency() |
set() |
Additional regions considered adjacent |
ignore_adjacency() |
False |
Override all adjacency checks |
is_valid_first_target() |
None |
Override first-conquest border restriction |
can_conquer_sea() |
False |
Allow sea/lake conquest |
can_conquer_target() |
True |
General conquest permission |
on_region_conquered() |
no-op | Fires after a conquest |
modify_tokens_lost_on_defeat() |
base_loss |
Modify token loss on defense |
on_ready_troops() |
0 | Extra tokens during ready-troops step |
on_post_redeploy() |
no-op | Fires after redeployment |
get_post_redeploy_phases() |
[] |
Inject phases after REDEPLOY |
compute_score_bonus() |
0 | Extra coins during scoring |
on_decline() |
no-op | Fires when going into decline |
tokens_kept_in_decline() |
1 | Tokens kept per region in decline |
decline_counts_toward_limit() |
True |
Whether decline counts toward one-decline limit |
can_conquer_in_decline() |
False |
Allow conquests while in decline |
can_stout_decline() |
False |
Allow end-of-turn decline |
use_reinforcement_die_per_conquest() |
False |
Roll die on every conquest |
is_region_immune() |
False |
Region immunity check |
on_turn_end() |
no-op | Fires at end of turn |
on_combo_picked() |
no-op | Fires when combo is selected |
Abilities are registered via decorators (__init__.py:118-129):
@register_race(RaceName.AMAZONS)
class AmazonsAbility(AbilityHook):
...
@register_power(PowerName.FLYING)
class FlyingPower(AbilityHook):
...Lookup functions: get_race_ability(name) (line 132), get_power_ability(name) (line 137). Unregistered names return NullAbility, a no-op subclass.
The registries are populated by importing smallworld.abilities.races and smallworld.abilities.powers in smallworld/__init__.py (lines 6-7).
File: smallworld/abilities/races.py
| Race | Line | Ability |
|---|---|---|
| Amazons | 16 | on_ready_troops: +4 temporary conquest tokens (returned before redeployment) |
| Dwarves | 25 | compute_score_bonus: +1 coin per mine region (works in active and decline) |
| Elves | 47 | modify_tokens_lost_on_defeat: Lose 0 tokens when conquered |
| Ghouls | 58 | can_conquer_in_decline + tokens_kept_in_decline: All tokens persist, can conquer while in decline |
| Giants | 70 | modify_conquest_cost: -1 cost when target is adjacent to own mountain-terrain region |
| Halflings | 88 | is_valid_first_target + on_region_conquered + is_region_immune: First 2 conquests ignore adjacency, place Hole-in-the-Ground markers (immune to conquest) |
| Humans | 124 | compute_score_bonus: +1 coin per farmland (active only) |
| Orcs | 142 | compute_score_bonus: +1 coin per non-empty conquest this turn (active only) |
| Ratmen | 158 | No special ability (compensated by higher base token count) |
| Skeletons | 165 | on_ready_troops: +1 bonus token per 2 non-empty conquests this turn |
| Sorcerers | 177 | Ability handled by engine via USE_SORCERER action type (converts adjacent single-token enemy) |
| Tritons | 184 | modify_conquest_cost: -1 cost for coastal regions |
| Trolls | 198 | on_region_conquered: Places Troll Lair marker (+1 defense, persists in decline) |
| Wizards | 211 | compute_score_bonus: +1 coin per magic source (active only) |
File: smallworld/abilities/powers.py
| Power | Line | Ability |
|---|---|---|
| Alchemist | 16 | compute_score_bonus: +2 coins per turn (active only) |
| Berserk | 29 | use_reinforcement_die_per_conquest: Roll die on every conquest (not just final) |
| Bivouacking | 39 | get_post_redeploy_phases: PLACE_BIVOUACS phase; up to 5 encampments (+1 defense each) |
| Commando | 55 | modify_conquest_cost: -1 cost for all regions |
| Diplomat | 66 | get_post_redeploy_phases: CHOOSE_DIPLOMAT_ALLY; chosen opponent has mutual immunity |
| Dragon Master | 75 | Engine-handled: USE_DRAGON action conquers with 1 token, places Dragon marker (immune) |
| Flying | 82 | ignore_adjacency: Can conquer any region regardless of adjacency |
| Forest | 91 | compute_score_bonus: +1 coin per forest region (active only) |
| Fortified | 110 | get_post_redeploy_phases: PLACE_FORTRESS; 1 per turn, 6 max total; +1 defense, +1 coin, persists in decline |
| Heroic | 132 | get_post_redeploy_phases: PLACE_HEROES; 2 hero markers; regions become immune to conquest |
| Hill | 147 | compute_score_bonus: +1 coin per hill region (active only) |
| Merchant | 165 | compute_score_bonus: +1 coin per region occupied (active only) -- effectively doubles base scoring |
| Mounted | 178 | modify_conquest_cost: -1 cost for hills and farmland |
| Pillaging | 192 | compute_score_bonus: +1 coin per non-empty conquest (active only) |
| Seafaring | 208 | can_conquer_sea + cost discount: Can conquer sea/lake regions |
| Spirit | 226 | decline_counts_toward_limit: Decline stored in decline_spirit, allowing 2 decline races simultaneously |
| Stout | 235 | can_stout_decline: May decline at end of turn (after SCORE) instead of next turn |
| Swamp | 244 | compute_score_bonus: +1 coin per swamp region (active only) |
| Underworld | 263 | get_extra_adjacency + modify_conquest_cost: All caverns are adjacent to each other; -1 cost for caverns |
| Wealthy | 287 | on_combo_picked + compute_score_bonus: +7 coins on first turn only |
Player's Turn Start
│
├─ No active race ──► PICK_COMBO ──► [GHOUL_CONQUEST] ──► CONQUEST
│
└─ Has active race ──► CHOOSE_EXPAND_OR_DECLINE
│
├─ EXPAND ──► CONQUEST
│
└─ DECLINE ──► SCORE
│
CONQUEST │
│ │
├─ CONQUER (stay in CONQUEST) │
├─ USE_DRAGON (stay in CONQUEST) │
├─ USE_SORCERER (stay in CONQUEST) │
├─ SKIP_CONQUEST ──► FINAL_CONQUEST │
│ │ │
│ └──────────────────┐ │
└─ FINAL_CONQUEST ──────────────────────►│ │
▼ │
REDEPLOY
│
▼
Post-Redeploy Phases
(injected by abilities)
├─ PLACE_BIVOUACS
├─ PLACE_FORTRESS
├─ PLACE_HEROES
└─ CHOOSE_DIPLOMAT_ALLY
│
▼
SCORE ◄─────────────┘
│
├─ Stout power ──► STOUT_DECLINE
│
▼
OPPONENT_REDEPLOY
(if pending displacements)
│
▼
TURN_END
│
├─ Last player of round ──► increment turn
│
├─ turn > max_turns ──► GAME_OVER
│
└─ Next player's turn start
| Phase | Requires Player Input | Description |
|---|---|---|
PICK_COMBO |
Yes | Select a race+power combo |
CHOOSE_EXPAND_OR_DECLINE |
Yes | Expand with current race or go into decline |
CONQUEST |
Yes | Conquer regions (multiple actions) |
GHOUL_CONQUEST |
Yes | Special conquest phase for decline Ghouls |
FINAL_CONQUEST |
Yes | Last conquest attempt with reinforcement die |
REDEPLOY |
Yes | Distribute tokens among owned regions |
PLACE_BIVOUACS |
Yes | Place up to 5 encampment markers |
PLACE_FORTRESS |
Yes | Place 1 fortress marker |
PLACE_HEROES |
Yes | Place 2 hero markers |
CHOOSE_DIPLOMAT_ALLY |
Yes | Choose one opponent for mutual immunity |
STOUT_DECLINE |
Yes | Optional end-of-turn decline (Stout power) |
OPPONENT_REDEPLOY |
Yes | Displaced player repositions tokens |
SCORE |
No (automatic) | Award victory coins |
TURN_END |
No (automatic) | Fire turn-end hooks, advance player |
GAME_OVER |
No (terminal) | Game has ended |
Defined in smallworld/conquest.py.
Base cost (compute_base_conquest_cost, line 48):
cost = 2
+ number of defender tokens
+ number of defensive markers (Mountain, Troll Lair, Fortress, Encampment)
+ 1 if lost tribe present
Effective cost (compute_conquest_cost, line 74):
effective = max(1, base - race_discount - power_discount)
- Region is not already owned by attacker's active race
- Region is not immune (Hero markers, Dragon markers, Hole-in-the-Ground)
- Water regions require sea-conquest ability (Seafaring power)
- Diplomat immunity is respected (mutual: neither player can attack the other)
- Player has enough tokens (or at least 1 for final-conquest die roll)
- Snapshot defender state
- Displace defender tokens (lose 1, redeploy the rest; Elves lose 0)
- Remove lost tribe
- Remove non-persistent markers (keep Mountain terrain marker only)
- Install attacker tokens
- Fire race and power
on_region_conqueredhooks - Update bookkeeping (conquered regions, non-empty count, opponents attacked)
Defined in smallworld/scoring.py.
Per turn, a player earns:
| Source | Coins | Condition |
|---|---|---|
| Active regions | +1 each | Always |
| Decline regions | +1 each | Always |
| Active race bonus | Varies | Race-specific (e.g., Humans: +1 per farmland) |
| Active power bonus | Varies | Power-specific (e.g., Alchemist: +2 flat) |
| Decline race bonus | Varies | Only if persists_in_decline is true |
| Decline power bonus | Varies | Only if persists_in_decline is true |
Scoring happens automatically at the SCORE phase. The engine calls apply_scoring() which adds the computed coins to the player's total.
Defined in smallworld/map_generator.py.
| Players | Land Regions | Sea | Lakes | Total |
|---|---|---|---|---|
| 3 | 26 | 3-4 | 1 | ~30 |
| 4 | 34 | 4-5 | 1 | ~39 |
| 5 | 40 | 5-6 | 1 | ~46 |
| Terrain | Weight |
|---|---|
| Farmland | 22% |
| Hill | 22% |
| Forest | 22% |
| Swamp | 17% |
| Mountain | 17% |
Each of mine, magic source, and cavern targets 15% of land regions, constrained to 2-5 instances.
40% of land regions start with a lost tribe (+1 defense, removed on conquest).
A generated map must satisfy:
- All land regions form a single connected component (BFS check)
- At least 4 border regions exist
- At least 2 each of mines, magic sources, and caverns
Failed maps are discarded and regenerated (up to 100 attempts).
All randomness flows through SeededRNG, making every game perfectly reproducible. Given the same GameConfig, the same sequence of actions always produces identical outcomes. This enables:
- Deterministic replay via
SmallWorldGame.replay() - Reproducible test cases
- AI training with controlled random seeds
GameState holds all mutable state in one place. No component maintains its own shadow state. This simplifies reasoning about game logic and makes serialization straightforward.
MapGraph is immutable after construction. Region connections never change. Only RegionState (owner, tokens, markers) is mutable. This separates static map structure from dynamic game state.
The AbilityHook base class defines 20+ hook methods with no-op defaults. Concrete race/power abilities override only the hooks they need. This avoids conditional chains (if race == AMAZONS ... elif race == DWARVES ...) and makes adding new abilities straightforward.
Race and power abilities register themselves via @register_race and @register_power decorators. The registry is populated by importing the modules in __init__.py. Unregistered abilities fall back to NullAbility.
The phase_machine.py module is a pure-function FSM. It determines the next phase based on the current phase, the action taken, and the game state. It never mutates state directly. Phase transitions are deterministic and testable in isolation.
actions.py contains both validators and enumerators, but they are separate functions. Validators raise exceptions on illegal actions. Enumerators produce all legal actions for a given state. The engine calls validators during submit_action and enumerators for get_legal_actions.
The engine's _auto_advance() loop processes phases that require no player input (SCORE, TURN_END) immediately after each action. This means the caller never needs to manually trigger scoring or turn advancement.
The observation.py module builds per-player observations that hide opponent coins (replaced with "HIDDEN") and never expose RNG state. This makes the observation API suitable for training AI agents with imperfect information.
GameConfig supports a map_seed separate from the game seed. This allows varying game randomness while holding the map constant, or vice versa. If map_seed is not provided, it defaults to seed.
The engine uses only Python standard library modules (random, json, pathlib, dataclasses, enum, collections). This minimizes installation friction and ensures portability.
Contains all 14 race configurations. Each entry has:
name: Race name (matchesRaceNameenum values)base_tokens: Starting token count for the racepersists_in_decline: Whether the race ability continues to function in declinerules: Markdown text describing the race's rulestags: Categorization tags
Contains all 20 power configurations. Each entry has:
name: Power name (matchesPowerNameenum values)bonus_tokens: Token bonus from the power badgepersists_in_decline: Whether the power continues to function in declinerules: Markdown text describing the power's rulestags: Categorization tags
Both files are loaded by data_loader.py and cached after first access.
The test suite is in tests/ and uses pytest.
| Fixture | Description |
|---|---|
game_config |
GameConfig(num_players=3, seed=42) |
game |
Fresh SmallWorldGame from game_config |
game_state |
Internal GameState extracted from game._state |
Plays a complete game using a greedy agent that prefers CONQUER > EXPAND > first legal action. Used by integration and determinism tests. Defined in tests/conftest.py:39.
| File | Focus |
|---|---|
test_engine.py |
Game initialization, combo picking, action dispatch |
test_actions.py |
Per-action-type validation and legal action enumeration |
test_conquest.py |
Conquest cost calculation, execution, displacement |
test_combo.py |
Combo row selection, coin placement, refill |
test_scoring.py |
Scoring calculation and application |
test_map_generator.py |
Map generation, connectivity, feature placement |
test_determinism.py |
Same seed produces identical game outcomes |
test_full_simulation.py |
Full games run to completion without errors |
# Run all tests
uv run pytest
# Run a specific test file
uv run pytest tests/test_engine.py
# Run with verbose output
uv run pytest -vDetailed game rules are in the rules/ directory:
| File | Content |
|---|---|
00_rules_overview.md |
High-level game overview |
01_setup.md |
Game setup procedure |
02_turn_structure.md |
Turn phases and flow |
03_conquest.md |
Conquest mechanics |
04_decline.md |
Decline mechanics |
05_scoring_endgame.md |
Scoring and end-game |
06_glossary.md |
Game terminology |
| Players | Turns |
|---|---|
| 3 | 10 |
| 4 | 9 |
| 5 | 8 |
Defined in GameConfig.TURN_COUNTS.
- Each player starts with 5 victory coins
- 6 combos visible in the combo row
- Combo selection costs index N coins (index 0 is free)
Faces: [0, 0, 0, 1, 2, 3] -- 50% chance of rolling 0, then uniform across 1/2/3.
Defined in rng.py:6.
MOUNTAIN, TROLL_LAIR, FORTRESS, ENCAMPMENT -- each adds +1 to conquest cost.
Defined in enums.py:36.
| Parameter | Value | Location |
|---|---|---|
| Lost tribe fraction | 40% | map_generator.py:34 |
| Max generation attempts | 100 | map_generator.py:35 |
| Min border regions | 4 | map_generator.py:36 |
| Feature targets | 15% of land, 2-5 each | map_generator.py:27 |