Case study: rack
Context
The idea started as a throwaway line: “what if the homelab was the game.” The genre fit suspiciously well. Idle games are about numbers going up, managing resources, and occasionally watching things break. Homelabbing is about numbers going up, managing resources, and occasionally watching things break at 2am. The flavour text essentially wrote itself.
The constraint was intentional: no framework, no backend, no dependencies beyond Vite. Same philosophy as the rest of the homelab project family (waste-go, flit) — keep it small, keep it shippable, don’t add a build pipeline that outlives the weekend.
Problem
Build an idle game that:
- Captures the actual feel of homelabbing, not a generic “factory” reskin
- Has enough mechanical depth to be interesting past the first five minutes
- Ships as a fully static page — no server, no accounts, no cloud saves
- Doesn’t soft-lock the player in early game
The last point turned out to be the most interesting design problem.
Approach
1) The tick loop
The simulation runs on requestAnimationFrame but is time-delta based, not frame-based. This matters for the idle part of “idle game” — if the tab is backgrounded or the browser throttles RAF, game time still advances correctly on resume. Production is calculated from elapsed wall-clock time, not tick count.
The UI re-render is decoupled from the tick and runs on a slower ~1s interval. User actions (buy, deploy, fix, uninstall) trigger an immediate re-render via a direct callback rather than waiting for the next passive interval. One edge case: the passive rebuild is skipped entirely while a <select> has focus — an early bug caused the service dropdown to snap shut every second because a full innerHTML = '' re-render was destroying and recreating the DOM node with the picker open.
2) Per-host capacity — not a shared pool
The first version summed RAM and power across all owned hardware into a single shared pool. Clicking on a specific host before deploying was cosmetic — a service nominally “on the Pi” could draw from the Desktop’s power budget.
This was quietly wrong in a way that only surfaced when the UI started showing which host each service ran on. Fixed with hostCapacity(state, hardwareId) — checks the specific host’s budget, not the aggregate. The deploy dropdown now shows each service’s RAM/power cost up front and disables anything that wouldn’t fit the selected host, so the player knows before committing.
Each host gets a randomly drawn nickname on purchase (archimedes, the-basement, works-on-my-machine) from a pool of sci-fi/mythology/sysadmin-humour names. This was partly aesthetics, partly practical — “Old Desktop #2” is less interesting to look at than “blueprint.”
3) The soft-lock bug
The original crash-fix mechanic had a 60-second global cooldown shared across all services: “have you tried turning it off and on again” as a deliberate throttle. Fine in theory; broke in practice.
Early game you have 2-3 RAM slots, one host, no spare hardware to buy. If two services crash in the same minute — very plausible given the Poisson event schedule and climbing crash rates — the second stayed dead for up to a minute with zero income and nothing to do. Player had no points to buy hardware, couldn’t fix the second service, couldn’t deploy a replacement. Reported directly: “nginx crashed, can’t install a new one, same with pi-hole.”
Fix: moved fixCooldownUntil onto each DeployedService instance rather than GameState. Each service has its own 20-second cooldown. A freshly crashed service can always be fixed immediately; the throttle only prevents re-fixing the same instance repeatedly. The test suite has an explicit regression test for this: “one service’s fix cooldown must not block fixing a different crashed service.”
4) Random events
Events fire on a Poisson-ish schedule — the interval between events tightens as game time advances, so the early game is forgiving and the late game is busier. Six event types:
- Disk full — crashes a random running service
- Power outage — stops all production for N seconds (UPS upgrade shortens this)
- Kernel panic — crashes a random service and cancels any in-flight upgrade
- Fan noise complaint — flavour only, no mechanical effect, just vibes
- Update available — optional; applying it risks a brief outage but boosts production 10%
- rm -rf — rare; deletes a service outright, only fires before Backups I is purchased
The rm -rf guard was deliberate: losing a service you spent significant points on due to pure RNG feels bad once and then you never trust the game again. Gating it behind a purchasable upgrade means it’s a threat in the early game (when it has the least impact) and gone once you’ve stabilized.
5) Quirks
30% of deploys roll a per-instance trait from six options: Overclocked (faster, crashes more), Chatty (faster, uses more bandwidth — flavour only), Legacy (slower, more stable), Well-documented (slight bonus), Flaky (higher crash risk), Artisanal (slower but charming). Each modifies that instance’s ratePerSec and crash chance independently of the base service definition.
This answers a question that came up early: “do services do anything besides produce points?” The answer is mostly no, but quirks add enough per-instance variance that two Gitea deployments are not identical, and whether you got the Artisanal or the Overclocked one matters a little.
6) The ASCII rack view
One line per owned host, hostname left-aligned, then 2-letter service codes coloured green (running) or red (crashed), with dots for free capacity. This sits above the two-column layout and directly answers “what’s actually running on what” without requiring the player to scan the full service list.
Partial motivation: the per-host capacity fix made host selection important, which meant “which host is this on” became a question people would actually ask. The rack view answers it in one glance.
7) The balance sanity tests
The test suite has two layers: engine logic tests (deploy, capacity, fix cooldowns, prestige) and a “balance sanity” block that asserts properties of the static data in src/data/. The sanity block checks things like: every service pays back its deploy cost in under 5 minutes at base rate; hardware cost and capacity strictly increase with tier; no service’s base crash risk exceeds a ceiling.
These aren’t locking in specific numbers — they’re meant to catch a future data tweak that quietly breaks the early-game feel. The thresholds are documented inline so someone adjusting balance knows to update them deliberately rather than discover the test failing mysteriously.
One of these caught a real gap on first run: the crash risk ceiling assertion failed because the waste-go service (the highest-tier entry) had a base crash rate above what I’d assumed was the ceiling. Not a bug in the game logic — the game balanced fine — but it surfaced the assumption. Fixed by adjusting the test’s threshold and documenting why.
What’s missing
Service synergies — there’s currently no mechanical reason to run Uptime Kuma alongside other services beyond its own production rate. The monitoring angle is flavour only. A bonus to Gitea production when Gitea Actions is also running, or a crash probability reduction when Uptime Kuma is healthy, would make the service selection more interesting. It’s the obvious next depth layer.
More hardware tiers are defined (Full Rack, Colo) but the cost curve makes them aspirational in a standard playthrough. Prestige is there to reset the grind, but the post-prestige experience is currently identical to the first run with a multiplier. A second prestige run deserves something new.
Stack
Vanilla TypeScript, DOM rendering, requestAnimationFrame tick loop. Vite for dev and build. Vitest for tests (31 tests, no DOM — engine and balance only). No runtime dependencies. No backend. State in localStorage, autosaved on every render tick and on beforeunload.