Architecture (player-side)
Note: this page describes the original standalone project (a static site with one serverless leaderboard function). The version in this workspace adds a Python server; see chapters 11 and 13-18 of the guide.
Architecture (for developers)
How the game is built, for anyone extending it. This summarizes the game's own
in-repo docs — ARCHITECTURE.md, SUBSYSTEMS.md, and DATA-CATALOGS.md in
data/uploads/terrapin-trail-vercel/ — which remain the authoritative reference
(grep them by subsystem or function name).
Design philosophy
- No build step, no framework, no client dependencies. The whole game is one
hand-written IIFE in
assets/game.js, styled byassets/game.css, mounted by a 34-lineindex.html. Edit a file, refresh — that's the dev loop. - Data-driven. Content (roles, tours, events, items, crew, perks…) lives in
plain array/object catalogs at the top of
game.js. Adding content means adding a data entry, not writing control flow. - Same code in the browser and headless under Node, which makes it testable without a DOM.
- Small on the wire. A gzipped-size ratchet guards the payload.
File map
index.html mount point (#app), loads game.css + game.js, meta/icons
assets/game.js the entire game (~3,800 lines, one IIFE)
assets/game.css phosphor-CRT terminal styling
api/hall.js serverless leaderboard endpoint (Vercel function, uses ioredis)
api/_validate.js dependency-free submission validator (shared by endpoint + tests)
test/ headless tests + the size ratchet (Node's built-in runner)
One file, two runtimes
game.js is a single (function(){ "use strict"; … })(). Its first act:
var HAS_DOM = typeof document !== "undefined" && typeof window !== "undefined";
Everything branches on that. In a browser it wires listeners, boots to the
title screen, and paints via innerHTML. Under Node (tests) rendering, audio,
animation, and network all early-return, and the IIFE instead hangs a test surface
on globalThis.TerrapinTrail exposing the catalogs, the same lifecycle functions
the UI calls, a seedable RNG, and viewHtml() (a screen's HTML as a string).
No game logic is duplicated for tests.
State model
A single module-global S holds the entire run: current stats (cash, gas,
grub, van, vibes, tapes, blanks, cred), progress (stop, day,
miles), the crew, inventory (stash, vanmods), flags, the chosen tour and
campaign, and S.view — the string naming the current screen. State splits into
persistent run data (plain JSON, safe to save) and transient in-flight
screen data (stripped before any save).
Rendering & input
- Rendering is a pure
view → HTML stringmap.viewHtml()is aswitchover ~21 screen builders (viewTitle,viewLot,viewEvent, …); each returns a string and touches no DOM, so tests render any screen headlessly.render()paints it (browser only) and fires per-view side effects (drive animation, mini-game loop, save-on-lot snapshot). - Input is event delegation. Buttons carry a
data-action; one document click listener routes it to a bighandle()dispatcher (actions namespaced by prefix:pick-,mod-,buy-,ev-,van-,set-pace-, …). A separateinputlistener handles live renaming without re-rendering (so focus isn't lost). All interpolated text is escaped byesc().
Screen flow (the state machine)
title → tour → (campaign length) → role → builder → crew → (van) → LOT ⇄ …
LOT is the hub: shakedown / work / tape / van / stash / rest / drive
drive → driving → [events…] → (peak) → arrive → LOT finale → win / gameover
The lot is the hub; almost everything returns there. A leg
(driveOn → driving → resumeLeg) drains resources, queues events, maybe fires
the Peak, runs arrival perks, and lands back at the lot. checkDeath() routes to
gameover after anything that can zero Vibes or wipe the crew.
Data catalogs
Content lives in these; the engine reads them generically:
| Catalog | What it is |
|---|---|
ROLES |
playable characters + starting stats |
TOURS |
the ten tour years (each a list of real STOPS) |
VANMATES |
the crew roster (archetype id, name variants, trait, perk) |
LOT_ITEMS |
Shakedown vendors (apply(s) mutates, returns a line) |
HUSTLES |
work-the-lot money-makers (need(s), run(s)) |
EVENTS |
road events (choices[] with apply(s); optional needs gate) |
MODS |
builder perks (set stats/flags, never cash) |
VAN_PARTS |
installable upgrades; each contributes to the buff object |
CONSUMABLES |
stash items (use(s) or auto-trigger) |
PEAKS |
each role's once-per-run surge |
ACHIEVEMENTS |
badges (check(ctx)) |
VAN_STYLES |
creative-mode paint themes |
Grown past the docs. The in-repo
DATA-CATALOGS.mddescribes a single 1977 tour andSTOPS; the code now hasTOURS(1970–1979) plus campaign mode, and van-mates carry two name variants rolled per trip. The tables in this wiki reflect the current code.
Buffs & caps
recomputeBuffs(s) rebuilds s.buff from installed van parts and mutates the
shared, module-global CAP (max gas/grub). CAP is mutable, so it must be
re-derived whenever parts change and on save-resume.
Persistence (browser local storage, except the Hall)
Four independent stores, each guarded and wrapped in try/catch:
- Save/resume (
terrapin_save_v1) — snapshots the run whenever the lot is shown, stripping transient keys; restores re-runrecomputeBuffs. - Achievements (
terrapin_achievements_v1) — unlocked-badge map. - Sidebar open/closed and mute flags.
- Hall (
terrapin_hall_v1) — local fallback when there's no server.
The backend: the leaderboard
api/hall.js is a Vercel serverless function. It reads a Redis connection URL
from the environment (via ioredis) and stores JSON run records in a
terrapin:hall list. With no store configured it returns 503, and the client
transparently falls back to local storage — so the game is a fully playable static
deploy. Validation lives in the dependency-free api/_validate.js (allow-listed
fields, plausibility bounds, no-score rejection), which is unit-tested directly;
the handler adds a per-IP rate limit and an Origin check. The client (loadHall /
saveRun) probes the endpoint and flips shared-vs-local at runtime.
Live side panels
Three DOM elements live outside #app so they survive every re-render: the
left trail map, the right "Tonight's Tapes" panel (fetches the current
show's real Internet Archive recordings by date, browser-only, cached per date),
and the mute button. The About page pulls the latest dev-blog post live
from Blogger via JSONP and sanitizes it before injecting.
Determinism, RNG & testing
All randomness routes through one swappable rand via rnd/chance/pick/shuffle.
The headless surface's setSeed(n) swaps in a seeded PRNG for byte-for-byte
reproducible playthroughs. Tests (Node's built-in runner, zero dependencies):
test/rules.test.js— economy, mods, van upgrades, the Peak, catalogs, crew perks, achievements, save/resume, and a "every screen renders non-empty" sweep.test/playthrough.test.js— full seeded tours end to end.test/hall_validate.test.js— the leaderboard validator.test/sizes.js— the gzipped-size ratchet.
npm test # rules + playthrough, then the size ratchet
npm run test:size # just the size report
Extending it (quick recipes)
- New road event: append to
EVENTSwithtitle,text,choices[](eachapply(s)returns{ line, good }); addneeds:"<archetype>"to gate it on a van-mate; use{archetype}tokens for crew names. - New van-mate: add to
VANMATES(with aperk), wire the perk at its hook site viacrewHas, optionally add aneeds-gated event. - New achievement: append to
ACHIEVEMENTSwith acheck(ctx). - New tour year: append a
TOURSentry (real shows with Archive dates); everything else generalizes. - New screen: write a
viewX()builder, add acasetoviewHtml()and ago-xaction inhandle(), and add the view to the render-sweep test.
Conventions & gotchas
- Icons are HTML entities (that's why the codebase "uses emoji").
CAPis shared mutable state — always go throughrecomputeBuffs.- Never serialize transient state (keep function-bearing/in-flight fields out of saves).
- Route new randomness through
randso seeded tests stay deterministic. - Prefer a data entry over new code.
Back to Home.