An isometric real-time strategy game — 3D world, 2D sprites, fog of war, a build/train economy and an AI opponent.
This README is a map, not a manual: each section is a short orientation and a pointer to the document that actually owns the subject.
It is a Godot 4.7 project in GDScript, with a few hot loops in a small C++ extension that has to be built once before the project will open — the scripts name its classes, so without it they fail to parse.
- Install the engine from https://godotengine.org/download.
- Install the build tools: a C++17 compiler and SCons.
- macOS:
xcode-select --install(the Command Line Tools), thenpip install scons. - Linux: your distribution's
g++(orclang), thenpip install scons. - Windows: Visual Studio 2022 or its Build Tools with the "Desktop development with C++"
workload (
winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"), thenpython -m pip install scons.tools\install_godot.ps1installs the matching engine and putsgodoton your PATH. The.blendmeshes import through Blender, and are saved by Blender 5.1, which older versions cannot open; install it and, if Godot does not find it, set Editor Settings → FileSystem → Import → Blender → Blender Path.
- macOS:
- Build the extension from the repository root:
The first run fetches the
tools/build_native.shnative/godot-cppsubmodule; a clean build takes under a minute. The library lands inbin/, which is git-ignored. Run it again after pulling any change tonative/. Add--releaseto also build the library an export ships. On Windows run it from Git Bash, orpowershell -ExecutionPolicy Bypass -File tools\build_native.ps1. - Open the project: point Godot at
project.godotin the repository root.
On macOS the script builds against the SDK your installed toolchain designates ($SDKROOT if
set), because a Command Line Tools update can leave behind a newer SDK its linker cannot read.
If the link step fails anyway, check xcode-select -p points at the toolchain you expect.
→ gdd/systems/authoring/native-code.md — what is
native and why, and the rules for changing it
New to Godot? Start with the engine's own Step by step guide. Scenes, nodes and signals are the three ideas the rest of this project assumes you have.
The main scene is the title screen, scenes/menu/main_menu.tscn; scenes/scenarios/skirmish.tscn
is the bot-versus-bot map most development happens against.
Every game piece — unit, structure, projectile, status effect, faction — is defined by a
markdown document in gdd/, not in the editor. The document's YAML frontmatter is the spec,
and the importer applies it to the Godot project:
---
kind: unit
title: Recruit
scene: res://scenes/entities/units/cl/cl_bioLight_antiLight.tscn
defense:
hp: 120
armour: LIGHT
frame: BIO
---Two rules carry most of the weight: a file is a spec if and only if its frontmatter names a
kind, and the file name is the piece's id (cl_bioLight_antiLight.md is the piece
cl_bioLight_antiLight). Folder location is purely organisational. Hand-written code refers to
pieces through the generated EntityIds constants, never raw strings.
The importer rewrites scenes and regenerates scripts/generated/ and resources/generated/,
so run it only when your change is doc-facing — a piece added, renamed or re-statted, or the
schema itself. It is not part of the test loop.
→ tools/spec_import/README.md — running it, the schema,
validation rules
→ gdd/systems/authoring/spec-importer.md — why
the pipeline is shaped this way, and what stays editor work
Terrain is a single TerrainData resource holding per-corner heights and a per-cell tile-type
layer, painted and sculpted in-editor with the terrain_brush plugin. Passability, the navmesh
and the fog grid are all derived from it at load — never edited directly.
Two things are worth knowing before you touch a map:
- A map's heights and its surface mesh are the same surface in two representations, and editing one without regenerating the other is how a map silently goes flat. There is a procedure, and a check that proves it worked.
- Water is a level and a seed cell; everything else about a body — which cells it covers, how deep each is, what leaves the navmesh — is derived from those two numbers.
→ gdd/systems/terrain-and-navigation/terrain-authoring.md
— the brush, the tile-type palette, the authoring loop
→ gdd/systems/terrain-and-navigation/water-bodies.md
— ponds, depth, and what may be built in water
→ CLAUDE.md §Regenerating data — the primary/derived rule and the check that
enforces it. Read this one before regenerating anything.
Three kinds of test, deliberately kept apart because they answer different questions and a red result means something different in each.
GUT, in tests/. This is the gate: its answer must not move
unless something broke. A "regression test" here just means one that needs a live scenario
rather than a bare object — it still belongs in this suite.
# one file
godot --headless --fixed-fps 30 -s addons/gut/gut_cmdln.gd -gtest=res://tests/test_Movement.gd -gexit
# the whole directory, split across four processes (~26 s)
python3 tools/gut_shards/gut_shards.py
# the whole directory in one process (~90 s)
godot --headless --fixed-fps 30 -s addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit-gexit is required — without it the run finishes and then waits forever for a window that
--headless never created. --fixed-fps 30 runs the ticks as fast as they compute rather than at
30 a second; seeded results are unchanged.
Authored as YAML specs in sims/, run by their own tool. These never run in GUT, because a
balance answer is allowed to change when a stat changes and a gate that goes red for that stops
being trusted.
# every spec once
godot --headless --path . --fixed-fps 30 tools/simulation/run_sims.tscn
# one spec, ten seeds, JSON out
godot --headless --path . --fixed-fps 30 tools/simulation/run_sims.tscn -- \
spec=duel_builder_mirror trials=10 seed=1 out=/tmp/sims.json
# watch one play out: drop --headless AND --fixed-fps
godot --path . tools/simulation/run_sims.tscn -- spec=antimech_vs_truck hold=5An expectation coming out false is a finding, not a failure — the runner exits non-zero only when a spec cannot be parsed or built.
→ gdd/systems/scenario-scripting/simulation-tests.md
— the grammar, the runner, and the reasoning behind the split
./tools/lint.shgdlint, gdformat --check (gdformat's layout is the house style — format with
gdformat -l 100 <files>), and a hand-rolled indentation check. The tree is tab-indented; the extra check
exists because Godot can silently reindent files it merely has open, which gdlint cannot
detect on its own.
→ gdd/systems/authoring/linting.md
A bot has no learned model. It is a set of hand-written decision modules — economy,
production, military, scouting, targeting — driven by a flat bag of numbers, BotDifficulty.
"Training" here means searching those numbers by adversarial self-play, not fitting weights.
Nothing in the repository loads or produces a neural network.
A bot is initialised from its slot's difficulty tier and nothing else:
PlayerSlot.difficulty → BotDifficulty.for_tier() → BotBrain → the manager modules
BotDifficulty is deliberately flat — plain int / float / bool fields, no arrays or
resources — because that is what lets a whole configuration be a JSON object a search can write.
The tier values are hand-made placeholders; the knobs and the direction each one means are
the settled part, and a tier that inverted one would be a bug (tests/test_ScenarioPlayerSlots.gd
pins the ramp).
Two traps worth knowing before you put a bot in a scenario:
PASSIVEdoes not mean inert. It means "minimally active, and never attacks" — a passive bot still builds, trains, defends itself and re-tasks its idle units.BotBrain.active = falseis the only thing that genuinely freezes a commander.- Several fields carry sentinels rather than being continuous (
-1meaning "uncapped" or "never"). Read them through the named helpers, not as raw numbers.
Self-play runs headless, two bots, one map, one seed per match, JSON out:
# one match
godot --headless --path . --fixed-fps 30 tools/selfplay/run_match.tscn -- \
config=/abs/path/match.json out=/abs/path/result.json
# a batch (see tools/selfplay/example_batch.json for the config shape)
GODOT=/path/to/godot python3 tools/selfplay/run_batch.py matches.json \
--out results.jsonl --jobs 2--fixed-fps 30 is not optional — without it Godot paces physics to the wall clock and a
20-minute match takes 20 minutes. With it, a --jobs 2 batch gets through roughly one match per
105 wall-seconds, so a 200-match sweep is about six hours. Budget accordingly.
A run never edits the shipped difficulty tiers: a tier is the starting point, and the config's
config block overrides fields on the live object afterwards.
Stated here because it changes what a campaign is worth running for, and both results are measured rather than suspected:
- The parameter space is largely flat. Of 29 one-at-a-time perturbations from MEDIUM, eight changed the simulation not at all (bit-identical digests) and eleven more moved it less than the harness's own run-to-run noise. Every finite-difference slope had a confidence interval spanning zero. Gradient descent over these parameters is not yet meaningful, and sample size is not the reason.
- The harness is not reproducible. The same config and seed can take different trajectories across processes. Determinism is a deferred question, not a flag to flip — so read a campaign as a distribution, never as a single trial.
→ gdd/systems/ai/selfplay-harness.md — input/output
schemas, termination rules, throughput figures, known limits
→ gdd/systems/ai/bot-parameter-space.md — every
knob, its range, and which ones are coupled
→ gdd/systems/ai/bot-architecture.md — what the
modules actually decide
→ gdd/systems/ai/bot-roadmap.md — the gaps in the decision
surface, and what a training harness needs next
→ gdd/systems/ai/selfplay-results-*.md — write-ups of campaigns already run
Mechanics are written up as rules, with their reasoning, under gdd/systems/
— one folder per system, each with a README.md stating its scope. Start at
gdd/systems/README.md.
CLAUDE.md carries the project-wide invariants: terminology, the command
lifecycle, conventions, and a list of things not to break.
gdd/tasks.md logs the open decisions, the plans not yet built, and the work queued for later.