nine-fives · documentation
How the game works.
Contents
Game · overview
Backend survival on a request lane. Chain rented components left to right, run traffic waves through them tick by tick, get graded on survival times efficiency. One wallet carries across waves. The Go sim and the web board share one rules engine — a shape that survives in one survives in the other. Prices track measured cloud ratios (us-east-1, 2026); units are points, ratios are dollars.
Game · install & run
Prereqs: Go 1.23+, Python 3 for the board. golangci-lint only for make check. No other dependencies — stdlib plus one TOML parser.
Run the sim headless — same engine as the board:
Play it in a browser (intro at /, board at /play/):
Scenarios live in cmd/ninesim/testdata/ (campaigns, lesson track, interview panel) and are copied to web/scenarios/ by make. After touching sim/, rebuild the browser bundle — the web embeds the sim:
Game · loop
Scout — orders name the threat, the peak, and the crush ticks. Size for the peak, not the average. Build — place boxes; order is architecture. New boxes need warmup ticks. Run — ticks walk the chain; slow-mo, pause, and hold available. Debrief — verdict, worst tick, up to three earned fixes. Retry rewinds free. Hints cost $25, a few per wave.
Game · traffic
- Reads — most waves. Caches eat them; databases shouldn't see them.
- Writes — orders, payments. Caches ignore them. Only databases take them.
- Junk — bots. Filter at the edge or it drowns real capacity.
| Modifier | Effect |
|---|---|
hotKeyFrac | Share of reads on one key. Pins one box — spread can't help. |
staticFrac | Share that is images/JS/CSS (subset of reads). Peel at the edge. |
volatile | Data changes every tick. All copies serve stale; only primaries stay fresh. |
junkFrac | Share that is bots. Gateway first; elsewhere the mix is unknowable. |
down / host_down / regionDown | Injected death: a tick, a host, a region. Nothing survives it — judge the rest. |
region | Where the tick lives. Off-region serving counts half slow. |
Game · modes
| Mode | Rules |
|---|---|
| Tutorial unranked | 9 lessons, one mechanic each (DB ceiling → cache → writes → replica → shards → gateway → queue → volatile/CAP → full bill). Cleared shows a check ; never graded. |
| Campaign ranked | Stage ladder, one shared wallet: URL Shortener, Twitter Feed, Flash Sale, Ledger, YouTube. Stages unlock in order; best ranks kept. |
| Interview ranked | 4 hearings (hot slugs, write-heavy checkout, bot flood, mid-flood region death). One build clears all four. |
| Sandbox wallet off | Any scenario file on its own budget. No standings. |
A twelve-mission lesson track (toll-booth … graveyard) isolates one mechanic per file, each with a sim-proven reference build.
Engine · sim
Discrete-event loop, deterministic. Each tick offers requests to the first box; every box serves what it can and forwards the rest; unserved past the last box drops.
| Fate | Credit |
|---|---|
| served — answered cleanly | full |
| slow — late: cold starts, backlog releases, cross-region miles | half |
| stale — wrong: lagged replicas, volatile caches | zero |
| dropped — nobody could save it | against |
| filtered — junk turned away at the edge | ignored |
| Rule | Mechanic |
|---|---|
| Warmup | 0–4 ticks per component before it serves. Mid-wave joins pay full for partial cover. |
| Cascade | Fails >half of what it sees, 2 ticks running → dead for the wave; load lands on neighbors. Limiters and open breakers shed on purpose and never strain. |
| Order | Each box sees only what the previous one forwards. Gateway anywhere but first can't filter; cache behind the DB sees nothing. |
Engine · absorption math
How each box eats. dynamic = everything but static.
The split. Every tick is divided before any box sees it:
Pool tracking. The remainder is three pools — static reads, other reads, writes — plus a hot sub-counter. Hot-first boxes (LFU) eat hot before static; everyone else drains hot proportionally, so the remainder keeps the offered hot share.
Worked: cache(80) → DB(200), tick 200 all reads, 100 static, hot 40. Random cache serves 80; remainder 120 with hot decayed to 40 × 120/200 = 24 — parked on one backend if an LB sits behind. LFU serves the same 80 but eats the 40 hot first: remainder 120, hot 0. Same totals (200/0), different downstream. That pinning gap is the entire LFU-vs-random tradeoff.
| Component | Absorption per tick |
|---|---|
| SQL DB | serves min(offer, cap); pool refuses past conns — refused drops, over-cap forwards; slow = served/4 |
| Indexed DB | reads min(reads, (1+n)·cap), writes min(writes, cap/(1+n)) — e.g. cap 100 depth 2: 300 reads, 33 writes |
| Provisioned | min(reads, readCap) + min(writes, writeCap); either side drowns alone |
| Redis / Big | min(reads, cap), rest forwards; LFU eats hot first; volatile turns all served stale |
| NoSQL | all writes + min(reads, cap); stale = min(servedReads, writes/2); Strong: no stale, ever |
| Replica | min(reads, cap); fresh = cap − writes, rest stale — e.g. cap 100 vs 150 reads + 50 writes: 50 fresh, 50 stale, 100 forwarded |
| CDN / S3 | min(static, cap); S3 all slow; bill served/20 at edge, served/10 at origin |
| Queue | out = min(backlog + arrivals, drain) released as served; keep = min(rest, buffer); drop = rest; rage-quits past patience; released backlog arrives slow |
| Kafka | same machine, patience ≈ ∞ — pressure lingers instead of dying |
| Worker | min(offer, cap), all of it slow |
| VM / Container / Serverless | VM: min(dynamic, cap). Autoscale: boxes = ⌈dynamic/perbox⌉ clamped 1..max, bill boxes×5. Serverless: cold halves cap and all slow, bill served/2 |
| Balancer | routable = min(offer, routecap); excess drops up front, writes first — e.g. 500 in (400 reads + 100 writes) at cap 400: all 100 writes die, 400 reads route. Cold splits even, hot pins backend 0 |
| Shard | cold + writes even over N, hot pins shard 0, dead ranges drop outright |
| Fork | reads branch runs an all-read tick, writes branch an all-write tick; each branch banks released work as served before rejoining |
| Gateway | filters min(junk, cap) while the stream is pristine, routes everything it sees |
| Limiter | tokens += rate (to burst); serves to tokens; sheds the rest, destroyed not forwarded |
| Breaker | backend drops >half for Trip ticks → open Cooldown ticks (backend untouched, bills nothing) → one-tick probe |
Queue drill (conservation). Buffer 100, drain 40. Tick 200, room empty: 40 out, 100 held, 60 dropped (40+100+60 = 200 ✓). Next tick 30 arrive, 100 waiting: 40 out, 90 held, 0 dropped. Next tick 200 arrive, 90 waiting: 40 out, 100 held, 150 dropped (40+100+150 = 290 = 90+200 ✓). The invariant, always: served + dropped + filtered + held = offered + prior backlog. Served is clamped to the offer, forwarded is lower-bounded only (released backlog can exceed the tick), drops are trusted never clamped — over-claiming drops fails safe.
Pricing curve. price = base × (cap/baseCap)², floor base/4, saturates past ~1e8/tick. Upkeep is usually price/10. Copies: kth = k×base by display name (plain and indexed DB share "SQL DB" — they tax each other). Berths: ×0.85 upfront, upkeep full. Host proof: cache150 + db200 solo = 263+26 + 400+40 = 729; stacked on S = 223+340 + 66 upkeep + 44 rent = 673 — cheaper every day until the host-killer wave darkens the rack.
Engine · money
The pot only drains. Upfront bills once per piece before tick 0. Upkeep bills every owned piece on every wave, win or lose — re-runs bill again. Broke ends the run; unaffordable builds are refused before tick 0.
Why broke stays. Every ▶ spends, even an F — upkeep still ticks. Spam `11× DB` and you snowball broke. That's intentional: in production the bill arrives too. Design lab (Design → Configure wave → Okay → ▶) is the free place to experiment — sandbox, no wallet, no rank, `nf-design` persists — while Campaign's wallet stays meaningful.
| Shape | Who | Billing |
|---|---|---|
| Flat rent | VM | Fixed upfront + upkeep, idle or not |
| Committed rent | Reserved VM | Same box, half upkeep; pre-placed only |
| Breathing | Autoscale | No upkeep; per live box per tick (~1.7× reserved rent at volume) |
| Metered | Serverless | $0 to commit; per call; cold after idle (half cap, all late) |
| Usage | All serving bytes | Edge serves bill half the origin rate |
Quadratic pricing: twice the box, four times the price (minimum box price). Vertical scales, never cheap — that gap is where horizontal builds win.
Engine · scoring
Drops cap the score at 59. Under 40 fails outright. Filtered junk scores nothing either way. Overprovisioning survives and still loses rank.
Build · components
Roles: edge junk dies here · cache reads die here · store durable truth · flow shapes traffic.
| Component | Does | Weakness | WU |
|---|---|---|---|
| API Gateway edge | Turns away junk to cap, routes the rest | Only on a pristine stream — anything before it pollutes the mix | 1 |
| CDN edge | Absorbs static at scale, edge-cheap | Nothing dynamic passes any other way — it walks through | 2 |
| Rate Limiter edge | Serves to burst at refill rate, sheds the rest (429s) | Shed counts as dropped — you choose what dies | 0 |
| Redis cache | Absorbs reads to cap; stays warm across waves | Nothing for writes; volatile serves stale; TTL ticks pass all to origin | 2 |
| Redis LFU cache | Hot-first eviction — eats the burning key first | ~10% smaller bucket; same write-blindness | 2 |
| Big Cache cache | Huge dumb read room, ~20% cheaper than Redis | Random eviction only; cold every wave | 1 |
| SQL DB store | Serves everything to one ceiling | One box, one ceiling; pool refuses past its limit (drops, not forwards) | 0 |
| Indexed SQL DB store | Depth n: reads (1+n)×, writes ÷(1+n), same price | Deeper reads wider, taxes harder; write floods still kill it | 0 |
| Provisioned SQL DB store | Read and write ceilings bought and billed apart | Only pays on skewed mixes | 0 |
| NoSQL DB store | Unbounded writes, reads to cap | Every 2 writes stale 1 read; Strong fixes at double upkeep | 1 |
| Read Replica store | Offloads reads to cap — never the truth | Writes lag it toward zero; volatile stales everything | 4 |
| Object Store store | Huge slow static; cool/archive tiers | All slow; cool dies with any region; archive restores 2 ticks late | 0 |
| Queue flow | Buffers bursts, releases drain/tick (released scores served) | Floods fill it; past patience (~3 ticks) rage-quits; leftovers drop at wave end | 1 |
| Kafka flow | Log that never rage-quits | Slow drain; unclaimed tail drops at wave end | 1 |
| Worker flow | Cheap bulk compute for backlogs | Everything late — half credit | 1 |
| VM / Reserved flow | Fixed room, flat rent, live instantly | Bleeds quiet waves; reserved halves upkeep, pre-placed only | 0 |
| Container / Autoscale flow | Boxes breathe with load, 1 to max | ~1.7× reserved standing rate; a tick to start | 1 |
| Serverless flow | Near-infinite cap, per call, $0 commit | Priciest per unit at volume; cold after idle | 0 |
| Load Balancer flow | Spreads across backends to a route cap; living absorb dead share | Saturates up front; hot pins one — below | 1 |
| Sharded Table store | Rows by key hash, no central choke | Dead range drops, nothing reroutes; hot pins shard 0 | 3 |
| Fork flow | Reads one road, writes another | Branches warm and die whole; slowest sets the gate | max |
| Breaker flow | Trips after N failing ticks, cools, probes | Same drops, cheaper failure | 0 |
Build · copies vs buying
The kth copy of a type costs k times base, capacity flat. ×3 = 6× base. ×5 = 15×. The dials (indexes, ttl) are free.
| Instead of another copy | Buy | Why |
|---|---|---|
| 3rd cache | Bigger size | 2× size = 4× price still beats a 3×-taxed copy |
| DB copies on mixed traffic | INDEXES dial / Provisioned | Reshape to the mix; free vs taxed |
| DB copies on writes | Shards | Copies share one ceiling shape; shards remove the choke |
| App boxes on spikes | Autoscale / queue | Buy time, not taxed room |
Money fixes capacity, not architecture — some waves need a different box, not a bigger one.
Build · balancers & shards
Balancer: routes to its route cap across live backends; overflow drops up front while backends idle. Cold splits even; hot pins backend 0. Dead shrink the pool, living absorb. Route cap bills per 50. Shards: same split, no central cap — LB wins small, sharding wins big. Same hot pin on shard 0. Difference: a dead shard's range drops outright, nothing reroutes.
| Balancer | Shard | |
|---|---|---|
| Saturates | Route cap, drops up front | Never centrally |
| Hot key | Pins one backend | Pins shard 0 |
| Dead member | Living absorb | Range drops |
| Price | Fee + route pipe + backends | Router tax + every shard |
Build · tuning
| Knob | On | Effect |
|---|---|---|
ttl | Redis, LFU, Big Cache | Ticks between revalidations; cold ticks pass all to origin; 0 never expires |
indexes | Indexed DB | Depth n: reads (1+n)×, writes ÷(1+n); 1 is reference |
count | Stackables (dial 1–12) | N copies in series, kth-copy priced |
conns | SQL DB | Pool ceiling — past it requests refuse (drop), not forward |
readCap + writeCap | Provisioned | Two ceilings, billed apart |
consistent | NoSQL | Fresh reads at double upkeep, even volatile |
reserved | VM | Half upkeep, pre-placed only |
tier | Object Store | cool: half upkeep, dies with any region; archive: quarter upkeep, 2 ticks late |
patience | Queue (default 3) | Ticks held before rage-quit; Kafka never quits |
trip + cooldown | Breaker | Failing ticks to open; open ticks to probe; open bills nothing |
dead | LB, shards | Members dead from the start |
at | Anything | Mid-wave join; full price, live after warmup |
Build · regions & hosts
Berth boxes on a host for 15% off upfront (upkeep stays full). One M split four ways beats four S rents.
| Host | Slots | Rent | Upkeep |
|---|---|---|---|
| S | 2 | 40 | 4 |
| M | 4 | 70 | 7 |
| L | 8 | 120 | 12 |
First region free; each extra pays 100 + 10/wave. One dead host darkens every slot on it; a regional outage darkens every host in the region. Off-region serving counts half slow — miles, not death. Hostless components are exempt.
Reference · debrief
Generated from the run's own why-log. Verdict with rank, spend, served vs dropped; slow/stale/filtered lines; the peak and crush ticks; the worst tick with the first box's reason; up to three earned fixes. Stale answers mark the AP position; clean survival through death marks CP. Recognized structures (cache-aside, leader-follower, queue-backed workers, balanced tier, sharded writes, three-tier web, edge-cached origin) are named, never scored.
Reference · sim findings
Reference builds and the tuning that designed them — every shape below was earned by the sim, not asserted. Budget picks the winner, and the file says so.
Shortener: the cache dissolved
A/87 @ 942The mission: a URL shortener doing 100M requests a day — nearly every hit a read (~99%, hot 0.5–0.6, peak 5000 a tick). Started cache-3200 + 10 VMs at B/68 @ 159,150; tuning walked three eras — smaller brain, breathing muscle, no brain at all.
Under even browsing the cache was money for nothing: spend fell 159k → 942 while rank rose, and the build followed the numbers.
Ledger: twin primaries, no lies
B/61 @ 18kThe mission: a bank ledger where stale is a wrong balance — 70%-write payday burst, 40%-junk fraud flood, volatile audit, then fire kills the vault host mid-burst. Caches lied, followers idled.
So the shape bans copies by design: gateway plus twin provisioned primaries (360 reads / 500 writes) on vault-home and spare-east, nothing else. The standby doubles the bill — the fire is the receipt.
Feed: the cache is the hero
B/74 @ 6,452The mission: a social feed's read day ending in a celebrity storm — one volatile write tick, then two 900-strong hot-0.75 ticks. Uniform fronts drowned on the pin; LFU ate the burning key first.
Design: LFU + memcached brains on separate hosts, balancer over app boxes, strong NoSQL back — plus a cache-fire wave proving the failover. The replica probe failed on the primary's write ceiling.
Flash sale: two roads
C/58 @ 25kThe mission: Black Friday doors-open — cachable browsing, write-heavy checkout, scalper bots throughout. One road can't serve both: gateway-2200 + fork, reads elastic, writes queued into provisioned inventory.
F/9 @ 68k → C/58 @ 25k after the read path went elastic and the queue shrank 2000 → 1000 on the drain math.
YouTube: serve splits from transcode
B/60 @ 1.0MThe mission: a video platform doing a billion requests a day — 80%+ static bytes, one viral row, write-heavy uploads, a live finale the CDN can't touch, a region quake, a transcode flood. No single road survives it, hence the fork.
Reads: CDN-8000 + live LFU brain + S3 origin + twin regional tiers; uploads: queue-4000 into workers; one strong store behind both. The CDN shrank until lateness nearly won (2.4M → 849k).
Skew-day: the bill is the lesson
efficiency examThe mission: a library catalog — nine readers per writer, all day. 600 a tick at 9:1, nothing else moving: no hot keys, no static, no junk, no volatility.
A symmetric 600 box survives the traffic and dies on the bill; split 540/60 ceilings cost what they use. Survival without thrift caps below B.
Reference · cli
One budget, one loadout, waves in order — upkeep bleeds, backlog carries. A loadout sweeping every wave in batch is a balance bug, not a build.
A scenario file, whole — one-spike shape:
Empty [[waves.ticks]] tables are quiet ticks — backlog drains into them. Full reference files live in cmd/ninesim/testdata/: campaigns, lesson track, interview panel.