recordstoreguy/ENGINE.md
type-two 4f7d21df2b Engine v2 + full 45-scene graph: choices, random, endurance, hold, needledrop, white labels, replay queue, stinger
- scenes.json: every DESIGN.md scene wired — T1-T7, SH1-6, Misfile, 4 needle-drop
  alts, 7 white labels, boss volleys, REPLAY queue sentinel, win+stinger gate
- player.js: choice windows (branch/death-goto), random variants (sh2 tape),
  endurance restarts w/ tightening (sh4), hold windows (t4s5/t5s5), needledrop
  power-up branches, unprompted collects, mirror-on-replay, soft fail (t5s4),
  needle_skip transitions, stale-death-timeout guard
- clips renamed to DESIGN ids (s01->t1s1 etc); placeholder gen covers all new
  window types and never overwrites real footage (86 clips: 8 real + 78 gen)
- sheets: added missing sh4_action + t5s4_action prompts
- verified in browser: choice pass/timeout, collect, needledrop, endurance
  tighten+death, hold pass/release-fail, soft fail, replay queue w/ mirror,
  win->stinger with 7 labels, live-timing run of t1s1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 04:40:41 +10:00

7.7 KiB
Raw Permalink Blame History

RECORD STORE GUY engine spec — FMV QTE player

Pure static web app: no build step, no framework, no server logic. One HTML file, one JS file, one CSS file, one JSON scene graph, a folder of mp4 clips. Must run from python3 -m http.server locally and from nginx on the games VPS unchanged.

Files

