Quarantine Wars
Multiplayer location based strategy game for friends — played on a
live map of the real world.
Node.js · TypeScript · GraphQL · React Native · Redis · AWS ·
Terraform
2019–2022 · four repositories · ~18,000 lines of application code
Disclaimer - AI was used to generate the copy for this page.
Overview
Quarantine Wars is a persistent multiplayer strategy game where the
board is the real world. Players occupy their actual GPS position,
capture ~2,900 real-world control points across the UK, and deploy
bombers, missiles, mobile AA, airlifts and paratroopers against each
other in continuous real time. There are no turns and no
matchmaking — one authoritative simulation runs permanently, and
clients watch the slice of it they can see.
I designed and built the system end to end — the simulation server,
the API layer, the mobile client, and the AWS infrastructure it runs
on.
Architecture
Four services, deliberately separated so that simulation cost and
fan-out cost scale independently.
┌──────────────────┐ subscriptions ┌──────────────────┐
│ React Native │◄─────websocket────►│ qw-graphql │
│ (Expo) client │ │ API gateway │
│ react-native- │─────mutations─────►│ Apollo + Koa │
│ maps │ │ Auth0 / JWKS │
└──────────────────┘ └────────┬─────────┘
│
Redis pub/sub, state cache,
command queues (RPUSH)
│
┌────────▼─────────┐
│ qw-game │
│ simulation │
│ 20 Hz game loop │
│ TypeScript │
└──────────────────┘
qw-terraform — VPC · ALB/TLS · dockerised EC2 · S3+DynamoDB state
qw-game — the authoritative simulation. A 20 Hz
fixed-step loop advances a World of players, units,
projectiles and control points. Domain events
(new_bomb, asset_destroyed,
cp_captured, player_death) flow through an
EventEmitter, which decouples the physics from the
things that react to it — scoring, push notifications, the event
feed sent to clients.
qw-graphql — the client-facing gateway. Apollo
Server on Koa, authenticating Auth0 JWTs against a JWKS endpoint on
both HTTP requests and websocket handshakes. It runs its own 10 Hz
push loop that reads simulation state from Redis and fans it out per
player over GraphQL subscriptions. Player commands travel the other
way as Redis list pushes, consumed by the simulation on its next
tick — a read/write split that lets the game loop stay synchronous
and free of I/O contention.
qw-front-end — React Native / Expo. Renders the
live world onto react-native-maps with a custom map
style, GeoJSON visibility overlays, and animated explosion and
debris effects.
qw-terraform — the AWS estate as code: a VPC with
public and private subnets, an application load balancer with ACM
TLS termination and Route 53 records, a dockerised EC2 host, a
bastion, and S3-plus-DynamoDB remote state with locking, written as
composable modules. Terraform itself runs from a pinned Docker image
(forked from broadinstitute/docker-terraform) so that
every apply is reproducible. CI on Bitbucket Pipelines lints,
unit-tests, then builds and swaps the container over SSH.
Technical highlights
One spatial abstraction to solve three problems
The central design idea is that geohashes — short
strings that encode a lat/lon into a grid cell, where a shared
prefix means spatial proximity — solve three otherwise unrelated
problems in this system. Every entity is assigned a geohash at two
precisions each tick, together with the block of surrounding cells
it could interact with.
1. Collision broad-phase. A naive real-time
simulation compares every entity against every other one — O(n²),
and the loop starts missing its 50 ms budget as soon as a few
players deploy squadrons. Instead the world maintains geohash-keyed
buckets of entities, rebuilt once per tick and memoised, so a bomb
only tests against things in its own cell. Cost becomes proportional
to local density rather than total population.
2. Network interest management. The client reports
its map viewport as a set of geohashes; the gateway uses them to
filter the state broadcast, so a player receives only the entities
they can actually see. Zoomed-out players fall back to a
lower-frequency update with their events buffered and coalesced.
This is the difference between broadcasting the entire world to
every client twenty times a second and sending each client a few
kilobytes.
3. Fog of war, as a game mechanic. A player's line
of sight is the union of the geohash neighbourhoods of their own
units — so scouting is spatial, and a unit's contribution to
visibility falls out of the same data the physics already needs. The
client converts those geohashes into GeoJSON polygons and paints
them over the map.
The same primitive also shapes the client's static data. The ~2,900
control points are sharded at build time into 389 files keyed by
geohash prefix, with a two-level index; loading markers for a
viewport is a couple of dictionary lookups against the current cell
and its eight neighbours, rather than a scan.
Aligning the map with the simulation
Physics in latitude and longitude won't work as a degree of
longitude is a different distance at every latitude, so velocities
and collision radii. The simulation therefore works entirely in a
planar metric projection (EPSG:3785) via proj4,
converting to WGS84 only at the system boundary. Inside the world, a
vector is just a vector and a radius is just metres.
Behaviour trees for unit AI
Deployed units are autonomous — a bomber flies a waypoint route,
acquires a target, drops its payload and returns; a missile tracks
and intercepts. Rather than encoding this as state-machine
spaghetti, unit logic is composed from reusable behaviour-tree tasks
(movePlaneTo, dropBomb, target
acquisition), so new unit types are assembled from existing nodes
instead of written from scratch.
Stateless-by-default game loop
The simulation can run in either of two modes, switched by
environment variable. Held in memory it is fast; rehydrating the
world from Redis on every tick it is slower, but the process becomes
disposable — it can be redeployed or crash mid-battle without losing
the game. Building both let me measure the trade-off, with a
tick-timing endpoint and a sampling script recording loop delta, tick
rate and entity counts to CSV under load.
Engineering practice
-
TypeScript migration in place — the simulation
began as JavaScript and was progressively typed, starting with the
domain model (entities, vectors, collision) where type errors are
most expensive and hardest to spot in a loop running twenty times
a second.
-
Testing — Jest unit tests across the game model
and collision framework, plus separate integration suites against
the REST surface, run in CI.
-
Observability — structured logging with Bunyan,
tick-rate and per-phase timing instrumentation on the game loop,
and ALB access logs shipped to S3.
-
Layered structure — resolvers, services,
components and cache are cleanly separated behind module aliases,
and the GraphQL schema is stitched from per-feature
.graphql files rather than one monolith.
Stack
| Layer |
Technology |
| Simulation |
TypeScript, Node.js, node-gameloop, behaviour
trees, proj4, ngeohash
|
| API |
GraphQL (Apollo Server), Koa, subscriptions over Redis
pub/sub, Auth0 + JWKS
|
| Data |
Redis — state cache, pub/sub fan-out, command queues |
| Client |
React Native, Expo, Apollo Client,
react-native-maps, Expo push notifications
|
| Infrastructure |
AWS (VPC, ALB, ACM, Route 53, EC2, S3, DynamoDB), Terraform,
Docker
|
| CI/CD |
Bitbucket Pipelines — lint, test, containerised deploy |
Impact
The geohash work here became the basis for a production optimisation
I later delivered on a client project for an EV-charging app, where
the same tile-and-prefetch approach cut cold map load from over ten
seconds to under fifty milliseconds.