nine-fives · docs overview

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.

$ git clone https://github.com/codetesla51/nine-fives && cd nine-fives $ go build ./... # sim + CLI + wasm bridge $ go test ./... # balance suite gates every commit $ make check # build + vet + gofmt + lint + test

Run the sim headless — same engine as the board:

$ go run ./cmd/ninesim campaign cmd/ninesim/testdata/youtube.toml · PASS youtube-1b-day [B/66]: served 187075, spent 1013291 of 3700000 $ go run ./cmd/ninesim batch cmd/ninesim/testdata/balance.toml $ go run ./cmd/ninesim describe my-loadout.toml

Play it in a browser (intro at /, board at /play/):

$ cd web && python3 -m http.server 8123

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:

$ make wasm # + make scenarios after tuning any testdata file

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.
ModifierEffect
hotKeyFracShare of reads on one key. Pins one box — spread can't help.
staticFracShare that is images/JS/CSS (subset of reads). Peel at the edge.
volatileData changes every tick. All copies serve stale; only primaries stay fresh.
junkFracShare that is bots. Gateway first; elsewhere the mix is unknowable.
down / host_down / regionDownInjected death: a tick, a host, a region. Nothing survives it — judge the rest.
regionWhere the tick lives. Off-region serving counts half slow.

Game · modes

ModeRules
Tutorial unranked9 lessons, one mechanic each (DB ceiling → cache → writes → replica → shards → gateway → queue → volatile/CAP → full bill). Cleared shows a check ; never graded.
Campaign rankedStage ladder, one shared wallet: URL Shortener, Twitter Feed, Flash Sale, Ledger, YouTube. Stages unlock in order; best ranks kept.
Interview ranked4 hearings (hot slugs, write-heavy checkout, bot flood, mid-flood region death). One build clears all four.
Sandbox wallet offAny 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.

1 offer Total → min(cap, Total) served → rest forwarded → next box 2 queue releases count as served · leftovers at wave end drop
FateCredit
served — answered cleanlyfull
slow — late: cold starts, backlog releases, cross-region mileshalf
stale — wrong: lagged replicas, volatile cacheszero
dropped — nobody could save itagainst
filtered — junk turned away at the edgeignored
RuleMechanic
Warmup0–4 ticks per component before it serves. Mid-wave joins pay full for partial cover.
CascadeFails >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.
OrderEach 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:

reads = Total × readFrac writes = Total − reads static = min(Total × staticFrac, reads) hot = reads × hotKeyFrac junk = Total × junkFrac

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.

ComponentAbsorption per tick
SQL DBserves min(offer, cap); pool refuses past conns — refused drops, over-cap forwards; slow = served/4
Indexed DBreads min(reads, (1+n)·cap), writes min(writes, cap/(1+n)) — e.g. cap 100 depth 2: 300 reads, 33 writes
Provisionedmin(reads, readCap) + min(writes, writeCap); either side drowns alone
Redis / Bigmin(reads, cap), rest forwards; LFU eats hot first; volatile turns all served stale
NoSQLall writes + min(reads, cap); stale = min(servedReads, writes/2); Strong: no stale, ever
Replicamin(reads, cap); fresh = cap − writes, rest stale — e.g. cap 100 vs 150 reads + 50 writes: 50 fresh, 50 stale, 100 forwarded
CDN / S3min(static, cap); S3 all slow; bill served/20 at edge, served/10 at origin
Queueout = min(backlog + arrivals, drain) released as served; keep = min(rest, buffer); drop = rest; rage-quits past patience; released backlog arrives slow
Kafkasame machine, patience ≈ ∞ — pressure lingers instead of dying
Workermin(offer, cap), all of it slow
VM / Container / ServerlessVM: min(dynamic, cap). Autoscale: boxes = ⌈dynamic/perbox⌉ clamped 1..max, bill boxes×5. Serverless: cold halves cap and all slow, bill served/2
Balancerroutable = 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
Shardcold + writes even over N, hot pins shard 0, dead ranges drop outright
Forkreads branch runs an all-read tick, writes branch an all-write tick; each branch banks released work as served before rejoining
Gatewayfilters min(junk, cap) while the stream is pristine, routes everything it sees
Limitertokens += rate (to burst); serves to tokens; sheds the rest, destroyed not forwarded
Breakerbackend 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.

ShapeWhoBilling
Flat rentVMFixed upfront + upkeep, idle or not
Committed rentReserved VMSame box, half upkeep; pre-placed only
BreathingAutoscaleNo upkeep; per live box per tick (~1.7× reserved rent at volume)
MeteredServerless$0 to commit; per call; cold after idle (half cap, all late)
UsageAll serving bytesEdge 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

$ score = kept × unspent × 100 · kept = (served − slow/2 − stale) / offered · unspent = 1 − spent/budget
F0+
C40+
B60+
A75+
S90+

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.

