BLACK SKIES ARCHITECTURE SERIES · PART 3 OF 8 · 14 MIN

Redis Coordination for Real-Time Tile State

This post is part of the Black Skies architecture series; the series hub is the thesis post, which argued that a 10,000-player battle can play the same as a 100-player skirmish because consistency versus availability is a design choice. This post is about the machinery inside a single tile's commit path: how authoritative state gets committed, delivered, and made durable every 500 milliseconds, with none of those three jobs allowed to interfere with the others.

The shape of the problem

A tile is a CP island: a bounded hex region of 2,401 H3 cells, processed by exactly one owner, ticking at a fixed 2 Hz. Every 500ms the tile processor (the tile's master, in the lifecycle post's terms) resolves the queued intents, applies the game rules, and produces a batch of committed events.

Three things have to happen to that batch, and they have wildly different requirements.

It has to commit atomically under the tile's current ownership epoch, so a stale processor that lost a partition fight can never corrupt state.

It has to reach every watching client fast. The budget is 100ms from commit to client, with roughly 600 bytes per client per tick on the wire. This is the highest-volume traffic in the system and it tolerates loss, because the next tick is 500ms away and gaps are repairable.

It has to survive losing Redis entirely. Durability is a slow-path concern measured in seconds of recovery window, not milliseconds of delivery latency.

The naive design is one Redis cluster doing all three, with the tile processor publishing its own events. That design couples everything to everything: a throttled durability write slows delivery, a delivery storm starves the commit path, and a crash between commit and publish silently drops events. The actual design splits the work across two Redis clusters and three small services, each with exactly one job.

CLIENTS intents in GATEWAY validates · admits DYNAMODB LEDGER cards spend here first TILE PROCESSOR simulates · commits intents COORDINATION CLUSTER {tile:X}:owner · epoch, address, TTL {tile:X}:stream · committed events {tile:X}:snapshot · length-triggered one hash tag, one slot, atomic function 1 write per tick, epoch-fenced BRIDGE consumer groups, republishes FAN-OUT CLUSTER sharded pub/sub, lossy OK RELAY WebSockets · viewports hot path cold path: snapshot + tail (viewport, gap, restart) 10,000 CLIENTS ≤100ms commit-to-client CHECKPOINTER dumbest service, on purpose reads snapshot DYNAMODB durability · recovery window AURORA · ownership authority mints epochs at transitions only, never on the per-tick path read a failure's blast radius off the arrows: DynamoDB throttles → the checkpointer lags, nobody else notices. a fan-out node dies → one tick of frames lost, relays repair from the stream.
The whole layer on one chart: six actors, one job each. Read any failure's blast radius straight off the arrows.

Three keys, two clusters

The coordination cluster holds three keys per tile:

The hash tag keeps all three on one slot, which is what makes an atomic server-side commit possible.

The key names are deliberately boring. An earlier draft baked the ownership epoch into the stream name as a safety mechanism, so a stale primary would be writing to a stream nobody read. That safety now lives in the events themselves: every committed event carries the epoch that committed it, and every consumer inspects it. Names stay stable, discovery stays trivial, and the fencing guarantee moves to where it can actually be enforced.

The owner key is a hash holding the current owner's address and epoch, with a three-minute TTL. There is no separate refresh timer: every call into the tile's fenced functions bumps the TTL as a side effect of the call, so an actively ticking tile refreshes it with every 500ms commit, and for a healthy tile it never expires. The stream carries the same three-minute TTL, refreshed by the same calls, and the width of that window is the point. Failure detection is SWIM's job on a clock of seconds, and authority is the epoch's job, so the TTL is neither: it is the janitor. A failover completes in a tick or two, orders of magnitude inside the window, so no recovery path can ever race an expiry. The only state the TTL ever collects belongs to a tile that has been dead for three full minutes, past every detection and promotion clock, whose durable record already lives in DynamoDB, and whose Redis keys are the last thing left to clean up. Redis does not mint epochs. Aurora does, and that path belongs to the ownership post.

The stream is the append-only log of committed events, one batch per tick.

