← Back to the gamebug or idea?Terrapin Trail//Code BibleIIhow the game works, archive to van
Reel 03 · 6 min read

The state machine

Screens as a pure view->HTML map, one state global, and input by event delegation.

The game is a state machine with about 21 screens. The current screen is a string, S.view. Rendering is a pure view -> HTML string map, and input is event delegation. Those two facts are the whole UI architecture.

Rendering: a switch that returns strings

●assets/game/game.js : 2328–2342trace ↗
2328  function viewHtml() {
2329    var v = S ? S.view : "title";
2330    switch (v) {
2331      case "title": return viewTitle();
2332      case "tours": return viewTours();
2333      case "campaign": return viewCampaign();
2334      case "role": return viewRole();
2335      case "crew": return viewCrew();
2336      case "builder": return viewBuilder();
2337      case "van": return viewVan();
2338      case "stash": return viewStash();
2339      case "library": return viewLibrary();
2340      case "peak": return viewPeak();
2341      case "lot": return viewLot();
2342      case "confirm-drive": return viewConfirmDrive();
viewHtml() dispatches S.view to a viewX() builder. Every builder returns a string and touches no DOM.

Because each viewX() returns markup as a string, tests can render any screen headlessly and assert on it — that is why the test surface exposes viewHtml(). render() is the only function that touches the DOM, and only when there is one:

●assets/game/game.js : 2363–2382trace ↗
2363  function render() {
2364    var html = viewHtml();
2365    if (!HAS_DOM) return; // headless: state is updated, nothing to paint
2366    if (S && STOPS && STOPS[S.stop]) prefetchSetlist(STOPS[S.stop]);
2367    if (!app) app = document.getElementById("app");
2368    app.innerHTML = bandify(html);
2369    var v = S ? S.view : "title";
2370    if (S && typeof S.restoreScrollTo === "number") { window.scrollTo(0, S.restoreScrollTo); S.restoreScrollTo = null; }
2371    // Only jump to the top when the screen actually changes -- an in-place update
2372    // (buying gear, toggling a mod, installing a van part...) leaves scroll alone.
2373    else if (v !== lastRenderedView) { window.scrollTo(0, 0); }
2374    lastRenderedView = v;
2375    if (v === "driving") startDriveAnim();
2376    if (v === "mini") startMiniLoop();
2377    // Snapshot the run whenever the player is at the lot hub (a clean save point).
2378    if (S && v === "lot") saveGame(S);
2379    updateTrailMap();
2380    updateMusicPanel();
2381    applyShortcutHints();
2382  }
render() swaps in band-fitting jokes (chapter 17), paints once, then fires per-view side effects: the drive animation, the mini-game loop, and the save-on-lot snapshot.

Note the details that matter: it early-returns headless; it only scrolls to top when the view changes (so buying gear doesn't jump you around); and it snapshots the save whenever you land on lot. Save-on-lot lives here, not in the lot actions, precisely because render() is the one choke point every screen passes through.

Input: one listener, a dispatcher

There are no per-button handlers. Buttons carry a data-action attribute; a single document click listener finds the nearest one and hands the action string to handle():

●assets/game/game.js : 3431–3445trace ↗
3431  function onClick(e) {
3432    var t = e.target.closest("[data-action]");
3433    if (!t) return;
3434    // first gesture unlocks audio; a soft tick gives every button tactile feedback
3435    Sound.init(); Sound.resume(); Sound.tap();
3436    var a = t.getAttribute("data-action");
3437    handle(a);
3438  }
3439
3440  function handle(a) {
3441    // navigation
3442    if (a === "go-title") { S = S || {}; S.view = "title"; render(); return; }
3443    if (a === "go-about") { S = S || {}; S.view = "about"; fetchBlogPosts(); render(); return; }
3444    if (a === "start-role") { S = S || {}; S.view = "role"; render(); return; }
3445    if (a === "go-tours") { S = S || {}; S.tourPreview = TOUR_YEAR; S.view = "tours"; render(); return; }
onClick unlocks audio on the first gesture, then routes the data-action to handle() -- a long dispatcher namespaced by prefix (go-, pick-, camplen-, ...).

Actions are namespaced by prefix (pick-, mod-, gearbuy-, install-, ev-, van-, set-pace-, …), and plain <a> links carry no data-action, so the delegate ignores them and the browser just follows the href. Live text inputs (crew renaming, the van name) use a separate input listener that updates state without re-rendering, so focus isn't lost mid-keystroke.

The screen flow

title -> tour -> (campaign) -> 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; nearly everything returns there. A driving leg is its own sub-flow, covered next.

Key takeaways
  • S.view + viewHtml()'s switch is the entire screen system; builders are pure string functions.
  • render() is the single DOM choke point — and therefore the right home for save-on-lot and scroll logic.
  • Input is one delegated listener into handle(); actions are prefix-namespaced strings.