Route One Tactics
Game Guide
← Back to site
Solo project — design, engineering, tooling and infrastructure
Route One Tactics is a monster-collecting auto-battler: a deterministic battle engine, a data-driven balance pipeline, authoritative online multiplayer, and three build targets from a single Python codebase. This page is the engineering write-up.
Written for people who want to know how it works. You don't need any of it to play. Figures are from the current build and will drift as the project changes.
The four decisions that shaped the project most.
Problem Balancing an auto-battler by playing it is hopeless — a single match takes minutes and produces one noisy data point.
Approach Combat is resolved entirely by a headless engine that takes two teams and emits an event log (damage, abilities, KOs). The battle screen is a renderer replaying that log — it has no say in outcomes. The engine runs with no window and no display driver.
Result A full round-robin across every preset composition — over a thousand battles — runs in about 26 seconds, so balance changes are measured rather than guessed.
Problem Tuning numbers shouldn't require a code change, a rebuild, and a release — but edits also shouldn't corrupt the source of truth.
Approach Stats, synergy breakpoints and item effects live in data modules. A web dashboard reads them, shows real win rates from played matches, and publishes edits as a separate overrides file that is merged in at lookup time. Base data is never rewritten.
Result Any balance patch is one reversible file, and the same override reaches the desktop game, the browser build and the public game guide.
Problem If the server and client resolve battles with different code, they will eventually disagree — and that class of bug is miserable.
Approach Online battles are resolved server-side over WebSockets using the same engine the offline game runs. Boards, items and synergies are serialised through one shared wire format, so there is a single implementation of the rules.
Result Online and offline can't silently diverge. The trade-off is that client and server must deploy together, which the release process enforces.
Problem Every screen was written against a fixed 1280×720 coordinate space and upscaled, which looked soft on a 1440p display. Rewriting the layout of every screen wasn't realistic.
Approach Introduced a scaling indirection: layout code still passes 720p coordinates, and a painter multiplies by the render scale at draw time, rasterising text with correctly-sized fonts and requesting sprites at native pixel size. Around 360 direct draw calls were migrated to it.
Result The UI renders at the display's real resolution. The migration was validated by rendering every screen and diffing against the previous build pixel-by-pixel to prove nothing moved at the original scale.
A game this data-heavy fails in data, not just in code — a unit missing a stat, a synergy referencing an effect the engine doesn't implement, two copies of a data file drifting apart. Those don't raise a neat exception; they crash a battle three rounds in.
So there's a preflight check that runs before builds and exits non-zero if anything is wrong, gating the pipeline. It targets the exact failure classes that have bitten the project before: duplicate unit IDs, abilities whose effect has no engine handler, and API drift between the player and wild-Pokémon classes.
Core modules also carry runnable self-test blocks, so a subsystem can be exercised on its own without launching the game.
The game is Python and Pygame — no engine, no scene editor, just a main loop and drawing code. An unusual choice at this size, and a deliberate one: full control over every frame, paid for by writing things an engine would give you free.
One codebase produces three targets:
| Target | How | Used for |
|---|---|---|
| Desktop | PyInstaller (Windows & macOS) | The full game, including online play |
| Browser | Compiled to WebAssembly with pygbag | Play instantly, no install |
| Headless | No display driver | Balance simulation and automated checks |
Server-side, four pieces run independently:
| Service | Responsibility |
|---|---|
| Game server | WebSocket lobbies and authoritative battle resolution |
| Staging server | Identical copy for testing; sleeps when idle, woken on demand |
| Admin service | Flask app: balance dashboard, stats API, SQLite storage |
| Static site | This site and the browser build |
Desktop builds deliberately don't run on every commit — they're the
expensive step, so they're triggered on demand or by a release tag.
A tag containing a hyphen (v1.2.0-uat.1) produces a
pre-release that never reaches players; a plain tag
(v1.2.0) is the real thing. Standard semver
pre-release syntax doing real work in the pipeline.
Character animations come from the PMD Sprite Collab community project — 191 animated sprite sets, with per-artist attribution listed in game under Settings → Credits.
Everything is pixel art, which makes scaling a correctness problem rather than a taste one: bilinear filtering turns a crisp one-pixel outline into a soft three-pixel smear. Sprites are scaled with nearest-neighbour sampling, snapped to whole-number multiples where possible, and only downscaled with smoothing — the one case where interpolation is the right answer.
This is a solo, non-commercial project, still in development. Some things on this site are switched off deliberately rather than left half-working: desktop downloads and trainer statistics are both paused while their pipelines are finished.
The browser version is the real game, not a cut-down demo, and it's playable now.
Play it in your browser →