16 KiB
Magic Civilization
Fantasy 4X turn-based strategy game (Civ5 + Master of Magic + Magic: The Gathering color pie) in Godot 4 / GDScript. 16 races, 5 magic schools, hex grid.
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
Early access demo ("Age of Dwarves") — 4 races (High Elf, Human, Dwarf, Orc), all 5 magic schools, full 4X + magic loop. See .project/ROADMAP.md for scope and build sequence.
Tech Stack
- Engine: Godot 4.x
- Simulation: Rust (
packages/physics-rs/) — compiled to GDExtension for the game, WASM for the web guide - Scripting: GDScript — presentation layer only (rendering, UI, input, signals)
- Data: JSON game packs (
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. See docs/engine/ABSTRACTION.md (to be written).
Rust is the simulation source of truth. All game logic (physics, combat, economy, AI, pathfinding, magic, tech, turn resolution) lives in packages/physics-rs/ and is compiled 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.
JSON game packs remain the canonical content store. Stats, costs, effects, thresholds — all in 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:
engine/docs/— genre-agnostic engine architecture (written as engine systems are built)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 crate
packages/physics-rs/is the source of truth for all simulation logic. It compiles to two targets. Never duplicate simulation logic in GDScript or TypeScript.
games/age-of-dwarves/data/climate_spec.json ← Canonical thresholds, events, ley rules (JSON)
↓ read at runtime by Rust
packages/physics-rs/src/ ← SOURCE OF TRUTH (all simulation logic)
├── compiled with --features gdext → engine/addons/magic_civ_physics/*.so/.dll
│ ↓ loaded by Godot
│ engine/src/modules/climate/climate.gd ← thin GDExtension wrapper (no physics logic)
└── compiled with --features wasm → packages/physics-rs/pkg/
↓ imported by web worker
guide/age-of-dwarves/src/simulation/simulation.worker.ts
Build commands
# WASM (web guide)
cd packages/physics-rs && bash build-wasm.sh
# GDExtension (Godot game, Linux dev)
cd packages/physics-rs && bash build-gdext.sh
Rules
- All simulation changes go in Rust (
packages/physics-rs/src/) — never in GDScript or TypeScript - 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 |
packages/physics-rs/src/algorithms/, hex math, A*, map gen, tile storage |
game-algorithms |
packages/physics-rs/src/economy/, city/, happiness/, culture/, turn sequencing |
game-systems |
packages/physics-rs/src/combat/, keywords, flanking, ZOC, promotions |
combat-dev |
packages/physics-rs/src/magic/, spells, mana, Archons, enchantments, Ascension |
magic-dev |
packages/physics-rs/src/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 |
guide/age-of-dwarves/, guide/engine/, React, Vite, WASM integration |
guide-web |
Conventions
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
packages/
physics-rs/ — Rust simulation engine (SOURCE OF TRUTH for all game logic)
src/
grid/ — GridState, TileState, hex math
climate/ — ClimatePhysics, EcologyPhysics, atmosphere, spec evaluator
map_gen/ — MapGenerator, terrain, hydrology, wind
algorithms/ — HexUtils, A* variants, LoS, range
combat/ — CombatResolver, keywords, flanking, ZOC, promotions
economy/ — gold, upkeep, yields, improvements
city/ — production queue, growth, buildings
happiness/ — global pool, racial tiers, Golden Ages
culture/ — generation, border expansion
magic/ — mana, spells, archons, enchantments, ascension
tech/ — TechWeb graph
ai/ — strategic AI, tactical, city, magic, wild creatures
turn/ — turn sequencing, victory, era progression
api_gdext.rs — godot-rust class registrations
api_wasm.rs — wasm-bindgen exports
Cargo.toml
build-wasm.sh
build-gdext.sh
engine-ts/ — @magic-civ/engine-ts (types, runner, HexGrid — no generated physics)
engine/
addons/
magic_civ_physics/ — compiled GDExtension .so/.dll/.framework
src/
autoloads/ — singletons (GameState, TurnManager, DataLoader, EventBus, ThemeVocabulary, ThemeAssets)
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
games/
age-of-dwarves/
data/ — all JSON content (units/, spells/, techs/, terrain/, ...)
assets/ — sprites/, icons/
game.json — game manifest
guide/
engine/ — @magic-civ/guide-engine (shared UI components, types, utils)
age-of-dwarves/ — @magic-civilization/guide-age-of-dwarves (all pages, app shell)
src/simulation/simulation.worker.ts — imports from @magic-civ/physics-rs (WASM)
DX Tooling
Testing & Linting
- Rust tests —
cargo testinpackages/physics-rs/ - WASM build —
cd packages/physics-rs && bash build-wasm.sh - GDExtension build —
cd packages/physics-rs && bash build-gdext.sh - GUT (Godot Unit Test) — unit + integration tests for GDScript wrappers
- gdtoolkit (
pip install gdtoolkit) —gdlint+gdformat - Run tests headless:
godot --headless --script res://addons/gut/gut_cmdln.gd - Run lint:
gdlint engine/src/
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 engine/src/
./run test # GUT tests headless
./run screenshot [name] [scene] # Capture + SCP to plum
./run export [version] # All platforms in parallel
Screenshot & Visual Verification
./tools/screenshot.sh [name] [scene] [delay]
Screenshots are captured to Flatpak user data and SCP'd to plum:~/Desktop/magic_civ_<name>.png.
Proof Scenes (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:
games/age-of-dwarves/data/races.json - Split directory:
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 (
engine/scenes/tests/) renders ALL claimed features in one screenshot - The screenshot is captured via
tools/screenshot.sh - The screenshot is SCP'd to
plum:~/Desktop/magic_civ_<phase>_proof.png - 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.
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
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