Architecture¶
What the pieces are, why they are separated the way they are, and where to change things.
citar/
engine/ the rules. No I/O, no network, no database
bots/ the scripted opponent
agents/ adapters that let a model take a seat
server/ FastAPI app, sessions, turn driver, benchmark scheduler
auth/ accounts, sessions, permissions
pool/ shared hardware: groups, grants, budgets, availability
db/ SQLAlchemy models
reports/ report building and rendering
worker/ the outbound agent that serves models from another machine
web/ the browser client
wizard/ first-time setup
data/ the ruleset, as JSON
The one rule¶
citar/engine/ does no I/O. It does not read files at runtime, open sockets, touch the
database or know what a request is. It takes a game state and an action and returns a new state or
an error.
Everything else follows from that:
- The same engine runs a browser game, a benchmark, a headless simulation and a lab experiment.
- A game is a value. Saving it is
json.dumps; loading it is the reverse; the replay is a list of them. - The bot, the LLM adapter and the HTTP API are all callers, none of them privileged. A model cannot do anything a human could not, because there is only one set of actions.
- Tests are fast and deterministic. 272 of them run in ninety seconds with no fixtures.
The ruleset is loaded once at import, which is the one exception, and it is read-only.
Engine modules¶
| Module | |
|---|---|
state.py |
The data. GameState, Player, City, Unit, Tile — plain dataclasses that serialise |
game.py |
Game: the state plus the operations on it. ActionError is how a rule says no |
rules.py |
Loads the ruleset JSON and answers questions about it |
uniques.py, unique_types.py |
The rule interpreter. See below |
hexmap.py, tiles.py, mapgen.py, maps.py |
Geometry, terrain, generation, saved maps |
movement.py, combat.py, units.py, workers.py |
Units and fighting |
cities.py, economy.py, research.py |
Cities, yields, growth, technology |
policies.py, religion.py, great_people.py, espionage.py |
The social systems |
diplomacy.py, city_states.py, conquest.py, victory.py |
Other players |
visibility.py, views.py, briefing.py |
What a player can see, as data and as prose |
tools.py |
The single registry of actions. Everything a player can do is here |
turns.py, triggers.py, barbarians.py, ruins.py |
The turn cycle |
The unique interpreter¶
UnCiv expresses rules as text on objects — [+15]% Strength <when attacking>,
[+2] [Food] from every [Lake]. Rather than hard-coding each one, CITAR parses them into
Unique objects with typed parameters and conditionals, and the systems that care ask
UniqueMap what applies in a given context.
This is why most content is data. A new building with a known unique is a JSON entry; only a new kind of rule needs code.
unique_types.py is generated from UnCiv's UniqueType enum by scripts/gen_unique_types.py.
scripts/check_uniques.py flags unique text matching no known type. Of the 402 unique types the
ruleset uses, 19 are unreferenced in code, mostly map-generation region hints.
Tools: one registry, three interfaces¶
@tool("found_city", "Found a city with a settler", ...)
def found_city(game, player, unit_id, name=None):
...
Registering an action makes it available to:
- the browser, over
POST /api/games/{id}/tool - MCP clients, through the bridge
- the LLM adapter, as a tool definition with its JSON schema
There is no second place to add an action, and no interface can drift from another. GET /api/tools
returns the whole registry with schemas.
The server¶
| Module | |
|---|---|
app.py |
The FastAPI app: routes, WebSockets, static files |
session.py |
SessionManager and GameSession: seats, the turn driver, negotiation interrupts, saving |
benchmarks.py |
The benchmark scheduler: suites, runs, restricted hours, resuming after a restart |
scoring.py |
Model scores |
metrics.py |
Per-seat, per-turn measurement |
auth_api.py, admin_api.py, pool_api.py, share_api.py, setup_api.py |
HTTP surfaces |
boot.py |
Migrations at startup, and the configuration checks that refuse to start a broken server |
workers.py |
The WebSocket endpoint worker agents connect to |
admin_cli.py |
citar admin |
The turn driver¶
A GameSession owns one game and drives it: when the current player is an AI, it runs that seat's
agent to completion, applies the orders, records metrics, autosaves, and moves on. Human seats wait
for HTTP. MCP seats are woken by wait_for_turn.
Negotiations interrupt: an AI that proposes a deal blocks until the other side answers, which is
why wait_for_turn returns for a negotiation as well as for a turn.
Authorisation¶
citar/auth/access.py is the only place that answers "what may this viewer do with this
object". Nothing hand-rolls a permission check. Two consequences worth knowing:
- Refusals are 404 when the caller has no view permission, not 403. A 403 confirms the object exists, which turns id-guessing into enumeration.
- Watching and playing are separate. A public spectator link never implies the right to play.
scripts/audit_routes.py walks every route — including those inside included routers — and reports
any that neither declares a gate nor appears in its list of deliberately public routes, each with a
reason. Run it after adding a route; CI runs it with --strict.
Agents¶
llm_agent.py |
The loop: briefing, tool calls, guard rails, limits, metrics |
prompts.py |
What the model is told |
bot_agent.py |
Wraps the scripted bot in the same interface |
mcp_server.py |
The MCP bridge |
providers/anthropic_provider.py |
Thinking, prompt caching, refusal fallbacks |
providers/openai_provider.py |
Native and JSON tool modes, for every OpenAI-compatible endpoint |
providers/worker_provider.py |
Sends the request down a worker's WebSocket instead |
providers/dryrun.py |
Answers plausibly without a model, for testing everything else |
A provider's only job is turning a request into a completion. The turn loop, the guard rails and the metrics are provider-independent, which is what makes a comparison across providers meaningful.
The browser client¶
Plain ES modules, no build step, no framework. citar/web/js/ is served as-is, which means editing
a file and reloading is the whole development loop.
app.js |
The router |
api.js |
Every HTTP call, CSRF, share keys |
game.js, render.js, hex.js, panels.js |
The game screen and the canvas renderer |
lobby.js, benchmarks.js, models.js, lab.js, probes.js, scenario.js, editor.js |
The other screens |
servers.js, pool.js, reports.js, console.js, setup.js |
Machines, sharing, reports, operator console, first-run wizard |
auth.js, account.js |
Sign-in and account settings |
Check a change with node --check on a .mjs copy of the file: on a .js file Node parses it
as CommonJS, where some module-level syntax errors do not error. A broken template literal inside a
ternary once passed that check and broke the entire module graph in the browser. CI does it the
right way.
Data and state¶
| Kind | Where | Format |
|---|---|---|
| Ruleset | citar/data/ruleset/ |
UnCiv-derived JSON, generated |
| CITAR additions | citar/data/custom/ |
Same format |
| Game settings | citar/data/game.json |
Map sizes, lobby defaults, AI limits |
| Saved games | saves/<id>/*.citar |
Gzipped JSON |
| Maps, scenarios, probes | saves/maps, saves/scenarios, saves/probes |
JSON |
| Server registry | config/servers.json |
JSON |
| Usage ledger | saves/usage/*.jsonl |
One line per activity, no prices |
| Accounts | the database | SQLite or PostgreSQL |
citar/paths.py resolves all of it. Nothing else computes a path from __file__.
Where to change things¶
| To change | Go to |
|---|---|
| A number, a unit, a building | citar/data/ — it is data |
| A rule that has a unique | citar/data/ — the interpreter handles it |
| A new kind of rule | The engine module that owns the system, plus unique_types.py |
| A new player action | engine/tools.py — it reaches all three interfaces at once |
| How the bot plays | bots/basic.py, and A/B it (BOTS.md) |
| What a model is told | agents/prompts.py and engine/briefing.py |
| A screen in the browser | web/js/, no build step |
| Who may do what | auth/access.py, and only there |