The snapshot is the full serialized tile state, written by a second epoch-fenced function on the same slot whenever the stream grows past a length threshold. The trigger is stream length, not the calendar, so a tile compacts exactly as often as it produces events: a saturated battle snapshots frequently, a quiet border tile barely at all. The function enforces its own invariants atomically: it rejects sequence regressions, rejects snapshots claiming ticks that were never committed, and stamps the blob with a checksum and the owner's contact. Replay cost on recovery is bounded by the length threshold, not by how long the battle has been running.

Trimming is where a lazy design loses data politely, so it is not a separate job that can drift out of step with the snapshot. The same fenced call that writes the snapshot trims the stream to the snapshot's sequence, one atomic step on one slot, and the floor never moves below the latest snapshot. The classic gap closes by construction: no schedule skew can run a trim ahead of the snapshot that was supposed to cover it, because the cut and the coverage are the same write. Anything the trim removes is already folded into state the snapshot vouches for, so a reader never finds a hole, only a shorter replay. And the function is epoch-fenced like every other write on the slot, so a stale owner can neither snapshot nor trim, and a failover can only ever delay compaction, never authorize a bad cut. That is the sentence every recovery path in this series leans on: snapshot plus tail is always the whole tile.

trimmed: already folded into the snapshot ← older · the committed stream · newer → snapshot_seq = the floor never below the latest snapshot SNAPSHOT FUNCTION · epoch-fenced fires on stream length, not the clock one atomic call on one slot: write the snapshot, trim to its sequence, never below it a stale owner can neither snapshot nor trim {tile:X}:snapshot full state through snapshot_seq, checksummed, epoch-stamped writes trims the cut and the coverage are the same write: snapshot plus tail is always the whole tile
Snapshot and trim are one fenced write: the stream is cut to the snapshot's sequence and never below the latest snapshot, so snapshot plus tail always reconstructs the tile.

The second cluster is the fan-out cluster, running Redis 7 sharded pub/sub. It exists because delivery is the highest-volume traffic in the system and it is at-most-once by design. Putting it on separate hardware means a battle's delivery load can never starve the coordination functions that matter most under stress. SPUBLISH hashes the channel to a slot, so broadcast traffic stays on the nodes that own it instead of flooding the whole cluster, which is the property that made sharded pub/sub viable here where classic cluster-wide PUBLISH would not be. Sharded pub/sub is the normal case; a tile drawing extreme spectator load escalates to a dedicated broker tier, the same move Discord makes for its largest servers, and that escalation is already written into the benchmark plan as a failure action rather than improvised when a battle makes the news.

One function per tile per tick

The commit itself is a Redis function, not an ad hoc Lua script shipped with every call. Once per tick, the tile processor invokes it with the tick's deltas, its epoch, and its SWIM contact information. The function compares the presented epoch against the owner hash and takes one of three paths.

commit(deltas, epoch, contact) one Redis function, once per tick, compared against {tile:X}:owner epoch = owner · the normal case refresh the 3-minute TTLs, append the batch. done. epoch > owner · a new owner's first commit install epoch + SWIM contact and append, atomically: takeover and first write with no window between them epoch < owner · a stale writer reject the entire batch. the reply carries the current epoch and the new owner's address: the handoff rides the rejection
One atomic server-side function, three paths. Ownership enforcement, owner-hash maintenance, and the append share one slot, so no window exists between them.

Equal epoch is the normal case: refresh the TTLs, append the batch. A higher epoch is a new owner's first commit, so the function installs the new epoch and SWIM contact into the owner hash and then appends, which makes takeover and first write one atomic step with no window between them. A lower epoch means the writer is stale, and the entire batch is rejected.

The rejection is not a bare error. The failure reply carries the current epoch and the SWIM contact of the owner that replaced the writer, which is what lets a superseded processor redirect its clients and stand down instead of retrying into a wall. That handoff is the ownership post's subject.

The important property is that ownership enforcement, owner-hash maintenance, and the append all live inside one atomic server-side operation on one slot. There is no window where the stream holds events the owner hash did not vouch for, which is also why everything downstream of the stream gets to be simple.

The bridge

The processor does not publish its own events, because commit-then-publish from a single process has an unfixable ordering problem. Publish before commit, and a crash delivers events that never became real. Commit before publish, and a crash drops delivery with no record that anything was missed.

