Skip to content
Nicola Sabaini

Projects

ExaWar

Turn-based strategy on a hex grid, written in Python. Five resources, twelve technologies, fourteen missions, multiplayer on an authoritative server. Free, and finished.

Kind
Game
Status
Released
Language
Python 3.12
Graphics
Pygame · NumPy
Code
~133k lines
Tests
1383
Languages
5
A game of ExaWar in progress: the hex grid with units deployed, the resource panel and the turn bar

A turn-based wargame on a hexagonal grid, set in a future where factions fight for technological supremacy. Written in Python 3.12 on Pygame, released in December 2025 for Windows 10 and 11, and free: no in-app purchases, no account, no sequel in the works to excuse what is missing.

How it plays

A turn has three strict phases: production, action, combat. In production a dice roll decides which sectors extract, and what they extract depends on the terrain. In action you move units and build. In combat it gets resolved, and terrain still counts: a forest is worth +2 on defence, a hill +3.

The economy runs on five flows that have to be kept in balance against each other: energy to power structures and advanced units, materials to build and repair, data to research, food for human troops, tokens for the market. An energy deficit does not stop the game, it cuts production — and you notice two turns later.

There are three ways to win, and they are not variations on one theme: destroy every enemy headquarters, take your AI Core to level 5 and protect it through the final load, or take absolute economic and territorial control of the sector. Someone playing for the singularity and someone playing for military dominance are playing two different games on the same map.

Inside there are 13 units, 17 buildings, 12 technologies plus 4 doctrines, 5 eras and 23 maps. The doctrines come in mutually exclusive pairs — Swarm against Armoured, Logistics against Electronic Warfare: take one and the other is closed for the rest of the game.

How it is built

The project is 432 tracked Python files, around 133,000 lines. Some structural decisions held up better than others, and it is worth saying which.

State has exactly one owner. engine/game_state.py is the only place a game’s state lives, and it mutates only through advance_phase() and start_turn(). That rule is what kept the game tractable once multiplayer and the campaign arrived: if any module could write anywhere, keeping two clients in sync would have been impossible.

Combat is server-side. resolve_combat() sits in the engine, not the client, and the multiplayer server is authoritative: the client asks, the server decides. It uses random, so tests have to seed it — without a seed, simulated games go intermittent and the tests stop meaning anything.

Saves are atomic. Write to a temporary file, fsync, replace. A save interrupted halfway does not exist: either the old one is there or the new one is. And there is schema migration, because saves outlive versions.

Localisation has a single source of truth, and it is a SQLite database: assets/locales/locales.db. Five languages, 1,128 keys per language, zero missing and zero orphaned — and that is a check that runs in the gate before every commit, not a good intention. Scattered translation files are the classic way a multilingual game rots: one key added in Italian and forgotten everywhere else, and four languages show a raw string.

There is one competitive AI. GoalDrivenAI decides by objectives; earlier versions are still in the repository but closed to development. The campaign AI is a different thing again: it knows which mission it is in, and every level from m1 to m14 has its own registered strategy.

Aria, and the console

The part that took the most work is not in the gameplay. The game has an internal console — around 31,000 lines — with an isolated virtual filesystem, entirely in memory, and Aria lives inside it.

Aria understands by meaning, not by keyword. An e5 semantic encoder in ONNX format compares the intent of a sentence against the intent of the commands, and it works across the game’s five languages without translating anything. It is optional: if the model is not there, Aria degrades to text matching instead of refusing to start. A game that will not launch because a few hundred megabytes are missing is not a game, it is a problem.

On top of it sits the NEXL coder: a micro assistant for the internal scripting language. It retrieves examples by semantic similarity, then composes code with a grammar instead of generating it freely, runs it in a dry-run sandbox, observes what would have happened and corrects. It is deterministic and calls eval nowhere: what it produces is valid NEXL by construction, not NEXL you hope about.

Multiplayer

Authoritative server, messages signed with HMAC, protection against compressed payloads that explode on decompression. It works both over a local network, without touching the internet, and online through a lobby relay running on a small cloud server — the same machine that serves this website, which is why the rest of this site is static.

What holds it together

1,383 tests in 128 files, run headless in about two minutes twenty. Before every commit there is a single gate — scripts/check.py — putting together ruff linting, five-language parity, campaign manifest validation and the whole suite.

This is not discipline for its own sake. In a turn-based game with AI, networking and versioned saves, a regression does not show up as an error. It shows up as a game that stops adding up ten turns after the commit that broke it.

The World Forge: the map generator, with its geographical parameters and victory conditions
Aria, the assistant that lives in the in-game console