engine/
  index.html      shell: <video>, overlay divs, HUD
  player.js       all logic (~400 lines, vanilla ES6, no deps)
  styles.css      arcade look: scanlines, CRT vignette, neon HUD
  scenes.json     the scene graph (authored data, engine reads it verbatim)
  clips/*.mp4     24fps 1280x720 h264+aac, named <scene_id>_<kind>.mp4

scenes.json schema

{
  "meta": { "title": "RECORD STORE GUY", "lives": 5, "version": 1 },
  "start": "s01_entrance",
  "scenes": {
    "s01_entrance": {
      "clip": "clips/s01_action.mp4",       // the scene's action clip
      "death": "clips/s01_death.mp4",       // played on fail, then retry scene
      "windows": [                           // 1..n QTE windows within the clip
        {
          "t": [3.2, 3.9],                  // seconds: window open..close
          "input": "right",                 // right|left|up|down|action (action = space/enter/click)
          "cue": true                        // show the flashing direction cue at t[0]
        }
      ],
      "onSuccess": "s02_aisles",            // next scene id, or "WIN"
      "checkpoint": false                    // true = death respawns here, not act start
    }
  }
}

Rules the engine enforces:

  • Input BEFORE the window opens = instant fail (Dragon's Lair rule — no button mashing). Grace: inputs in the 250ms before t[0] fail; earlier inputs are ignored (idle fidget).
  • No input by t[1] = fail. Wrong direction = fail.
  • Multiple windows per clip run in sequence; all must pass.
  • On fail: swap to death clip immediately (seek 0, play), then decrement lives. Lives 0 → game-over screen → attract. Otherwise retry the scene (or last checkpoint).
  • On success of last window: let action clip finish, then advance to onSuccess.

Branching / choice windows (NEW — required by THE SHIFT scenes in WORLDS.md)

A window may replace input with choices, mapping inputs to outcomes:

{ "t": [2.5, 4.0], "cue": true,
  "choices": {
    "left":  { "goto": "sh1_house_ok" },          // proceed/branch to scene
    "up":    { "death": "clips/sh1_death_pit.mp4",
               "goto": "misfile_dimension" },      // custom death THEN branch (pit!)
    "right": { "death": "clips/sh1_death_acct.mp4" } // custom death, normal retry
  } }

Rules: cue shows all choice glyphs side by side; no-input-by-close uses the scene's default death; a choices window must be the clip's last window; random: ["a","b"] on a scene picks one of two clip/outcome sets per attempt (for THE TAPE mimic gag). Choice windows default to longer durations (1.5s) — they're decisions, not reflexes.

v2 mechanics (BUILT 2026-07-20 — as implemented in player.js)

  • mirrorOk: true scene flag: scene is mirror-eligible. The engine mirrors it (CSS scaleX(-1) + left/right swap in inputs and cues) when it comes back in the replay queue — free variety, "the Dead Wax remembers... backwards".
  • Replay queue: onSuccess: "REPLAY" (on sh6) queues died-in scenes (unique, cap 8, choice/random scenes excluded), needle_skip.mp4 transition between each, then t7s1. Needledrop/collect are disabled during replays.
  • needledrop: {t, clip, bonus, label?, skipTo?} on a scene: optional action input during the flash branches to the alt clip; ignoring it is not a fail. label grants a white label, skipTo overrides onSuccess (t6s1 alt skips t6s2).
  • collect: {t, input, label} on a scene: unprompted input during a background flash grabs white label N (+250, toast); wrong unprompted inputs stay ignored. All 7 labels → stinger.mp4 plays after win.mp4.
  • Endurance: onMiss: "restart" + maxRestarts on a scene — misses replay the clip with window closes tightened 0.15s per restart; the Nth miss is a real death.
  • Hold windows: hold: true — input held from open (0.35s slack) to close.
  • Soft fail: failGoto: "id" on a scene — fail costs no life, plays the death clip if any, and respawns at the given scene (t5s4 platter lock → t5s1).
  • Choice deaths can branch: {death, goto} respawns at goto after the death (sh1 wrong file → Misfile Dimension). Scene-level score/label grant on clear (sh5_bonus). Choice window timeout uses the scene's default death.
  • NOT built (cut for now): difficulty tiers, gamepad, touch-hold windows.

Player loop (player.js structure)

  1. Boot: fetch scenes.json → preload manifest.
  2. Preloader: two <video> elements, A/B swap. While A plays scene N, B preloads scene N's death clip first (most likely next need), then N+1's action clip, via preload="auto" + load(). Also fetch() + blob-URL cache for clips ≤ ~8MB to guarantee gapless swap. Show % bar on boot for first 3 clips only.
  3. Clock: drive QTE timing off video.currentTime polled in requestAnimationFrame (NOT wall-clock setTimeout — survives decode stutter/tab throttling).
  4. Input: keydown (arrows + space/enter) and touch (swipe quadrants + tap = action). Map gamepad later (Gamepad API, d-pad + A). Ignore key auto-repeat.
  5. Cue rendering: at window open, flash a chunky neon arrow (direction) or ring (action) center-low on screen, CSS animation ~300ms pulse. Dragon's Lair barely cued; we cue by default — cue:false per window for hard mode later.
  6. HUD: lives as record icons top-left, scene name bottom-left (debug only), score = scenes cleared × 1000 minus deaths × 100, arcade font.
  7. States: ATTRACT (loop attract.mp4 or title card + "PRESS ANY KEY", high-score from localStorage) → PLAYING → DEATH → GAMEOVER → WIN (win.mp4 → credits → attract).
  8. Debug mode (?debug=1): timeline scrubber, windows drawn as colored bands on a bar under the video, current input state, frame-step keys (,/.), scene jump dropdown. This is how we'll tune every window against real Flow clips — build it FIRST.

Feel-tuning constants (top of player.js, tweak in one place)

const WINDOW_DEFAULT = 0.7;   // s — default window length if authoring shorthand used
const EARLY_GRACE   = 0.25;   // s before open where input = fail
const DEATH_HOLD    = 0.6;    // s to hold last death frame before retry fade
const CUE_LEAD      = 0.0;    // s to show cue before window opens (bump to 0.15 if too hard)

Placeholder clips — tools/make_placeholders.sh

Generates test clips so the whole game is playable before any Flow spend. For each scene in scenes.json: 5s 1280x720 clip, solid color per scene, huge scene-id text, and at each QTE window a white flash + 1kHz beep + arrow glyph burned in at exactly t[0]..t[1] (drawtext + sine + overlay enable='between(t,A,B)'). Death placeholders = red background + "DEATH sXX". ffmpeg one-liner per clip; script reads scenes.json with python3 -c json. Also make attract/gameover/win placeholders. Ship scenes.json with 3 authored placeholder scenes so the loop is provable end-to-end.

Post-processing real clips — tools/postprocess.sh

postprocess.sh <in.mp4|dir> <scene_id> <action|death> → trim silence/dead frames (args for in/out points), scale/pad to 1280x720, 24fps, -c:v libx264 -crf 20 -pix_fmt yuv420p -movflags +faststart, silent aac track if none (uniform streams = gapless swaps), output to engine/clips/ with canonical name, print duration so the author can set windows. Keep originals in production/raw/ untouched.

Deploy

rsync engine/ to botchat games VPS per deploy-map skill; it's static. Cache-bust scenes.json + player.js with ?v= query from meta.version. Verify live by curling the URL and loading in browser. ship-check before public link.