Adds a Dockerfile, nginx config, docker-compose.yml, and a Gitea Actions workflow (mirroring goonk's), so rack builds and ships as its own image rather than being embedded in goonk-cv's build - deploys stay fully decoupled. Targets a subdomain, not a goonk.se subpath, to avoid needing path-prefix rewriting kept in sync across the build, edge proxy, and container. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7.3 KiB
rack
A browser-based idle/incremental game about running a homelab. Start with a single Raspberry Pi on a desk, incrementally expand into something that vaguely justifies its electricity bill.
Dark terminal aesthetic, dry flavour text, numbers go up, occasionally things go wrong.
Running it
npm install
npm run dev # dev server on http://localhost:5677
npm run build # production build
npm run preview # preview the build on http://localhost:4587
npm test # vitest run - engine + balance tests
No backend. Game state lives entirely in localStorage, autosaved on every
render tick and on tab close/hide.
How it works
- Hardware (
src/data/hardware.ts) provides RAM slots and power budget, enforced per host (not a shared pool). You can either unlock the next tier, or buy another unit of a tier you already own as a cheaper bridge — each extra unit of the same tier costs 40% more than the last (hardwareCost()inengine.ts). The starter Pi 4 is a one-time freebie and can't be rebought. - Services (
src/data/services.ts) run on hardware, consume RAM/power, and produce points/sec. They age (production creeps up over time) and can crash (probability rises with age, mitigated by the Cooling upgrade). The deploy dropdown shows each service's RAM/power cost up front and disables anything that wouldn't fit the currently selected host. - Upgrades (
src/data/upgrades.ts) are one-time persistent purchases: UPS shortens outages, Backups tiers reduce/reverse crash losses, Monitoring reveals crash risk, Redundancy runs a second instance of every service, Documentation is a small passive multiplier. - Random events (
src/engine.tsrollRandomEvent) fire on a Poisson-ish interval that tightens as the game clock advances: disk-full crashes, power outages, kernel panics, fan-noise flavour-only events, optional updates, and a rarerm -rfthat deletes a service outright (guarded once Backups I is bought). - Prestige: once total lifetime earnings cross 50,000 pts, a banner offers to decommission the rack — wipes hardware/services/upgrades but grants a permanent production multiplier and carries forward.
- Host nicknames (
src/data/hostnames.ts): every host is randomly christened on purchase from a pool of sci-fi/mythology/sysadmin-humour names (hal9000, shodan, the-basement, works-on-my-machine, ...), drawn without replacement so they double as unique identifiers wherever a host is named. - Quirks (
src/data/quirks.ts): each deploy has a 30% chance of rolling a per-instance trait (Overclocked, Chatty, Legacy, Well-documented, Flaky, Artisanal) that shifts that instance's production and crash risk. Shown as a badge on the service row and in the rack view's tooltip. - Rack view: an ASCII-art strip above the two main columns, one line per owned host, showing which services (as 2-letter codes) are deployed where and free capacity as dots — a compact answer to "what's actually running on what."
- Help & changelog: click the
?in the header, or press?anywhere outside a text input, to open an in-app overlay with a quick "how this works" list and the full version changelog (src/data/version.ts), mirroring../adventure's help-modal pattern.
Project layout
src/
types.ts game state & definition types
state.ts new game, save/load (localStorage), id allocation
engine.ts tick loop: production, aging, crashes, events, prestige
ui.ts DOM rendering, one full re-render per render tick
help.ts the ?-triggered help/changelog overlay
data/ static definitions: hardware/services/upgrades/events/
quirks/version (VERSION + in-app CHANGELOG entries)
style.css terminal theme
The simulation tick runs on requestAnimationFrame and is time-delta based
(not frame-based), so offline/backgrounded tabs still catch up correctly
when they regain focus. The passive UI re-render runs on its own ~1s
interval, decoupled from the tick — user actions (buy, deploy, fix,
uninstall) re-render immediately via a direct callback instead of waiting
on that interval, and it's skipped entirely while a <select> has focus so
the service-picker dropdown doesn't get yanked shut mid-render.
Deployment
rack is its own container — deployed independently of goonk/goonk-cv,
same pattern as every other project on this host: a Dockerfile builds
the static site and serves it via nginx, .gitea/workflows/docker.yml
builds and pushes that image to the Gitea registry on every push to
main, and docker-compose.yml is the host-side reference for running
it. The CI workflow needs a repo secret named TKNTKN (a Gitea PAT with
packages:write) — same requirement as goonk's own workflow, set under
Settings → Secrets.
Served from its own subdomain (e.g. rack.explewd.com), not a subpath of
goonk.se — that keeps the build completely vanilla (root-relative asset
URLs work as-is, no --base flag needed) and means the edge nginx just
needs one new server_name block or NPM proxy host pointing at whichever
port docker-compose.yml's HOST_PORT exposes (5677 by default — no
--base=/rack//path-prefix plumbing required, unlike a subpath
deployment would need).
docker build -t rack .
docker run -d -p 5677:80 rack # or: docker compose up -d
rack's own localStorage key (rack-save-v1) is namespaced regardless
of URL shape, so it won't collide with anything else on the same origin.
If a subpath deployment (goonk.se/rack) is wanted later instead, use
npm run build:subpath (vite build --base=/rack/) — it rewrites
dist/index.html's asset URLs to /rack/assets/.... That requires the
edge proxy to forward the /rack/... path through unchanged (not
strip the prefix) and the container to serve files at that same
sub-path — more moving parts to keep in sync than the subdomain route
above, which is why subdomain is the current choice.
Tests
src/engine.test.ts (Vitest) covers the core loop mechanics rather than UI:
deploy/capacity checks (including per-host isolation), hardware/upgrade
purchases and prerequisite gating, the fix-cooldown (per-service, not
global — see CHANGELOG for why that matters), uninstall refunds,
production/crash/outage ticking, and prestige. A separate balance sanity
block asserts properties of the static data in src/data/ rather than
specific numbers — e.g. every service pays back its own deploy cost in
under 5 minutes at base rate, hardware cost/capacity strictly increases
with tier, and no service's crash risk has crept past the intended
ceiling. These are meant to catch a future data tweak that breaks the
early-game feel, not to lock the current numbers in stone — adjust the
thresholds deliberately if you retune the balance.
Design decisions worth knowing
- Vanilla TypeScript + DOM, no framework — matches the rest of the
homelab-project family (
waste-go,flit) and keeps this dependency-light perPLAN.md. - Dev/preview ports are non-default (
5677/4587instead of Vite's5173/4173) because the other sibling projects on this machine claim the usual ports and everything within ±10 of them. - Starting balance is 15 pts, not 0 — the cheapest service (Nginx) costs 10, so a brand-new game can bootstrap its first deploy immediately without an awkward zero-income deadlock.