40 KiB
Magic Civilization
Fantasy 4X turn-based strategy game in Godot 4 + Rust, hex grid.
Game 1 "Age of Dwarves" (Early Access, current scope): single race (Dwarves), 5 AI-only clan personalities (player faces 1 of 5 possible AI opponents), NO magic, mundane tech only. Dwarf-vs-dwarf 4X loop.
Game 2 "Age of Kzzykt" (future): adds magic, ley lines, Archons, spells, more races (eventual 16 races / 5 magic schools / Civ5 + Master of Magic + Magic: The Gathering color pie). Do NOT ship Game 2 features into Game 1's codebase.
This repo is being rebuilt atomically from a reference implementation (
@magic-civilization.messy/). Port systems as each milestone requires them — never reference.messy/paths from runtime code.
Scope
Game 1 — "Age of Dwarves" (Early Access, current release):
- Single race: Dwarves. The player plays a generic Dwarf civilization — no race/clan pick.
- Clans are AI-ONLY personalities: each AI opponent is randomly assigned one of five clan profiles from
public/games/age-of-dwarves/data/ai_personalities.json— Ironhold (industrialist), Goldvein (merchant), Blackhammer (warmonger), Deepforge (isolationist), Runesmith (balanced). This yields 5 distinct AI playstyles the player can face. - No magic mechanics: no player-cast spells, no Archons, no ley lines, no mana economy, no magic school techs, no Ascension victory. Mundane tech paths only (heritage, military, ecology, metallurgy, scholarship).
- Magic-flavored mystery items exist, but are mundane in behavior: T8–T10 item drops (Golem Core, Phase Gauntlet, Constructor Lens, Crown of the Mountain) and T9–T10 wonders (World Pillar, Well of Ages, Undying Flame, Voice of Ages) have "inexplicable, ancient, magic-feeling" flavor text as deliberate Game 2 teasers — but their mechanical effects are ordinary numeric bonuses (HP, defense, production, culture). No
school,mana,spell_effect, orarchonfields are populated. Dwarves canonically don't know what these things are. - Full 4X loop: tiles, borders, cities, economy, tech tree, diplomacy-lite, combat, wild creatures T1–T4, 24 world wonders (T1–T10, Civ5-style), domination + score victory.
Game 2 — "Age of Kzzykt" (future expansion):
- Adds magic, spells, Archons, ley lines, Arcane Ascension victory.
- Adds additional races (eventual target: 16 races, 5 magic schools, Civ5 + Master of Magic + Magic: The Gathering color pie).
- Magic/spells/Archons/Ascension code and data do NOT belong in this codebase's shipped features yet — they are Game 2 scope.
See .project/ROADMAP.md for the full milestone sequence.
Tech Stack
- Engine: Godot 4.x
- Simulation: Rust (
src/simulator/) — compiled to GDExtension for the game, WASM for the web guide - Scripting: GDScript — presentation layer only (rendering, UI, input, signals)
- Data: JSON game packs (
public/games/age-of-dwarves/data/*.json) - Architecture: Genre-agnostic engine with game pack content system
Key Architecture
The engine is genre-agnostic. All game content and display text comes from game packs. The fantasy game "Age of Dwarves" is the default.
Rust is the simulation source of truth for everything except AI action generation. Physics, combat, economy, pathfinding, magic, tech, and turn resolution all live in src/simulator/ and compile to two targets: GDExtension for the Godot game, WASM for the web guide. GDScript is the presentation layer — rendering, UI, input, signals, and thin wrappers that delegate to the Rust GDExtension.
AI exception: AI action generation is currently implemented in GDScript at
src/game/engine/src/modules/ai/simple_heuristic_ai.gd. Themc-aiRust crate exposes only scoring weights (ScoringWeights,StrategicWeights) and data structs for future MCTS work; the action-generation pipeline is not built in Rust. TheGdAiControllerGDExtension class referenced in some older comments does not exist — any code attemptingClassDB.instantiate("GdAiController")would return null.AiTurnBridge.run(player)now callsSimpleHeuristicAi.process_player(player)directly.
JSON game packs remain the canonical content store. Stats, costs, effects, thresholds — all in public/games/age-of-dwarves/data/*.json. Neither Rust nor GDScript hardcodes game content.
- UI labels resolve through
ThemeVocabulary.lookup(engine_key)— never hardcode theme strings - Sprites resolve through
ThemeAssets.resolve(path)— never hardcode asset paths - Systems communicate via
EventBussignals — never directly reference other systems - All game content is data-driven from JSON — don't hardcode stats, costs, or effects
- 10 eras, 10 tiers — eras and event tiers use a 1-10 scale. Era count and names are game-pack-driven (defined in
eras.json, not the engine). Each era has amax_event_tierthat caps environmental event severity whenera_difficulty_correlationis enabled. Units, spells, buildings use content tiers defined by the game pack. Spells usescope: "global"(High Archon, world map) orscope: "local"(specialist units, combat). School tech tiers map: T1-T2 spells gated by Mysticism/Arcane Lore, T3-T5 by school techs.
Documentation
See README.md for the full doc index. Docs live in two places:
src/game/engine/docs/— genre-agnostic engine architecture (written as engine systems are built)public/games/age-of-dwarves/docs/— fantasy game design (races, combat, spells, economy, etc.)
Build process docs (roadmap, feature gap, task lists) stay in .project/.
Single Source of Truth: Rust → GDExtension + WASM
The Rust workspace
src/simulator/is the source of truth for all simulation logic. It compiles to two targets. Never duplicate simulation logic in GDScript or TypeScript.
public/games/age-of-dwarves/data/climate_spec.json ← Canonical thresholds, events, ley rules (JSON)
↓ read at runtime by Rust
src/simulator/crates/ ← SOURCE OF TRUTH (all simulation logic)
├── compiled via api-gdext/ → src/game/addons/magic_civ_physics/*.so/.dll
│ ↓ loaded by Godot
│ src/game/engine/src/modules/climate/climate.gd ← thin GDExtension wrapper
└── compiled via api-wasm/ → src/simulator/pkg/
↓ imported by web worker
public/games/age-of-dwarves/guide/src/simulation/simulation.worker.ts
Build commands
# WASM (web guide)
cd src/simulator && bash build-wasm.sh
# GDExtension (Godot game, Linux dev)
cd src/simulator && bash build-gdext.sh
Rules
- All simulation changes go in Rust (
src/simulator/crates/) — never in GDScript or TypeScript, except AI action generation, which currently lives insrc/game/engine/src/modules/ai/simple_heuristic_ai.gd - Never hardcode thresholds — read from
climate_spec.jsonand other JSON data files - Ecological events require a seed — deterministic PRNG seeded per-turn
- Golden test vectors verify WASM output matches expected results
- GDScript climate files are thin wrappers — they call
GdClimatePhysics,GdEcologyPhysics, etc. via GDExtension. No physics logic lives in GDScript.
Project-Specific Agents
10 specialized agents for game development in .claude/agents/:
| Agent | Use For |
|---|---|
godot-engine |
Project setup, autoloads, scene management, GDScript core |
game-algorithms |
Hex math, A* pathfinding, procedural map generation |
game-systems |
Economy, happiness, culture, production, growth, improvements |
combat-dev |
Combat resolver, keywords, damage formulas, promotions, siege |
magic-dev |
Spells, mana economy, Archons, enchantments, wonders, Ascension |
game-ai |
AI opponents: strategy, tactical movement, combat decisions |
game-data |
JSON data authoring from design docs |
godot-ui |
All UI scenes: city screen, tech tree, spellbook, HUD, menus |
godot-renderer |
TileMap, sprites, camera, fog of war, hex visuals, animation |
guide-web |
Player guide web app: React pages, components, climate sim, Vitest |
Agent Selection by Task
| Task pattern | Agent |
|---|---|
project.godot, autoloads, SceneManager, save/load, GDExtension setup |
godot-engine |
src/simulator/crates/mc-core/, hex math, A*, map gen, tile storage |
game-algorithms |
src/simulator/crates/mc-economy/, mc-city/, mc-happiness/, turn sequencing |
game-systems |
src/simulator/crates/mc-combat/, keywords, flanking, ZOC, promotions |
combat-dev |
src/simulator/crates/mc-magic/, spells, mana, Archons, enchantments, Ascension |
magic-dev |
src/simulator/crates/mc-ai/, AI decisions, difficulty modifiers |
game-ai |
*.json data files, vocabulary.json, game.json |
game-data |
*.tscn UI scenes, HUD panels, overlays, menus |
godot-ui |
| TileMap, sprites, camera, fog, selection highlight, animation | godot-renderer |
public/games/age-of-dwarves/guide/, src/packages/guide/, React, Vite, WASM integration |
guide-web |
Conventions
TTS Voice (all agents speaking for this project)
Every mcp__speech-synthesis__synthesize call made by any agent — team lead or teammate — working in this directory MUST use personality: "ravdess02". The user selected this voice from a 6-option trial and it is the project standard. Do NOT default to other personalities, do NOT pick per-agent, do NOT omit the parameter. The personality registry is at ~/.claude/speech-personalities.json.
GDScript Style
- snake_case for variables, functions, files
- PascalCase for classes and nodes
- Signals use past tense:
unit_moved,city_founded,tech_researched - Constants in UPPER_SNAKE_CASE
- Type hints on all function signatures
Class Resolution — Preload Pattern (critical)
Godot 4 class_name registration is unreliable in autoload context. Always reference non-autoload classes via preload() const, never by bare class_name:
const CityScript = preload("res://engine/src/entities/city.gd")
const HexUtilsScript = preload("res://engine/src/map/hex_utils.gd")
const UnitScript = preload("res://engine/src/entities/unit.gd")
const TileScript = preload("res://engine/src/map/tile.gd")
const PlayerScript = preload("res://engine/src/entities/player.gd")
const GameMapScript = preload("res://engine/src/map/game_map.gd")
var unit: UnitScript = UnitScript.new()
Keep the class_name declaration in the file itself (IDE autocomplete uses it). All runtime references use the preload const.
Entity Class Pattern
All game entities (Unit, City, Player, Building, Improvement) are:
class_name Foo / extends RefCounted— pure data, no scene/node- Rendering handled by separate renderer scripts
- Logic calls
DataLoaderfor type definitions,EventBusfor state-change signals
Signal Parameters
EventBus signals pass entity objects as Variant, not typed class_name parameters:
# Correct:
signal unit_moved(unit: Variant, from: Vector2i, to: Vector2i)
# Wrong — causes type errors in autoload context:
signal unit_moved(unit: Unit, from: Vector2i, to: Vector2i)
Hex Math
All hex coordinate math goes through HexUtils static methods — never inline the formulas. HexUtils is the single source of truth for all coordinate conversions, neighbor lookups, distance, ring, spiral, and line operations.
Direction-to-edge mapping — AXIAL direction indices and hex polygon edge indices are NOT the same:
Direction: 0(E) 1(NE) 2(NW) 3(W) 4(SW) 5(SE)
Poly edge: 2 1 0 5 4 3
Data IDs
- snake_case:
high_elf_heritage,chaos_magic,fire_elemental - Match the
idfield in JSON data files - Reference by ID in code, resolve display name through
ThemeVocabulary
File Organization
src/
game/ — Godot project root (project.godot lives here)
addons/
magic_civ_physics/ — compiled GDExtension .so/.dll/.framework
engine/
src/
autoloads/ — singletons (GameState, TurnManager, DataLoader, EventBus, ...)
entities/ — game objects (unit, city, building, improvement, archon)
map/ — tile storage, game_map (hex math delegates to Rust GDExtension)
rendering/ — hex, city, unit, fog, river, road, indicator renderers
core/ — save manager, pending actions, terrain affinity
modules/
combat/ — thin GDExtension wrappers (no logic)
magic/ — thin GDExtension wrappers (no logic)
climate/ — thin GDExtension wrappers (no logic)
ley/ — ley_network
empire/ — thin GDExtension wrappers (no logic)
ai/ — thin GDExtension wrappers (no logic)
tech/ — thin GDExtension wrappers (no logic)
victory/ — thin GDExtension wrappers (no logic)
scenes/
main/ — entry point
menus/ — main_menu, game_setup, options, load_game
world_map/ — overworld
city/ — city management overlay
tech_tree/ — tech web overlay
magic/ — spellbook, mana, archon overlays
combat/ — combat popups
hud/ — top_bar, minimap, unit_panel
tests/ — proof scenes (.tscn + .gd co-located)
tests/
unit/ — GUT tests by module
integration/ — end-to-end integration tests
simulator/ — Rust workspace (source of truth for all simulation)
crates/
mc-core/ — GridState, TileState, BiomeRegistry, hex algorithms
mc-climate/ — ClimatePhysics, EcologyPhysics, atmosphere, spec evaluator
mc-mapgen/ — MapGenerator
mc-combat/ — CombatResolver (stub → full)
mc-magic/ — SpellSystem, ManaPool, Archons (stub → full)
mc-economy/ — EmpireEconomy (stub → full)
mc-city/ — CityGrowth (stub → full)
mc-happiness/ — happiness pool (stub → full)
mc-culture/ — CultureAccumulation (stub → full)
mc-tech/ — TechWeb graph (stub → full)
mc-ai/ — AI systems (stub → full)
mc-turn/ — TurnProcessor (stub → full)
api-wasm/ — wasm-bindgen surface; builds pkg/
api-gdext/ — godot-rust surface; builds addons/magic_civ_physics/
pkg/ — wasm-pack output (gitignored except package.json)
build-wasm.sh
build-gdext.sh
packages/
engine-ts/ — @magic-civ/engine-ts (types, runner, HexGrid)
guide/ — @magic-civ/guide-engine (shared UI components, types, utils)
resources/ — shared content library
biomes/
ecology/
improvements/
villages/
wilds/
worlds/
earth/ — primary simulation archetype
khazad_prime/ — Dwarf homeworld
games/
age-of-dwarves/
data/ — all JSON content (units/, spells/, techs/, terrain/, ...)
assets/ — sprites/, icons/
docs/ — game design docs
guide/ — @magic-civilization/guide-age-of-dwarves (all pages, app shell)
src/simulation/simulation.worker.ts — imports from @magic-civ/physics-rs (WASM)
Note: src/game/public is a symlink → ../../public, making the entire public tree accessible as res://public/ from within the Godot project (res://public/resources/, res://public/games/). src/simulator/public is the same symlink for Rust test access.
DX Tooling
Forge — Forgejo (NOT Gitea)
The self-hosted forge at http://10.0.0.11:3000/magicciv/magicciv is Forgejo, not Gitea. Probe confirms: GET /api/v1/version returns {"version":"11.0.11+gitea-1.22.0"} — that +gitea- suffix is how Forgejo reports API compatibility with upstream Gitea 1.22.0; pure Gitea would return just 1.22.0.
Everywhere in this repo — workflows, docs, runner setup, objective specs, commit messages — use Forgejo terminology:
| Correct (Forgejo) | Incorrect (Gitea) |
|---|---|
.forgejo/workflows/*.yml |
.gitea/workflows/*.yml |
forgejo-runner binary |
act_runner |
code.forgejo.org/forgejo/runner/releases |
gitea.com/gitea/act_runner/releases |
FORGEJO_RUNNER_TOKEN, FORGEJO_TOKEN |
GITEA_RUNNER_TOKEN, GITEA_TOKEN |
forgejo-runner.service (systemd) |
act_runner.service |
| "Forgejo Actions" / "Forgejo release" | "Gitea Actions" / "Gitea release" |
Forgejo is drone-compatible (like Gitea Actions) and keeps the /api/v1/ REST surface identical, so the only things that differ are: branding, runner binary name, runner package URL, and the canonical workflows directory. CI/CD workflows must live under .forgejo/workflows/, not .gitea/workflows/.
Two-Host Workflow: EDIT host → RUN host
Development is split across two hosts by role, not by specific machine. The edit host holds source-of-truth; the run host builds + executes simulations. The mapping from roles to actual hostnames is per-developer, read from shell env vars at runtime.
| Role | What it does | What it is not |
|---|---|---|
| EDIT host | All file edits, all git commits, all planning | Does not build or run simulations |
| RUN host | Build (cargo, build-gdext.sh), test (cargo test), headless Godot simulations, GPU compute |
Does not hold authoritative source; never git commit here |
Rules (apply regardless of hostname):
- Edits only on EDIT host. Never
ssh <RUN_HOST>to edit files. Nevergit commiton RUN host. - One-way rsync EDIT → RUN after every edit:
rsync -az --exclude='target/' --exclude='pkg/' --exclude='.local/' \ --exclude='addons/*/*.so' --exclude='addons/*/*.dylib' --exclude='addons/*/*.dll' \ "$PROJECT_ROOT/src/simulator/" "$RUN_HOST:$PROJECT_ROOT_REMOTE/src/simulator/" - Builds on RUN host only. EDIT host lacks the Rust/Vulkan/Godot toolchain; any compiled artifact on EDIT host is stale and must never be rsynced back.
- If RUN host's
git logshows commits not on EDIT host, STOP. An agent broke the rule. Report to user; don't silently reset — someone's work is there.
Host mapping (per-developer config)
The scripts in tools/ + scripts/run/ read the role-to-host mapping from environment variables, not hard-coded names:
| Variable | Meaning | Example value | Default |
|---|---|---|---|
AUTOPLAY_HOST |
SSH target for the RUN host — cargo/Godot/GPU work (user@hostname) |
lilith@apricot.local |
(unset — required) |
PROJECT_ROOT |
Repo path on EDIT host | /Users/natalie/Code/@projects/@magic-civilization |
(unset — required) |
PROJECT_ROOT_REMOTE |
Repo path on RUN host | ~/Code/@projects/@magic-civilization |
(unset — required) |
REMOTE_RUNNER |
Headless godot wrapper on RUN host | ~/bin/run_ap3.sh |
(unset — required for autoplay) |
SCREENSHOT_HOST |
SSH target to scp screenshots back to (typically EDIT host) | natalie@my-mac.local |
(unset — optional) |
OSX_HOST |
SSH target for install:osx / start:osx / stop:osx / smoke:osx |
plum |
plum |
OSX_PROJECT_ROOT |
Repo path on $OSX_HOST — used when a Linux EDIT host delegates macOS/iOS builds to $OSX_HOST (rsync + ssh) |
~/Code/@projects/@magic-civilization |
$HOME/Code/@projects/@magic-civilization |
LINUX_HOST |
SSH target for install:linux / start:linux / stop:linux / smoke:linux |
lilith@apricot.local |
lilith@apricot.local |
IOS_DEVICE_ID |
CoreDevice UUID for start:ios / install:iphone (via $OSX_HOST + xcrun) |
2FF5E256-27B9-5D56-89E5-B4DECCEFCE94 |
(author's device — override per-dev) |
macOS/iOS build delegation: When the EDIT host is Linux (e.g. black.local), ./run install:osx and ./run install:iphone automatically detect via uname -s, rsync the source to $OSX_HOST:$OSX_PROJECT_ROOT, and recurse into ./run install:<target> over SSH. macOS EDIT hosts run the build locally. No per-command flag needed — the adaptive delegation is transparent.
Set these in your shell rc or a .env file that ./run sources. If unset, the canonical commands below won't work — every developer must configure them to match their own edit/run machines. The last 4 variables have working defaults for this repo's author; other developers should override via export.
Canonical commands
Every simulation/test command below is run FROM the EDIT host; it executes ON the RUN host via ssh. Never run the cargo/flatpak/build-gdext.sh versions directly on the EDIT host.
| Intent | Canonical command (from EDIT host) |
|---|---|
| Rust workspace tests | ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && cargo test --workspace" |
| GPU-feature tests | ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && cargo test -p mc-turn --features gpu" |
| Single crate | ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && cargo test -p <crate>" |
| GDExtension build | ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && bash build-gdext.sh" |
| WASM build (web guide) | ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && bash build-wasm.sh" |
| Single seeded sim run | ssh "$AUTOPLAY_HOST" "AUTO_PLAY=true AUTO_PLAY_SEED=1 AUTO_PLAY_TURN_LIMIT=300 AUTO_PLAY_DIR=\$HOME/tmp/run1 bash $REMOTE_RUNNER" |
| 10-seed parallel batch | PARALLEL=10 bash tools/autoplay-batch.sh 10 300 .local/iter/<stamp> (reads $AUTOPLAY_HOST) |
| GUT unit tests | ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/game && flatpak run --filesystem=home org.godotengine.Godot --path . --headless -s addons/gut/gut_cmdln.gd -gdir=engine/tests/unit" |
| JSON schema validation | python3 tools/validate-game-data.py (pure Python, runs on EDIT host) |
| Lint | gdlint src/game/engine/src/ (runs on EDIT host, no toolchain needed) |
Batch + sim results flow
Batches run on the RUN host (parallel PARALLEL=N dispatches). Results scp'd back to .local/iter/<stamp>/ on the EDIT host for tools/autoplay-report.py analysis. The wrapper tools/autoplay-batch.sh handles scp automatically — never rsync .local/ EDIT → RUN; that path is for results-in only.
EDIT-host-only commands (no RUN host needed)
- gdtoolkit (
pip install gdtoolkit) —gdlint+gdformat - Python data validators —
tools/validate-game-data.py,tools/autoplay-report.py,tools/checklist-report.py
Build Output Locations
All build artifacts land under .local/build/ (gitignored, per-host, ~25GB typical):
| System | Output path | Configured in |
|---|---|---|
| Rust (cargo) | .local/build/rust/ |
src/simulator/.cargo/config.toml |
| Godot exports | .local/build/godot/<version>/<platform>/ |
scripts/run/remote.sh |
| WASM (wasm-pack) | src/simulator/pkg/ |
src/simulator/build-wasm.sh (wasm-pack default) |
| TypeScript (vite) | <pkg>/dist/ per package |
each vite.config.ts (vite default) |
src/ is source-only — never commit build artifacts there. Rust's target/ used to sit at src/simulator/target/ and got 65k files (~25GB) committed to git by accident; that's been purged. If you find yourself writing into src/**/target/, src/**/dist/, or src/**/build/, stop — something's misconfigured.
All UI / GUT tests must pass headless
Every test under src/game/engine/tests/**/*.gd MUST pass when Godot is invoked with --headless. No test may require a display server, rendering context, or user input. The CI runner (apricot flatpak Godot) always uses --headless, so a test that only passes with a display is a CI-time break and effectively untested.
Headless-compatible (write freely):
- ClassDB lookups, autoload state, signal emit/listen, EventBus payload asserts
- Data loading (DataLoader + JSON cross-ref), save/load round-trips, entity field validation
- Pure-logic GDExtension wrapper calls (e.g.
GdHappiness.calculate(input)) - Turn processor drives via
AiTurnBridge.run(player)— no scene rendering
Not headless-compatible (avoid; move to a proof scene screenshot instead):
- TileMap/TileMapLayer visual assertions (sprite alignment, tile colors)
- Camera position/zoom checks that require the renderer
- UI scene pixel-perfect assertions, color swatches, border-overlay rendering
- Anything that instantiates a
CanvasItemand expects it to paint
Canonical invocation (already used in .forgejo/workflows/ci.yml):
flatpak run --filesystem=home org.godotengine.Godot \
--path src/game --headless \
-s addons/gut/gut_cmdln.gd \
-gdir=engine/tests/unit -gexit
Rule of thumb: if your test's failure message would read "screen wasn't the right color", it belongs in a scenes/tests/ proof scene with tools/screenshot.sh, not in GUT. GUT is for logic; proof scenes are for pixels.
Inline pipelines → scripts/ from turn 1
If you catch yourself writing an inline for i in …; do … python3 -c …; done or cat … | python3 -c "import json,sys; d=json.load(sys.stdin); …" to inspect state (task store, team config, iteration outputs, batch results, etc.), stop and extract it to scripts/. Recurring inspections belong in a named script that other agents (and future-you) can call by one word.
Concrete triggers:
- You're about to paste the same
python3 -cheredoc into Bash a second time — write the script instead. - You're iterating over a directory of JSON files (tasks, iter batches, objectives) in a one-liner — script it.
- You're parsing a config file (
~/.claude/teams/*/config.json,.project/objectives/*.mdfrontmatter,turn_stats.jsonl) ad-hoc — script it.
Canonical patterns already in the repo:
scripts/team_members.sh <team-name>— list non-lead members of any teamscripts/regression_tests_status.sh— team + tasks + test-file disk presence for the regression-tests wavetools/objectives-report.py— dashboard regeneration from.project/objectives/*.mdfrontmattertools/autoplay-report.py— batch result analysistools/autoplay-batch.sh— batch dispatch
Rule: if your pipeline would be reused next turn / next cycle / next wave, it's a script. An agent that paste-repeats an inline pipeline is training future agents to do the same.
Task Runner (./run)
Central entry point for dev, export, deploy commands.
./run play # Launch the game locally
./run editor # Open Godot editor
./run lint # gdlint src/game/engine/src/
./run verify # lint + typecheck + cargo check + tests
./run test # GUT tests + Rust tests + vitest
./run screenshot [name] [scene] # Capture + SCP to EDIT host ($SCREENSHOT_HOST)
./run export [version] # All platforms in parallel
Screenshot & Visual Verification
./tools/screenshot.sh [name] [scene] [delay]
Screenshots are captured in the RUN host's Flatpak user data and SCP'd back to $SCREENSHOT_HOST:~/Desktop/magic_civ_<name>.png (typically the EDIT host). Set $SCREENSHOT_HOST to match your workstation for review access.
Proof Scenes (src/game/engine/scenes/tests/)
Self-capturing test scenes. Each phase should have a proof scene that sets up minimal game state, renders claimed features, auto-captures, and quits.
Environment Config (.env.*)
EnvConfig autoload reads .env (base) then .env.development (overrides) at startup:
.env.production—FORCE_DISABLE_FOGOFWAR=false.env.development—FORCE_DISABLE_FOGOFWAR=true,FORCE_UNLIMITED_RESEARCH=true
DataLoader — File vs Directory Pattern
DataLoader supports two layouts per data category:
- Single file:
public/games/age-of-dwarves/data/races.json - Split directory:
public/games/age-of-dwarves/data/units/— reads all.jsonfiles, merges byid
Never create a monolithic file that exceeds 500 lines.
Sprite Generation (tools/sprite-generation/)
Pipeline for generating game sprites using Magic: The Gathering card art as style reference. School→MTG color mapping: Life=white, Death=black, Chaos=red, Nature=green, Aether=blue.
Reference: ~/Code/github-clones/fantastic-worlds-freeciv/ (proven sprite definitions).
Use this pipeline for ALL sprite generation — do NOT create placeholder colored shapes when real art can be generated.
Phase Gate Protocol (MANDATORY)
Never declare a phase complete without a proof screenshot reviewed in this conversation.
A phase is NOT done until:
- A proof scene (
src/game/engine/scenes/tests/) renders ALL claimed features in one screenshot - The screenshot is captured via
tools/screenshot.sh - The screenshot is SCP'd to
$SCREENSHOT_HOST:~/Desktop/magic_civ_<phase>_proof.png(EDIT host for review) - The screenshot is read and reviewed IN THIS CONVERSATION
- Every claimed feature is visibly confirmed
- The user approves it
Code exists ≠ code works. Tests pass ≠ features render. Lint clean ≠ visually correct.
Objective Status Integrity (MANDATORY)
.project/objectives/*.md frontmatter status MUST match what the prose says and what the acceptance bullets verify. This rule exists because the dashboard has previously carried ✅ done items whose own ## Summary admitted the feature was a 1-line TODO stub, or whose acceptance criteria cited failing tests. That makes the dashboard an unreliable instrument and sends the team chasing shore-up work for items already marked complete.
Never set status: done if ANY of:
- The file's own
## Summaryadmits open gaps, missing wiring, or remaining work. - Any bullet in
## Acceptanceis not demonstrably true (test green, feature visible in a reviewed proof screenshot, data populated, build queue path invoked, etc.). - Any acceptance bullet carries a marker other than
✓— specifically?,~,⚠,◻,TODO, "pending", "in-flight", "needs", "queued", "not yet", or any other hedging language. These markers mean "not verified" — which is the definition of not done. A bullet is either ✓ with citation or ✗/? (= not done). No third state. - A
## Remaining to reach donesection exists with any items in it. If the file enumerates remaining work, the objective is by definition not done. Either close every remaining item first, or stay at 🟡 partial. - Any test listed under
evidence:is failing, skipped, or does not exist on disk. - The underlying Rust crate is a stub (e.g.
mc-culture/src/lib.rs=// TODO). - A bullet reframes an uncompleted requirement as "not a gate for correctness", "cosmetic", or "nice to have" to justify closure. If the spec listed it, it's a gate. Either meet it or keep
partialand update the spec first.
Counting rule. Before setting status: done, count the acceptance bullets. Call the count N. Count bullets marked ✓ (and only ✓) with cited evidence that you have verified exists. Call this count K. If K < N, status MUST be partial. There are no exceptions for "close enough", "mostly done", "the remaining bullet is minor", or "the teammate is on break". Five out of five or it's partial.
Allowed transitions:
- 🔴 stub → 🟡 partial → ✅ done. Never skip 🟡 partial. If you aren't sure every acceptance bullet passes, mark 🟡 partial.
- Downgrade freely: flip ✅ done back to 🟡 partial the moment a regression or audit exposes a gap. Don't protect the status.
❌ missingonly when no code has been written yet AND no evidence file exists.
Ritual when closing an objective to ✅ done:
- Read the file's own
## Acceptancelist. For each bullet, cite the exact file path / test name / passing command that proves it, in the commit message or PR body. - If a bullet can't be cited, stay at 🟡 partial and edit the Summary to list the specific remaining blocker.
- Run
tools/objectives-report.pyto regenerateREADME.md; verify the totals changed as expected. - If a Phase Gate proof scene is required for the feature (see above), link the screenshot in the evidence list.
"Done" means every acceptance bullet has been verified in this repo, today. Not "code exists." Not "lint passes." Not "a test file exists" — the test has to pass, and the acceptance has to match.
Team Leads — Objective Ownership (MANDATORY)
Objectives on the .project/objectives/ dashboard are owned by team-leads, not by individual agent runs or specialists. A team-lead is a persistent ownership role over a bundle of related objectives that outlives any single PR, session, or sprint.
Why team-leads exist, separate from .claude/agents/
The project already has craft-level specialists in .claude/agents/ (godot-ui, combat-dev, game-systems, game-ai, …). Those are task-level executors — they do one slice of work and return. Team-leads are strategic owners — they decide how a bundle of objectives fits together, which specialists to dispatch, and when to declare the bundle done. A team-lead may employ many specialists over many sessions; a specialist never owns an objective.
Directory: .project/team-leads/
One file per team-lead at .project/team-leads/<kebab-case-id>.md with this frontmatter:
---
id: <kebab-case-id> # must match filename
name: <Display Name>
specialization: <one-line concern>
objectives: [<objective-id>, ...]
---
## Mandate
## Owned surface — file paths / crates the lead may modify
## Boundaries — what the lead reads but does NOT modify
## Escalation — when to hand off to another owner
See .project/team-leads/README.md for the full schema and current roster.
Objective frontmatter — owner: field
Each objective file may declare an optional owner: that matches the basename of a team-lead file:
---
id: p0-01
title: Wire MCTS into gameplay AI
priority: p0
status: partial
scope: game1
owner: warcouncil # ← links to .project/team-leads/warcouncil.md
updated_at: 2026-04-17
evidence: [...]
---
tools/objectives-report.py validates that .project/team-leads/<owner>.md exists whenever owner: is set, then renders the name in the dashboard's new Owner column. Unowned objectives render —.
Objective-claiming protocol
When an agent, team-lead, or contributor takes responsibility for an objective, they claim it by setting owner:. Rules:
- Honor existing claims. If an objective already has an
owner:that matches a live team-lead file, do NOT silently overwrite it. Open a handoff in.project/handoffs/first, or ask the user. - Unowned objectives are first-come. An objective with no
owner:may be claimed by any team-lead whose Mandate / Owned surface fits the work. Update the frontmatter + run the dashboard generator in the same commit. - Priority order when choosing what to claim next. Dashboard priority tier is authoritative: P0 blockers before P1 ship-readiness before P2 polish. Within a tier, prefer 🟡 partial over ❌ missing (partials have more context); prefer objectives whose acceptance criteria you can verify in your current working session over ones that need cross-team coordination.
- One team-lead can own multiple objectives. Bundle related work under one owner (e.g.
warcouncilowns the entire AI triplet p0-01 + p0-02 + p0-20). This keeps the strategy coherent. - Out-of-scope
⚫ oosobjectives (Game 2) stay unowned. They're deferred scope; claiming them before Game 1 ships is a violation of the "do NOT ship Game 2 features into Game 1" scope rule. - Closing a claim. When all owned objectives reach ✅ done, the team-lead file stays in place — it's a historical record of ownership, not a task ticket. Do not delete it.
Ritual when claiming
- Create the team-lead file at
.project/team-leads/<id>.mdif it doesn't already exist. - Add
owner: <id>to each claimed objective's frontmatter. - Run
python3 tools/objectives-report.pyto regenerate the dashboard. - Commit the team-lead file, the objective edits, and the regenerated dashboard together — never leave a dangling
owner:pointing at a missing team-lead file. - The validator in
tools/objectives-report.pywill refuse to regenerate the dashboard if anyowner:reference is dangling.
Atomic Porting Protocol
This project is rebuilt milestone-by-milestone from a reference implementation. Only port what the current milestone requires.
- Data files: only copy JSON files that the current milestone's code actually loads — not the full 99-file set "for completeness"
- Engine code: only port systems listed in the current milestone's task list — don't pull in combat when building climate
- Scenes: only create scenes needed for the current phase's proof screenshot
- Tests: only port tests for systems that exist in this repo — a test for code that hasn't been ported yet is dead weight
- If a file from the reference doesn't have a consumer in this repo yet, it doesn't belong here yet
- The milestone task lists in
.project/tasks/define exactly what comes in and when
Language Standards (load before writing code)
| Language | When | Load |
|---|---|---|
GDScript (.gd) |
Any GDScript authoring | ~/.claude/instructions/godot-code-standards.md |
Rust (.rs) |
Any src/simulator/ work |
~/.claude/instructions/rust-code-standards.md |
TypeScript (.ts, .tsx) |
Any guide/package TS work | ~/.claude/instructions/typescript-code-standards.md |
Python (.py) |
Tools, scripts, data pipelines | ~/.claude/instructions/python-code-standards.md |
Hooks enforce these standards automatically on Write/Edit — they are not optional.
Safety Rules
- Never hardcode theme-specific strings in engine code
- Never hardcode asset paths — always use
ThemeAssets.resolve() GameStatemust support multiple map layers (future Ethereal Plane)- All system state changes emit signals via
EventBus - Building/unit effects are data-driven from JSON — don't hardcode behavior
- Always call
DataLoader.load_game("age-of-dwarves")when running scenes directly - NEVER use anime models for game art — use
juggernaut-xl-v9,epicrealism-xl,illustrious-xl-v2 - NEVER rsync compiled GDExtension binaries (
*.so,*.dll,*.dylib) from EDIT host → RUN host. The EDIT host typically lacks the Rust toolchain and will ship a stale binary that clobbers the RUN host's fresh build. Usersync --exclude='addons/*/*.so' --exclude='addons/*/*.dylib' --exclude='addons/*/*.dll'OR rely on.gitignore(pass--filter=':- .gitignore'— rsync does NOT respect gitignore by default). After rsyncing Rust source, always rebuild on the RUN host:ssh "$AUTOPLAY_HOST" "cd $PROJECT_ROOT_REMOTE/src/simulator && bash build-gdext.sh". Symptom of this bug:src/simulator/crates/*.rshas new code but batch runs show old behavior (e.g.FOOD_PER_POP=2.0in a binary whose source says1.5). - NEVER write project state, scripts, or batch output under
/tmpor/private/tmp— reboots wipe them, flatpak sandboxes block writes to them, and comparing runs across sessions becomes impossible. Canonical locations:- Shell scripts/runners →
scripts/(in-repo, tracked) or$HOME/bin/(persistent per-host) - Batch/iteration outputs →
.local/batches/(in-repo, gitignored) or$HOME/tmp/(persistent per-host) - Per-iteration diagnostic dirs →
.local/iter/(in-repo, gitignored) - Build artifacts (cargo target, Godot exports) →
.local/build/{rust,godot}/(in-repo, gitignored) - RUN host paths →
$PROJECT_ROOT_REMOTE/.local/...(mirror of EDIT host repo layout) - ONLY
/tmpis acceptable for: genuine inter-process pipes (mkfifo), socket activation (.sock files), or Godot internal scratch the engine itself puts there. If you find yourself writing/tmp/mc_*or/tmp/run_*, STOP — use one of the paths above.
- Shell scripts/runners →