ComponentDoesWeaknessWU
API Gateway edgeTurns away junk to cap, routes the restOnly on a pristine stream — anything before it pollutes the mix1
CDN edgeAbsorbs static at scale, edge-cheapNothing dynamic passes any other way — it walks through2
Rate Limiter edgeServes to burst at refill rate, sheds the rest (429s)Shed counts as dropped — you choose what dies0
Redis cacheAbsorbs reads to cap; stays warm across wavesNothing for writes; volatile serves stale; TTL ticks pass all to origin2
Redis LFU cacheHot-first eviction — eats the burning key first~10% smaller bucket; same write-blindness2
Big Cache cacheHuge dumb read room, ~20% cheaper than RedisRandom eviction only; cold every wave1
SQL DB storeServes everything to one ceilingOne box, one ceiling; pool refuses past its limit (drops, not forwards)0
Indexed SQL DB storeDepth n: reads (1+n)×, writes ÷(1+n), same priceDeeper reads wider, taxes harder; write floods still kill it0
Provisioned SQL DB storeRead and write ceilings bought and billed apartOnly pays on skewed mixes0
NoSQL DB storeUnbounded writes, reads to capEvery 2 writes stale 1 read; Strong fixes at double upkeep1
Read Replica storeOffloads reads to cap — never the truthWrites lag it toward zero; volatile stales everything4
Object Store storeHuge slow static; cool/archive tiersAll slow; cool dies with any region; archive restores 2 ticks late0
Queue flowBuffers bursts, releases drain/tick (released scores served)Floods fill it; past patience (~3 ticks) rage-quits; leftovers drop at wave end1
Kafka flowLog that never rage-quitsSlow drain; unclaimed tail drops at wave end1
Worker flowCheap bulk compute for backlogsEverything late — half credit1
VM / Reserved flowFixed room, flat rent, live instantlyBleeds quiet waves; reserved halves upkeep, pre-placed only0
Container / Autoscale flowBoxes breathe with load, 1 to max~1.7× reserved standing rate; a tick to start1
Serverless flowNear-infinite cap, per call, $0 commitPriciest per unit at volume; cold after idle0
Load Balancer flowSpreads across backends to a route cap; living absorb dead shareSaturates up front; hot pins one — below1
Sharded Table storeRows by key hash, no central chokeDead range drops, nothing reroutes; hot pins shard 03
Fork flowReads one road, writes anotherBranches warm and die whole; slowest sets the gatemax
Breaker flowTrips after N failing ticks, cools, probesSame drops, cheaper failure0

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 copyBuyWhy
3rd cacheBigger size2× size = 4× price still beats a 3×-taxed copy
DB copies on mixed trafficINDEXES dial / ProvisionedReshape to the mix; free vs taxed
DB copies on writesShardsCopies share one ceiling shape; shards remove the choke
App boxes on spikesAutoscale / queueBuy 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.

BalancerShard
SaturatesRoute cap, drops up frontNever centrally
Hot keyPins one backendPins shard 0
Dead memberLiving absorbRange drops
PriceFee + route pipe + backendsRouter tax + every shard

Build · tuning

KnobOnEffect
ttlRedis, LFU, Big CacheTicks between revalidations; cold ticks pass all to origin; 0 never expires
indexesIndexed DBDepth n: reads (1+n)×, writes ÷(1+n); 1 is reference
countStackables (dial 1–12)N copies in series, kth-copy priced
connsSQL DBPool ceiling — past it requests refuse (drop), not forward
readCap + writeCapProvisionedTwo ceilings, billed apart
consistentNoSQLFresh reads at double upkeep, even volatile
reservedVMHalf upkeep, pre-placed only
tierObject Storecool: half upkeep, dies with any region; archive: quarter upkeep, 2 ticks late
patienceQueue (default 3)Ticks held before rage-quit; Kafka never quits
trip + cooldownBreakerFailing ticks to open; open ticks to probe; open bills nothing
deadLB, shardsMembers dead from the start
atAnythingMid-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.

HostSlotsRentUpkeep
S2404
M4707
L812012

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 @ 942

The 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 @ 18k

The 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,452

The 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 @ 25k

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

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

The 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

$ go run ./cmd/ninesim campaign <campaign.toml> $ go run ./cmd/ninesim batch <batch.toml> # standings + dominator check $ go run ./cmd/ninesim describe <loadout.toml> # card-by-card printout $ make wasm # rebuild browser bundle after touching sim/

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:

name = "one-spike" budget = 800 [[hosts]] name = "apphome" size = "M" region = "home" [[loadout]] component = "queue" buffer = 200 drain = 100 [[loadout]] component = "db" capacity = 100 host = "apphome" [[waves]] name = "spike" [[waves.ticks]] total = 10 [[waves.ticks]] total = 150 readFrac = 0.9 [[waves.ticks]]

Empty [[waves.ticks]] tables are quiet ticks — backlog drains into them. Full reference files live in cmd/ninesim/testdata/: campaigns, lesson track, interview panel.