15 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
- Language: GDScript only (no C#, no GDExtension)
- 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).
Godot is the primary runtime. GDScript + JSON is the source of truth for everything. The TypeScript in packages/ and guide/ exists only to serve the web guide — it is secondary, generated, and must never be the canonical location for game data or logic. When creating new systems, data structures, or content packages: design for Godot/GDScript first, with JSON data files in the engine tree (engine/ or games/). The TS side consumes those same JSON files through bundler imports. Never create TypeScript-only packages for game content that Godot needs to read.
- 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: GDScript → TypeScript
The GDScript climate engine is the source of truth for all simulation logic. The TypeScript in
guide/is generated output — never edited directly. If the guide gives wrong results, fix the GDScript and re-run the transpiler.
The climate simulation runs in two runtimes: GDScript (Godot game engine) and TypeScript (web guide). GDScript is written first; TypeScript is auto-generated from it via lilith-gdscript-transpiler.
Architecture
games/age-of-dwarves/data/climate_spec.json ← Canonical thresholds, events, ley rules
↓ read at runtime by both engines
engine/src/modules/climate/climate.gd ← SOURCE OF TRUTH (physics steps)
engine/src/modules/climate/ecological_events.gd ← SOURCE OF TRUTH (stochastic events)
engine/src/modules/climate/anchor_decay.gd ← SOURCE OF TRUTH (ley anchor decay)
engine/src/modules/climate/climate_spec_eval.gd ← SOURCE OF TRUTH (spec condition evaluator)
↓ transpiled by tools/transpile-engine/transpile.py
packages/engine-ts/src/ClimatePhysics.generated.ts ← AUTO-GENERATED, never edit
packages/engine-ts/src/index.ts ← Re-exports for @magic-civ/engine-ts
guide/age-of-dwarves/ ← Consumes @magic-civ/engine-ts (workspace:*)
Transpiler Package
lilith-gdscript-transpiler — custom GDScript→TypeScript transpiler.
- Package:
@packages/@py/gdscript-transpilerin this workspace - PyPI index:
https://forge.nasty.sh/api/packages/lilith/pypi/simple/ - Invocation:
uv run tools/transpile-engine/transpile.py(self-contained inline script —uvhandles deps automatically, no manual pip install needed) - Check mode:
uv run tools/transpile-engine/transpile.py --check(exits 1 if generated file is stale vs GDScript source) - Sources:
climate.gd,ecological_events.gd,anchor_decay.gd,climate_spec_eval.gd - Output:
packages/engine-ts/src/ClimatePhysics.generated.ts
uv run tools/transpile-engine/transpile.py # regenerate
uv run tools/transpile-engine/transpile.py --check # CI: exit 1 if stale
Rules
- Never edit
ClimatePhysics.generated.ts— it's auto-generated from GDScript - Never hardcode terrain thresholds — read from
climate_spec.json - All simulation changes go in GDScript first — then re-run transpiler
- Ecological events require a seed —
process_turn(game_map, turn, seed)for deterministic PRNG - Golden test vectors verify both engines produce identical output on the same seed
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 |
godot-engine |
| Hex coordinates, A*, map generation, tile storage | game-algorithms |
economy.gd, happiness.gd, culture.gd, city production, growth |
game-systems |
combat_resolver.gd, keywords, flanking, ZOC, promotions |
combat-dev |
spell_system.gd, mana_pool.gd, archon.gd, spells, wonders |
magic-dev |
ai_player.gd, 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, climate sim |
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
engine/
src/
autoloads/ — singletons (GameState, TurnManager, DataLoader, EventBus, ThemeVocabulary, ThemeAssets)
entities/ — game objects (unit, city, building, improvement, archon)
map/ — tile storage, hex utils, pathfinder, game_map
generation/ — map generator, terrain refiner, hydrology, wind
rendering/ — hex, city, unit, fog, river, road, indicator renderers
core/ — save manager, pending actions, terrain affinity
modules/
combat/ — combat_resolver, keyword_handler
magic/ — spell_system, mana_pool, infusion_system
climate/ — climate, atmosphere, weather, ecological_events
ley/ — ley_network
empire/ — economy, happiness, culture, government
management/ — city_manager, unit_manager, improvement_manager
events/ — natural_events
victory/ — victory_manager, ascension_ritual
ai/ — ai_player, ai_tactical, ai_city, ai_magic_research, ai_military
tech/ — tech_web
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)
DX Tooling
Testing & Linting
- GUT (Godot Unit Test) — unit + integration tests for GDScript
- 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