The bridge dissolves the problem instead of handling it. It is a small service that reads the committed streams and republishes each batch to the fan-out cluster with SPUBLISH. Each tile stream gets its own consumer group, read with a blocking XREADGROUP, BLOCK 0, so a committed batch is pushed to the bridge the moment it lands instead of waiting out a poll interval, and the bridge acknowledges an entry only after its publish succeeds. A crash between read and publish leaves the entry pending, and the restarted bridge picks up exactly the entries whose delivery was never confirmed. The bridge can only forward what is already committed, so the race does not exist by construction. This is the same instinct behind log-first architectures like Kafka: make the durable log the source of truth and let delivery be a consumer of it, never a sibling racing it.

The bridge is read-only against coordination and write-only against fan-out. It holds no game state and makes no decisions at all.

The checkpointer

Durability is DynamoDB's job, and the service that feeds it is deliberately not the bridge. Coupling DynamoDB write latency to fan-out delivery would let one throttled or cross-AZ write slow down how fast events reach relays, which is exactly the coupling this whole design exists to prevent.

The checkpointer is the dumbest service in the system, on purpose. On a cadence, it reads {tile:X}:snapshot from the coordination cluster and writes the blob to DynamoDB. It does not reconstruct state from the stream. It does not understand the game. If it falls behind or restarts, the worst case is a slightly older checkpoint, which means a slightly longer stream replay on cold-start recovery. No data loss, just a wider recovery window. That is the correct failure mode for a durability path: degrade the recovery clock, never the gameplay clock.

One number this design owes the reader plainly: losing the coordination cluster outright loses every tick since the last checkpoint, because the stream dies with it. That is the recovery point objective, and at 2 Hz with checkpoints every few seconds, the window is a handful of seconds of game time. Whether that loss is acceptable depends on what the ticks contained. For a prototype and for load testing, yes. For competitive combat, probably not. For anything touching the economy, no. The upgrade paths are known and priced: shorter checkpoint cadence, a durable log such as Kafka or LogDevice behind the stream, or write-through to DynamoDB before acknowledging a tick. The current architecture deliberately targets the first case and says so, and the one economy-adjacent write already lives outside the blast radius, because card exhaustion commits to DynamoDB before any tile ever sees the action.

The relay's two paths

Relays are the processes holding client WebSockets, and they connect to both clusters, for different reasons.

The hot path is sharded pub/sub from the fan-out cluster, every tick, at-most-once. Signal runs its online message delivery on the same bet: Redis pub/sub for the fast path, with durability handled entirely elsewhere.

The cold path is a direct read of snapshot plus stream tail from the coordination cluster. It fires when a player opens a new viewport, when a relay detects a sequence gap, or when a relay restarts. It is on-demand, not per-tick.

An earlier version of this design claimed relays only ever touch the fan-out cluster. That was too strong, and pretending otherwise would have hidden a real load source on the coordination cluster: gap repair during reconnect storms is precisely when coordination is already under the most pressure. Naming the cold path is what makes it measurable.

Before the tile: the admission ledger

Everything above assumed actions arrive at a tile as queued intents, and what happens to them first depends on which of two classes each belongs to, because the system deliberately runs two admission doors. Economic actions, card plays and anything else that spends or mints, are admitted at the gateway against a durable ledger in DynamoDB: the card or cooldown is spent there, conditionally and versioned, before any tile sees the intent, and the admission writes an artifact keyed by the client's action ID plus an admission ID. From that moment the action has a single terminal result it can reach, applied, fizzled, rejected, or redirected, and the result is replayable: a client that reconnects and asks about an action it sent gets the same answer every time, because the answer derives from the artifact, not from whichever process happens to remember. The spend is final at that conditional update, before the tile queue ever sees the intent. Any failure after it, a full tile queue, a target that moved, a tile that crashed mid-resolution, consumes the card and terminates as a fizzle, which the fiction reads as a misfire; rejected and redirected are reserved for actions that never cleared admission at all. One failure class stays invisible on purpose: a routing miss, where the gateway forwarded an admitted intent to a tile that just changed owners, is resubmitted once in-band by the gateway and never surfaces to the player. A card that moves a ship is still a card; it takes this door.

