Skip to content

Latest commit

 

History

14 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

exbattle

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


What the game is

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.

Quick start

Requirements: Erlang/OTP 28 and rebar3 (brew install erlang rebar3).

git clone <this-repo> exbattle
cd exbattle
bin/exbattle            # or: rebar3 shell

Then open http://localhost:8080/ in one or more browser tabs.

Listen address & port

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 options

The 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.


How to play

Joining a game

  • 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.

Choosing the grid topology

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).

  1. Type a fresh game id (one nobody has created yet).
  2. Tick or clear the “hex grid (new games)” checkbox in the lobby — it starts ticked, matching the hex default.
  3. 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.

Controls

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.

Light / dark theme

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.

Where bases come from

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.


Architecture

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.erl is 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.

Supervision tree

exbattle_app
└── exbattle_sup (one_for_one)
    ├── games_sup   (simple_one_for_one)  → dynamic game_server children
    └── game_manager (gen_server registry: GameId → Pid)

Source layout

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.

Wire protocol

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 (dirn,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.

Tuning

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.


Testing

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 another

ws_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.


Roadmap

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.


About the original xbattle

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.


License

MIT — see LICENSE.

About

Reimagining of the classic X11 game xbattle

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages