A modern reimagining of the classic terminal/X11 game xbattle — a real-time, continuous-flow strategy game of territory and troops.
The whole thing is an Erlang/OTP server plus a single-file HTML5 Canvas
client. No JavaScript build step, no framework, no external JSON library — just
rebar3 shell and a browser.
See a Demo here
You start on a base and command a nation of coloured cells. There are no units to click around one at a time. Instead you paint flow arrows on the cells you own, and troops stream continuously in those directions every tick — growing on the land you hold, spilling into neutral territory, and colliding with enemies at the borders. Take every enemy base to win.
- Continuous flow — troops move like a fluid along the arrows you draw.
- Fog of war — you only see cells within sight of the land you own.
- Terrain — plains, hills (defensive bonus), bases (strong defensive bonus + your paratroop launch points), and water (impassable).
- Bots — add a computer opponent that pathfinds toward the nearest front.
- Digging — raise land into hills, or flood a cell into water (a moat).
- Bases — start on one, capture more, or fortify your own land into a new one; bases defend strongly and launch paratroops.
- Paratroops — spend base troops to drop a garrison anywhere you can see.
- Hex or square grids — choose the board topology per game.
Requirements: Erlang/OTP 28 and rebar3 (brew install erlang rebar3).
git clone <this-repo> exbattle
cd exbattle
bin/exbattle # or: rebar3 shellThen open http://localhost:8080/ in one or more browser tabs.
By default the server binds 0.0.0.0:8080 (all interfaces). Override it with
switches on the launcher:
bin/exbattle --port 9000 # 0.0.0.0:9000
bin/exbattle --ip 127.0.0.1 # loopback only
bin/exbattle -i 192.168.1.10 -p 80 # a specific interface + port
bin/exbattle --base-presses 3 # a base takes 3 presses to complete
bin/exbattle --grid square # new games default to a hex grid
bin/exbattle --help # list optionsThe launcher just sets EXBATTLE_IP / EXBATTLE_PORT /
EXBATTLE_BASE_PRESSES / EXBATTLE_GRID, which the app reads at startup — so
you can also run rebar3 shell directly with those environment variables (or
set the ip / port / base_presses / grid keys in the exbattle
application env). The default grid for new games is hex; set it to
square to flip that default.
Each browser tab is a player. Open a second tab (or send the URL to a friend on your network) to play head-to-head, or click Add bot for a computer opponent.
- The game id box picks which match you join (it's also stored in the URL
hash, e.g.
http://localhost:8080/#mymatch). Anyone using the same id lands in the same game. - Click Join to switch games. Your seat, territory, and a reconnect token
are remembered in
localStorage, so a refresh drops you straight back in.
The board topology is fixed when a game is first created, so pick it before
anyone joins that game id. New games default to a hex grid (server
configurable — see the launcher's --grid switch / EXBATTLE_GRID).
- Type a fresh game id (one nobody has created yet).
- Tick or clear the “hex grid (new games)” checkbox in the lobby — it starts ticked, matching the hex default.
- Click Join.
The choice is remembered per game id across the page reload. Everyone who joins that id afterwards gets the same board automatically — the first creator wins, so you can't change an existing game's topology.
Pick a tool in the Tool panel, then act on the board:
| Tool | Action |
|---|---|
| Arrows | Drag across your cells to paint flow direction; click a cell edge to toggle a single arrow; right-click clears a cell's arrows. |
| Build hill | Click a full (100%) cell you own to raise it into a hill (spends some troops; hills defend better). |
| Build base | Click a full (100%) cell you own to start fortifying it into a new base. It builds incrementally — each press fills more of the outline until it closes and the base activates. You only need a full cell to start; construction then continues regardless (strong defence + a fresh paratroop launch point). |
| Moat | Click a full (100%) cell you own to flood it into water — impassable, and it wipes the garrison. |
| Para | Click any cell you can see to drop a garrison there from your strongest base. Great for reinforcing a distant front or opening a new one off-adjacency. |
On a square grid arrows point N/S/E/W; on a hex grid they point along the six hex neighbours (E, W, NE, NW, SE, SW). The client figures out the right direction from where you drag.
Keyboard shortcuts act on whichever cell the pointer is hovering, so you can build without switching tools:
| Key | Action |
|---|---|
b |
Build base on the hovered cell. |
h |
Build hill on the hovered cell. |
m |
Moat (flood) the hovered cell. |
p |
Paradrop onto the hovered cell. |
The ☀ / ☾ button in the header toggles between light and dark palettes; the
label shows the theme you'll switch to. Your choice is remembered in
localStorage, and new visitors default to their system preference.
The map seeds a fixed set of neutral bases (each holding a garrison). You claim your first by joining, and you gain more two ways:
- Capture a neutral or enemy base by flowing enough troops onto it — bases defend at 2×, so you need a real column to take one.
- Fortify your own land with the Build base tool once the cell holds enough troops to pay the cost.
More bases means more places to launch paratroops from and more strongpoints to hold the line.
browser (index.html, Canvas 2D) ⇄ WebSocket ⇄ cowboy ⇄ one gen_statem per game
- One process per game. Each match is a single
gen_statem(game_server) that owns the entire board state. Because there's exactly one writer per game, the simulation is lock-free. - Each browser is a subscriber. A
ws_handler(cowboy WebSocket) process per connection joins a game, receives the initial board, and then gets per-player deltas filtered through that player's fog of war. - Pure board model.
board.erlis a side-effect-free module: grow → flow → combat each tick, plus terrain, adjacency (square and hex), and combat resolution. It's trivially testable in the shell.
exbattle_app
└── exbattle_sup (one_for_one)
├── games_sup (simple_one_for_one) → dynamic game_server children
└── game_manager (gen_server registry: GameId → Pid)
| File | Role |
|---|---|
src/exbattle_app.erl |
OTP application; starts the cowboy listener. |
src/exbattle_sup.erl |
Top supervisor. |
src/games_sup.erl |
Dynamic supervisor for per-game processes. |
src/game_manager.erl |
Registry that maps a game id to its process (creating it on demand). |
src/game_server.erl |
gen_statem running one match: seats, fog, ticking, bots, commands. |
src/board.erl |
Pure board model: terrain, flow, combat, square/hex topology. |
src/ws_handler.erl |
cowboy WebSocket handler; the wire protocol edge. |
priv/www/index.html |
The entire client: Canvas rendering, input, WebSocket. |
scripts/ws_*.py |
Raw-WebSocket Python test clients, one per milestone. |
plans/feasibility-and-plan.md |
Design notes and the milestone roadmap. |
JSON text frames over /ws. Cell keys are terse: o = owner (0 = neutral),
n = troops, t = terrain, d = arrow directions.
Client → server
| Message | Meaning |
|---|---|
{t:"join", game, token, grid} |
Join/create a game. grid is "square" or "hex" (only used when the game is created); token reconnects to a held seat. |
{t:"dir", cell:[x,y], dir, on} |
Set/clear one flow arrow (dir ∈ n,s,e,w or e,w,ne,nw,se,sw). |
{t:"clear", cell:[x,y]} |
Clear all arrows on a cell. |
{t:"build", cell:[x,y]} |
Raise owned land into a hill. |
{t:"build_base", cell:[x,y]} |
Fortify owned land into a new base. |
{t:"moat", cell:[x,y]} |
Flood owned land into water. |
{t:"para", cell:[x,y]} |
Paradrop onto a visible cell from your strongest base. |
{t:"addbot"} |
Add a bot opponent. |
{t:"ping"} |
Keepalive (server replies pong). |
Server → client
| Message | Meaning |
|---|---|
{t:"init", w, h, grid, you, token, fog, players, cells} |
Full initial state for your view. you is your player id (or null for a spectator). |
{t:"delta", cells, hidden} |
Cells that changed within your view; hidden lists coords that left your sight. |
{t:"players", players} |
Roster: id → {colour, bot}. |
{t:"over", winner} |
Game finished. |
{t:"pong"} |
Keepalive reply. |
Gameplay constants live at the top of game_server.erl and board.erl:
| Constant | Where | Default | Meaning |
|---|---|---|---|
TICK_MS |
game_server | 150 |
Simulation tick period (ms). |
VISION |
game_server | 2 |
Sight radius (Chebyshev) around owned cells. |
START_TROOPS |
game_server | 20 |
Garrison you get on claiming a base. |
BUILD_COST |
game_server | 12 |
Troops to raise a hill. |
BASE_COST |
game_server | 25 |
Troops to fortify land into a new base. |
PARA_COST / PARA_TROOPS |
game_server | 15 / 10 |
Troops a base spends vs. troops that land. |
DEFAULT_W / DEFAULT_H |
board | 20 / 15 |
Board size. |
NEUTRAL_GARRISON |
board | 10 |
Troops sitting in an unclaimed base. |
The number of presses to complete a base is configurable at startup (it
doesn't change the total BASE_COST, just how many clicks spread it out) — via
bin/exbattle --base-presses N, the EXBATTLE_BASE_PRESSES env var, or the
base_presses app env key (default 2, minimum 1).
The default grid for new games is likewise configurable — via
bin/exbattle --grid hex|square, the EXBATTLE_GRID env var, or the grid
app env key (default hex). Players can still override it per game with the
lobby checkbox before a game id is first created.
The scripts/ directory holds raw-WebSocket Python clients (standard library
only) that drive the server and assert on the results — one per milestone. Boot
the node, then run a script:
rebar3 shell # in one terminal
python3 scripts/ws_m6.py # in anotherws_m6.py covers the latest features: hex-grid adjacency (odd-r offset flow)
and paratroop drops (landing, base cost, out-of-vision rejection).
Tip: if a test can't bind port 8080, a stale node is still running. Clear it with
pkill -f beam.smp; lsof -ti tcp:8080 | xargs kill -9.
Milestones M0–M6 are complete: real-time flow + combat, fog of war,
reconnect, bots, digging, and (M6) hex grids + paratroops. See
plans/feasibility-and-plan.md for the full history and what's next —
gunboats and pipelines are deferred to a future pass, since they need a
naval / mobile-unit system.
xbattle is a concurrent, multi-player strategy game for the X Window System, written by Steve Lehar and Greg Lesher in the early 1990s. It spread across university Unix labs and networked workstations, where several players would fight in real time over a shared, gridded battlefield.
Rather than clicking individual units, you commanded whole territories by setting the direction troops should flow, and armies streamed continuously across cells — accumulating, spilling into neutral ground, and grinding against each other at the borders. It shipped with square and hexagonal boards, fog of war, varied terrain (hills, seas, bases), and a grab-bag of tactical toys: digging and building, gunboats, paratroops, and pipelines. Those ideas — flow instead of clicks, terrain that matters, and simultaneous real-time play — are exactly what this project sets out to recreate for the browser.
See also here for more on the original.
MIT — see LICENSE.