iron-borders

Real-time multiplayer territory-control game built in Rust and Bevy ECS, running on desktop (Tauri) and in the browser (WebAssembly) over WebTransport.

Maintained Updated 5mo ago
This project is closed-source. No public repo — but I'm happy to walk through how it works. Ask me about it

iron-borders is a real-time multiplayer territory-control game written in Rust. Players claim tiles to grow a contiguous border, press into their neighbors, and ferry troops across water by ship. The game is deliberately small; the engineering is the reason it exists. The whole simulation lives in a single crate that compiles unchanged to three places — a coordination server, a native desktop app via Tauri, and the browser via WebAssembly — fronted by a React and Pixi.js UI that does all the drawing.

One core, three targets

The crate, borders-core, holds everything that is the game — terrain, nations, combat, ships, bots — and depends on Bevy's ECS rather than Bevy itself. There is no Bevy renderer, window, or asset pipeline; the crate is pure state and the systems that advance it. That austerity is what lets it cross-compile cleanly. Platform differences are confined to cfg splits in the manifest: native builds pull in tokio and reqwest, WebAssembly builds pull in gloo-timers and wasm-bindgen. A single Clock resource hides the one primitive the two worlds genuinely cannot share — SystemTime on native, Date.now() in the browser — so every system reads the clock the same way.

The three consumers are thin shells around that core. borders-server is a tokio binary. borders-desktop is a Tauri app driving a 60 FPS loop. borders-wasm is a cdylib spun up inside a Web Worker.

The server runs no game logic

This is the decision the rest of the project hangs on. The server never simulates the game. Every 100 milliseconds it collects the intents players have sent, packs them into a Turn, and broadcasts that turn to everyone connected. Each client then executes the identical turn — same intents, in the same order — and lands on the identical world state. The server coordinates; it never needs to know what a nation or a ship is.

That only holds if every client is perfectly deterministic, and the constraint shapes everything downstream. No system is allowed to keep its own random state. Instead a single DeterministicRng resource derives a fresh seed for every turn and context, so two machines asking for nation 5's randomness on turn 200 draw the same sequence by construction.

The payoff is in the bots. Their moves come out of that same deterministic stream, so every client computes the identical bot behavior locally — bots are never sent over the network at all. The wire carries only human intents, which means an entire field of AI opponents costs zero bandwidth.

How the per-turn seed is derived

Each context mixes three numbers with prime multipliers, then seeds a StdRng:

rust
let seed = turn_number
    .wrapping_mul(997)
    .wrapping_add(base_seed)
    .wrapping_add(context_id.wrapping_mul(1009));

No state survives between turns. The turn number, the game-wide base seed, and the context id are the only inputs, so the same request always reproduces the same stream — on any machine, in any order.

Buffering intents at the tick boundary

A subtle desync lived in that 100ms boundary. An intent that arrived in the same async poll as the tick could be assigned to either the current turn or the next one, nondeterministically — and a single misplaced intent is enough to split clients apart. The fix is to accumulate intents in a buffer and drain it atomically when the timer fires, so every intent that arrived before the tick ships together in that tick's turn. On the other side, the client tags each intent it sends and flags any that has not been echoed back within five turns — 500 milliseconds — as dropped.

Rendering is data, not draw calls

The Rust core draws nothing. It emits raw state — territory-ownership arrays and nation color palettes — and the frontend turns that into pixels. All rendering is Pixi.js in TypeScript, which selects WebGPU or WebGL at runtime depending on the machine. Territory changes stream as a compact binary envelope, a one-byte type tag followed by its payload, encoded once in Rust and decoded identically on both frontends.

Both frontends, because desktop and browser reach the core through the same FrontendTransport trait. The Tauri build implements it over IPC — invoke for messages, a Channel for the binary stream. The WebAssembly build implements it over Web Worker messages and registered JavaScript callbacks. The pipeline that builds the territory deltas is shared; only the final hop to the screen differs.

What is built, and what is not

The codebase is private and not open source. What runs today is the expansion-and-conquest loop: spawning into a five-by-five claim, tick-based combat weighted by empire size, A* pathfinding for ships moving over water, and the deterministic bots. A larger design — alliances and trade routes among it — is scoped but not built: GameAction exposes only Attack and LaunchShip, and the alliance request is a commented-out line. Defense structures are a placeholder hook that currently always returns false. It is a working multiplayer engine with a deliberately narrow action set, not a finished strategy game.

§06

Gallery

On this page