← Back to the gamebug or idea?Terrapin Trail//Code BibleIIcompanion player wiki
← Further reading
Companion wiki

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)

← Home

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 by assets/game.css, mounted by a 34-line index.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 string map. viewHtml() is a switch over ~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 big handle() dispatcher (actions namespaced by prefix: pick-, mod-, buy-, ev-, van-, set-pace-, …). A separate input listener handles live renaming without re-rendering (so focus isn't lost). All interpolated text is escaped by esc().

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.md describes a single 1977 tour and STOPS; the code now has TOURS (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:

  1. Save/resume (terrapin_save_v1) — snapshots the run whenever the lot is shown, stripping transient keys; restores re-run recomputeBuffs.
  2. Achievements (terrapin_achievements_v1) — unlocked-badge map.
  3. Sidebar open/closed and mute flags.
  4. 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 EVENTS with title, text, choices[] (each apply(s) returns { line, good }); add needs:"<archetype>" to gate it on a van-mate; use {archetype} tokens for crew names.
  • New van-mate: add to VANMATES (with a perk), wire the perk at its hook site via crewHas, optionally add a needs-gated event.
  • New achievement: append to ACHIEVEMENTS with a check(ctx).
  • New tour year: append a TOURS entry (real shows with Archive dates); everything else generalizes.
  • New screen: write a viewX() builder, add a case to viewHtml() and a go-x action in handle(), and add the view to the render-sweep test.

Conventions & gotchas

  • Icons are HTML entities (that's why the codebase "uses emoji").
  • CAP is shared mutable state — always go through recomputeBuffs.
  • Never serialize transient state (keep function-bearing/in-flight fields out of saves).
  • Route new randomness through rand so seeded tests stay deterministic.
  • Prefer a data entry over new code.

Back to Home.