- 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>
7.7 KiB
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: truescene 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.labelgrants a white label,skipTooverrides 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"+maxRestartson 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-levelscore/labelgrant 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)
- Boot: fetch scenes.json → preload manifest.
- 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, viapreload="auto"+load(). Alsofetch()+ blob-URL cache for clips ≤ ~8MB to guarantee gapless swap. Show % bar on boot for first 3 clips only. - Clock: drive QTE timing off
video.currentTimepolled inrequestAnimationFrame(NOT wall-clock setTimeout — survives decode stutter/tab throttling). - Input: keydown (arrows + space/enter) and touch (swipe quadrants + tap = action). Map gamepad later (Gamepad API, d-pad + A). Ignore key auto-repeat.
- 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:falseper window for hard mode later. - HUD: lives as record icons top-left, scene name bottom-left (debug only), score = scenes cleared × 1000 minus deaths × 100, arcade font.
- States: ATTRACT (loop attract.mp4 or title card + "PRESS ANY KEY", high-score from localStorage) → PLAYING → DEATH → GAMEOVER → WIN (win.mp4 → credits → attract).
- 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.