Plain movement takes the cheaper door on purpose. A travel intent is validated and rate-shaped at the gateway but never written to the ledger, because it has no economy to protect and its durable record already exists one layer down: an applied move is a committed event in the tile's replicated stream, and a blocked one resolves in the tile's arbitration as an in-fiction miss. For cards, the ledger is the record; for movement, the stream is the ledger. The split is also the cost model: durable admission scales with card plays, which hand size and cooldowns bound tightly, not with movement, which dominates intent volume in any game played at this rhythm. Card and cooldown authority living in the ledger rather than in tile state is why a tile crash can never refund or double-spend a card, and why this post's recovery story never has to mention the economy. Two doors, one shape: every action of either class still reaches exactly one outcome.

CARD PLAY · anything that spends or mints gateway DURABLE LEDGER · DynamoDB spend committed, conditional, versioned tile queue one replayable terminal result: applied · fizzled · rejected MOVEMENT · dominates intent volume gateway validate + rate-shape no ledger write: nothing economic to protect tile queue the committed stream IS its durable record the split is the cost model: durable admission scales with card plays (bounded by hands and cooldowns), never with movement. a tile crash can never refund or double-spend a card.
Two admission doors, one shape: economic actions spend durably before any tile sees them; movement's durable record is the committed stream itself.

The economic door follows a pattern payments infrastructure settled years ago: Stripe's idempotency keys made ambiguous retries safe by recording the result the first time and replaying that answer forever after, and the admission artifact is the same move with a card instead of a charge. The failure it prevents has a long name in this industry too: duplication exploits, the item dupes that have plagued MMOs whenever a crash or a retry window let the same spend land twice. Here the spend is durable before the intent exists anywhere else, the terminal result is cached against the action ID, and a retry can only ever fetch the answer it already received.

Six actors, one job each

The whole layer reduces to a short table of who touches what:

The gateway validates every intent, admits the economic ones against the durable ledger, and forwards the rest; it owns the economy and never touches tile state. The tile processor writes to coordination, through the Redis function, once per tick. The bridge reads streams from coordination and publishes to fan-out. The checkpointer reads snapshots from coordination and writes to DynamoDB. The relay consumes fan-out on the hot path and reads coordination on the cold path. Aurora sits off to the side as the ownership authority and never appears on the per-tick path at all.

None of them does two jobs. None of them couples fast-path latency to slow-path durability. When something breaks, the blast radius reads directly off the table. DynamoDB throttles: the checkpointer lags and nobody else notices. A fan-out node dies: a tick of notifications is lost and relays repair from the stream. A reconnect storm hits: the cold path loads the coordination cluster, and that load is isolated from the fan-out traffic serving everyone who stayed connected.

Proven versus hypothesized

The same honesty rule from the thesis post applies, and the ground truth under it has moved, so the split has three tiers now.

Exercised by the test suite: the function's fencing semantics, same-epoch append, install-on-greater takeover inside the commit call, fail-closed on a missing owner or an anonymous takeover, rejections that return the current owner's contact; the checkpointer's contracts and checksum detection; and the admission ledger's terminal-result and replay invariants. More than fifteen hundred test methods across the HEX and gateway suites sit behind this layer, with a compose-based integration stack underneath the unit tests.

Implemented but gated on live infrastructure, named as external proof gates in the build plan and machine-checked in a proof artifact where every gate currently reads pending: the same function path against a real Redis Cluster across masters, Aurora ownership with EXPLAIN-verified single-shard DML on live Limitless, a service-access proof that each of this post's actors runs on its own credentials with direct fan-out denied to tile processors, and evidence attached to the release benchmark rows themselves.

Hypothesis until the benchmark, unchanged: bridge throughput at saturated-tile output without falling behind the tick, fan-out pressure per relay against the 600 bytes per client per tick figure, cold-path read load during a reconnect storm, snapshot-and-trim cost inside the tick budget on a hot tile, and end-to-end commit-to-client p99 against the 100ms budget. Each has a failure threshold and a fallback. If the bridge cannot keep up, it shards by tile range. If cold-path reads contend with commits, recovery reads move to replicas. The architecture survives individual numbers failing. It does not survive pretending they have been measured.

What comes next

Everything in this post assumed one clean answer to a dirty question: who owns the tile right now? The ownership post is about the cases where that question briefly has two answers: Aurora epoch minting, the fencing model in full, and split-brain resolution without a global coordinator on the hot path.