diff --git a/.claude/launch.json b/.claude/launch.json
index d3a7312..2694c06 100644
--- a/.claude/launch.json
+++ b/.claude/launch.json
@@ -4,8 +4,8 @@
{
"name": "rsg-engine",
"runtimeExecutable": "python3",
- "runtimeArgs": ["-m", "http.server", "8123", "-d", "engine"],
- "port": 8123
+ "runtimeArgs": ["-m", "http.server", "8321", "-d", "engine"],
+ "port": 8321
}
]
}
diff --git a/ENGINE.md b/ENGINE.md
index 73dcd03..06c3357 100644
--- a/ENGINE.md
+++ b/ENGINE.md
@@ -61,18 +61,28 @@ default death; a `choices` window must be the clip's last window; `random: ["a",
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 (spec'd in DESIGN.md — build after Phase 3)
-- `mirror: true` scene flag: CSS `transform: scaleX(-1)` on the video + swap
- left/right in input handling and cues. Free content doubling (Dragon's Lair trick).
-- Replay queue: scenes died-in (cap 8) replay before the final track.
-- Difficulty tiers: easy = skip scenes flagged `skippableOnEasy` + windows +0.2s;
- hard = `cue:false` everywhere after first visit.
-- `needledrop: {t, clip, bonus}` on a scene: optional action input during a flash
- window branches to an alternate clip; ignoring it is not a fail.
-- `collect: {t, input}` on a scene: unprompted input during a background flash sets
- a flag (white labels); wrong unprompted inputs outside windows stay ignored.
-- Endurance windows: `onMiss: "restart"` (replay clip, tighter windows), `maxRestarts`.
-- Hold windows: `hold: true` — input must be held from window open to close.
+## 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.
diff --git a/engine/index.html b/engine/index.html
index ba185ce..ecca937 100644
--- a/engine/index.html
+++ b/engine/index.html
@@ -12,9 +12,12 @@
+ ◉♪
+
RECORD STORE GUY
diff --git a/engine/player.js b/engine/player.js
index 50c6f7d..e9d8db0 100644
--- a/engine/player.js
+++ b/engine/player.js
@@ -6,18 +6,25 @@ const WINDOW_DEFAULT = 0.7; // s
const EARLY_GRACE = 0.25; // s before window open where input = fail
const DEATH_HOLD = 600; // ms on last death frame before retry
const CUE_LEAD = 0.0; // s cue shows before window opens
+const HOLD_SLACK = 0.35; // s after a hold window opens before "not holding" = fail
+const RESTART_TIGHTEN = 0.15; // s shaved off window closes per endurance restart
+const REPLAY_CAP = 8; // max died-in scenes replayed before T7
const GLYPH = { left: "◀", right: "▶", up: "▲", down: "▼", action: "●" };
const KEYMAP = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down",
" ": "action", Enter: "action" };
+const MIRROR = { left: "right", right: "left" };
const $ = (id) => document.getElementById(id);
const vids = [$("vidA"), $("vidB")];
let cur = 0; // index of active video
const G = {
- data: null, state: "ATTRACT", scene: null, sceneId: null,
+ data: null, state: "ATTRACT", scene: null, sceneId: null, retryId: null,
wi: 0, windows: [], lives: 0, score: 0, checkpoint: null,
+ labels: new Set(), deathLog: [], replay: null, // null = normal play, [] = replay mode
+ restarts: 0, pendingGoto: null, mirror: false,
+ held: new Set(), // inputs currently held down (for hold windows)
debug: new URLSearchParams(location.search).has("debug"),
blobs: new Map(), // url -> objectURL
};
@@ -27,15 +34,22 @@ async function boot() {
G.data = await (await fetch("scenes.json?v=" + Date.now())).json();
$("hiscore").textContent = "HI-SCORE " + (localStorage.rsg_hiscore || 0);
if (G.debug) { initDebug(); window.RSG = { G, input, vid }; }
- preload(G.data.scenes[G.data.start].clip);
- preload(G.data.scenes[G.data.start].death);
+ const first = resolve(G.data.start);
+ preload(first.clip);
+ preload(first.death);
attractPlay();
addEventListener("keydown", onKey);
+ addEventListener("keyup", (e) => { const d = KEYMAP[e.key]; if (d) G.held.delete(d); });
initTouch();
requestAnimationFrame(tick);
}
const vurl = (url) => url + "?v=" + (G.data?.meta?.version || 0); // cache-bust per data version
+const resolve = (id) => { // follow random -> concrete variant scene
+ let s = G.data.scenes[id];
+ if (s.random) s = G.data.scenes[s.random[Math.floor(Math.random() * s.random.length)]];
+ return s;
+};
async function preload(url) {
if (!url || G.blobs.has(url)) return G.blobs.get(url);
@@ -57,6 +71,7 @@ function playClip(url, onEnded) {
if (p) p.catch(() => {}); // pre-gesture autoplay rejection is fine
next.onended = onEnded;
next.classList.add("active");
+ next.classList.toggle("mirror", G.mirror);
vids[cur].classList.remove("active");
vids[cur].pause();
cur = 1 - cur;
@@ -66,6 +81,7 @@ const vid = () => vids[cur];
// ---------- scene flow ----------
function attractPlay() { // looping mood video behind the title screen
+ G.mirror = false;
const v = playClip("clips/attract.mp4", null);
v.loop = true;
v.muted = true;
@@ -74,27 +90,44 @@ function attractPlay() { // looping mood video behind the title screen
function startGame() {
G.lives = G.data.meta.lives;
G.score = 0;
+ G.labels.clear();
+ G.deathLog = [];
+ G.replay = null;
G.checkpoint = G.data.start;
vids.forEach((v) => { v.muted = false; v.loop = false; });
$("attract").classList.add("hidden");
playScene(G.data.start);
}
-function playScene(id) {
- const s = G.data.scenes[id];
- G.state = "PLAYING"; G.sceneId = id; G.scene = s; G.wi = 0;
- if (s.checkpoint) G.checkpoint = id;
+function playScene(id, restarts = 0) {
+ const parent = G.data.scenes[id];
+ const s = resolve(id);
+ G.state = "PLAYING"; G.sceneId = id; G.retryId = id; G.scene = s;
+ G.wi = 0; G.restarts = restarts; G.pendingGoto = null;
+ G.mirror = !!(G.replay && s.mirrorOk); // replayed scenes come back flipped — the Dead Wax remembers
+ if (parent.checkpoint) G.checkpoint = id;
+ const shave = restarts * RESTART_TIGHTEN;
G.windows = s.windows.map((w) => ({
- t: w.t[1] != null ? w.t : [w.t[0], w.t[0] + WINDOW_DEFAULT],
- input: w.input, cue: w.cue !== false, cued: false, done: false,
+ t: w.t[1] != null ? [w.t[0], Math.max(w.t[0] + 0.25, w.t[1] - shave)]
+ : [w.t[0], w.t[0] + WINDOW_DEFAULT],
+ input: w.input, choices: w.choices, hold: w.hold,
+ cue: w.cue !== false, cued: false, done: false,
}));
+ // needledrop / collect are one-shots, disabled during the replay gauntlet
+ G.nd = (!G.replay && s.needledrop) ? { ...s.needledrop, done: false, cued: false } : null;
+ G.col = (!G.replay && s.collect && !G.labels.has(s.collect.label))
+ ? { ...s.collect, done: false } : null;
hud();
+ cueHide(); cueNd(false);
playClip(s.clip, onSceneClipEnd);
// preload likely-next clips: this death, then success path
preload(s.death).then(() => {
+ if (G.nd) preload(G.nd.clip);
const n = s.onSuccess;
- if (n && n !== "WIN") { preload(G.data.scenes[n].clip); preload(G.data.scenes[n].death); }
- else preload("clips/win.mp4");
+ if (n && n !== "WIN" && n !== "REPLAY") {
+ const ns = resolve(n);
+ preload(ns.clip); preload(ns.death);
+ } else preload("clips/win.mp4");
});
if (G.debug) debugScene();
}
@@ -102,33 +135,87 @@ function playScene(id) {
function onSceneClipEnd() {
if (G.state !== "PLAYING") return;
if (G.wi < G.windows.length) return fail("clip ended mid-window"); // safety net
- G.score += 1000; hud();
- const n = G.scene.onSuccess;
- if (n === "WIN") return win();
- playScene(n);
+ sceneCleared();
}
-function fail(why) {
- if (G.state !== "PLAYING") return;
- G.state = "DEATH";
+function sceneCleared() {
cueHide();
+ G.score += 1000 + (G.scene.score || 0);
+ if (G.scene.label) grabLabel(G.scene.label, 0);
+ hud();
+ advance(G.pendingGoto || G.scene.onSuccess);
+}
+
+function advance(next) {
+ if (G.replay) return replayNext();
+ if (next === "WIN") return win();
+ if (next === "REPLAY") return startReplay();
+ playScene(next);
+}
+
+// ---------- replay queue ("the Dead Wax remembers") ----------
+function startReplay() {
+ const elig = (id) => {
+ const s = G.data.scenes[id];
+ return s && !s.random && !s.windows.some((w) => w.choices);
+ };
+ G.replay = G.deathLog.filter(elig).slice(0, REPLAY_CAP);
+ if (!G.replay.length) { G.replay = null; return playScene("t7s1"); }
+ replayNext();
+}
+function replayNext() {
+ G.state = "CUT";
+ G.mirror = false;
+ playClip("clips/needle_skip.mp4", () => {
+ const id = G.replay.shift();
+ if (id) return playScene(id);
+ G.replay = null;
+ playScene("t7s1");
+ });
+}
+
+function fail(why, deathClip, gotoAfter) {
+ if (G.state !== "PLAYING") return;
+ cueHide();
+ const s = G.scene;
+ // soft fail (t5s4 platter lock): no death, no life lost, just spat backwards
+ if (s.failGoto && !deathClip) {
+ G.state = "CUT";
+ if (s.death) return playClip(s.death, () => playScene(s.failGoto));
+ return playScene(s.failGoto);
+ }
+ // endurance (sh4): restart the clip with tighter windows until maxRestarts
+ if (s.onMiss === "restart" && G.restarts + 1 < (s.maxRestarts || 3)) {
+ G.state = "CUT";
+ return playScene(G.retryId, G.restarts + 1);
+ }
+ G.state = "DEATH";
+ if (!G.replay && !G.deathLog.includes(G.retryId)) G.deathLog.push(G.retryId);
if (G.debug) $("dbg-state").textContent = "DEATH (" + why + ")";
- playClip(G.scene.death, () => {
+ playClip(deathClip || s.death, () => {
setTimeout(() => {
+ if (G.state !== "DEATH") return; // scene was jumped/reset while death played
G.lives--; G.score = Math.max(0, G.score - 100); hud();
if (G.lives <= 0) return gameOver();
- playScene(G.sceneId); // retry the scene (checkpoints reserved for act restarts)
+ playScene(gotoAfter || G.retryId); // retry (or branch: misfile dimension)
}, DEATH_HOLD);
});
}
function win() {
G.state = "WIN";
+ G.mirror = false;
saveHi();
- playClip("clips/win.mp4", () => showEnd("YOU SURVIVED", "score " + G.score));
+ playClip("clips/win.mp4", () => {
+ if (G.labels.size >= 7)
+ return playClip("clips/stinger.mp4",
+ () => showEnd("RECORD STORE GUY WILL RETURN", "all 7 white labels · score " + G.score));
+ showEnd("YOU SURVIVED", "score " + G.score);
+ });
}
function gameOver() {
G.state = "GAMEOVER";
+ G.mirror = false;
saveHi();
playClip("clips/gameover.mp4", () => showEnd("GAME OVER", "score " + G.score));
}
@@ -153,13 +240,26 @@ function saveHi() {
// qteCheck is driven from THREE sources so throttling can't starve it:
// rAF (smooth when focused), video timeupdate (fires while playing even
// unfocused), and a 100ms interval backup.
+const flip = (d) => (G.mirror && MIRROR[d]) || d;
+
function qteCheck() {
if (G.state !== "PLAYING") return;
const t = vid().currentTime;
const w = G.windows[G.wi];
if (w) {
- if (!w.cued && w.cue && t >= w.t[0] - CUE_LEAD) { w.cued = true; cueShow(w.input); }
- if (t >= w.t[1]) { w.done = "failed"; fail("too late"); }
+ if (!w.cued && w.cue && t >= w.t[0] - CUE_LEAD) { w.cued = true; cueShow(w); }
+ if (w.hold) {
+ if (t > w.t[0] + HOLD_SLACK && t < w.t[1] && !G.held.has(flip(w.input)))
+ { w.done = "failed"; return fail("let go"); }
+ if (t >= w.t[1]) { // held all the way through = pass
+ w.done = "passed"; G.wi++; G.score += 100; hud(); cueGood(flip(w.input));
+ if (G.debug) debugScene();
+ }
+ } else if (t >= w.t[1]) { w.done = "failed"; return fail("too late"); }
+ }
+ if (G.nd && !G.nd.done) { // needle-drop flash: optional, ignoring is not a fail
+ if (!G.nd.cued && t >= G.nd.t[0]) { G.nd.cued = true; cueNd(true); }
+ if (G.nd.cued && t >= G.nd.t[1]) { G.nd.done = true; cueNd(false); }
}
if (G.debug) debugTick(t);
}
@@ -171,18 +271,54 @@ function input(dir) {
if (G.state === "ATTRACT") return startGame();
if (G.state === "END") return toAttract();
if (G.state !== "PLAYING") return;
+ G.held.add(dir);
+ const t = vid().currentTime;
+ // needle-drop: action during the flash branches to the power-up clip
+ if (G.nd && !G.nd.done && dir === "action" && t >= G.nd.t[0] && t <= G.nd.t[1]) {
+ G.nd.done = true; cueNd(false); cueHide();
+ G.score += G.nd.bonus || 500;
+ if (G.nd.label) grabLabel(G.nd.label);
+ hud();
+ G.state = "CUT";
+ const target = G.nd.skipTo || G.scene.onSuccess;
+ return playClip(G.nd.clip, () => { G.score += 1000; hud(); advance(target); });
+ }
+ // white label: unprompted input during a background flash — wrong guesses ignored
+ if (G.col && !G.col.done && dir === G.col.input && t >= G.col.t[0] && t <= G.col.t[1]) {
+ G.col.done = true;
+ return grabLabel(G.col.label);
+ }
const w = G.windows[G.wi];
if (!w) return; // windows all cleared, clip finishing
- const t = vid().currentTime;
+ if (w.hold) { // the hold press itself; wrong direction still kills
+ if (dir !== flip(w.input) && t >= w.t[0]) { w.done = "failed"; fail("wrong input"); }
+ return;
+ }
if (t < w.t[0] - EARLY_GRACE) return; // idle fidget: ignore
if (t < w.t[0]) { w.done = "failed"; return fail("too early"); } // Dragon's Lair rule
- if (dir !== w.input) { w.done = "failed"; return fail("wrong input"); }
+ if (w.choices) {
+ const c = w.choices[dir];
+ if (!c) { w.done = "failed"; return fail("wrong choice"); }
+ w.done = "passed"; G.wi++;
+ if (c.death) return fail("bad choice", c.death, c.goto);
+ G.pendingGoto = c.goto; G.score += 100; hud(); cueGood(dir);
+ if (G.debug) debugScene();
+ return;
+ }
+ if (dir !== flip(w.input)) { w.done = "failed"; return fail("wrong input"); }
// pass
w.done = "passed"; G.wi++; G.score += 100; hud();
cueGood(dir);
if (G.debug) debugScene();
}
+function grabLabel(n, points = 250) {
+ G.labels.add(n);
+ G.score += points;
+ hud();
+ toast("WHITE LABEL " + G.labels.size + "/7");
+}
+
// ---------- input sources ----------
function onKey(e) {
if (e.repeat) return;
@@ -193,14 +329,19 @@ function onKey(e) {
}
function initTouch() {
+ // ponytail: swipes+taps only; hold windows are keyboard-first for now,
+ // add touch-hold (touchstart..touchend spanning the window) if mobile matters
let sx, sy, st;
addEventListener("touchstart", (e) => {
sx = e.touches[0].clientX; sy = e.touches[0].clientY; st = Date.now();
}, { passive: true });
addEventListener("touchend", (e) => {
const dx = e.changedTouches[0].clientX - sx, dy = e.changedTouches[0].clientY - sy;
- if (Math.hypot(dx, dy) < 30 && Date.now() - st < 400) return input("action"); // tap
- input(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : (dy > 0 ? "down" : "up"));
+ let d;
+ if (Math.hypot(dx, dy) < 30 && Date.now() - st < 400) d = "action"; // tap
+ else d = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : (dy > 0 ? "down" : "up");
+ input(d);
+ G.held.delete(d); // touches don't hold
}, { passive: true });
}
@@ -208,10 +349,23 @@ function initTouch() {
function hud() {
$("lives").textContent = "●".repeat(Math.max(0, G.lives));
$("score").textContent = G.score;
+ $("labels").textContent = "◉".repeat(G.labels.size) + "○".repeat(7 - G.labels.size);
+}
+function cueShow(w) {
+ const c = $("cue");
+ if (w.choices) c.textContent = Object.keys(w.choices).map((d) => GLYPH[d]).join(" ");
+ else c.textContent = GLYPH[flip(w.input)];
+ c.className = "show" + (w.hold ? " hold" : "");
}
-function cueShow(dir) { const c = $("cue"); c.textContent = GLYPH[dir]; c.className = "show"; }
function cueHide() { $("cue").className = ""; }
function cueGood(dir) { const c = $("cue"); c.textContent = GLYPH[dir]; c.className = "good"; }
+function cueNd(on) { $("nd").className = on ? "show" : ""; }
+function toast(text) {
+ const el = $("toast");
+ el.textContent = text;
+ el.className = "show";
+ setTimeout(() => (el.className = ""), 1500);
+}
// ---------- debug mode (?debug=1) ----------
function initDebug() {
diff --git a/engine/scenes.json b/engine/scenes.json
index e0d75fd..0a52ff4 100644
--- a/engine/scenes.json
+++ b/engine/scenes.json
@@ -1,89 +1,167 @@
{
- "meta": {
- "title": "RECORD STORE GUY",
- "lives": 5,
- "version": 3
- },
- "start": "s00",
+ "meta": { "title": "RECORD STORE GUY", "lives": 5, "version": 4 },
+ "start": "intro",
"scenes": {
- "s01": {
- "clip": "clips/s01_action.mp4",
- "death": "clips/s01_death.mp4",
- "windows": [
- {
- "t": [
- 4.6,
- 5.4
- ],
- "input": "left",
- "cue": true
- }
- ],
- "onSuccess": "s02",
- "checkpoint": true
- },
- "s02": {
- "clip": "clips/s02_action.mp4",
- "death": "clips/s02_death.mp4",
- "windows": [
- {
- "t": [
- 4.2,
- 5.0
- ],
- "input": "up",
- "cue": true
- }
- ],
- "onSuccess": "s03",
- "checkpoint": false
- },
- "s03": {
- "clip": "clips/s03_action.mp4",
- "death": "clips/s03_death.mp4",
- "windows": [
- {
- "t": [
- 2.4,
- 3.1
- ],
- "input": "left",
- "cue": true
- },
- {
- "t": [
- 4.6,
- 5.3
- ],
- "input": "up",
- "cue": true
- }
- ],
- "onSuccess": "s04",
- "checkpoint": false
- },
- "s00": {
- "clip": "clips/intro.mp4",
- "death": "clips/s01_death.mp4",
- "windows": [],
- "onSuccess": "s01",
- "checkpoint": false
- },
- "s04": {
- "clip": "clips/s04_action.mp4",
- "death": "clips/s04_death.mp4",
- "windows": [
- {
- "t": [
- 6.0,
- 7.2
- ],
- "input": "left",
- "cue": true
- }
- ],
- "onSuccess": "WIN",
- "checkpoint": false
- }
+
+ "intro": { "clip": "clips/intro.mp4", "death": "clips/t1s1_death.mp4",
+ "windows": [], "onSuccess": "t1s1" },
+
+ "t1s1": { "clip": "clips/t1s1_action.mp4", "death": "clips/t1s1_death.mp4",
+ "windows": [ { "t": [4.6, 5.4], "input": "left" } ],
+ "onSuccess": "t1s2", "checkpoint": true },
+ "t1s2": { "clip": "clips/t1s2_action.mp4", "death": "clips/t1s2_death.mp4",
+ "collect": { "t": [2.0, 2.5], "input": "up", "label": 1 },
+ "windows": [ { "t": [4.2, 5.0], "input": "up" } ],
+ "onSuccess": "t1s3" },
+ "t1s3": { "clip": "clips/t1s3_action.mp4", "death": "clips/t1s3_death.mp4",
+ "windows": [ { "t": [3.0, 3.7], "input": "right" } ],
+ "onSuccess": "t1s4" },
+ "t1s4": { "clip": "clips/t1s4_action.mp4", "death": "clips/t1s4_death.mp4",
+ "windows": [ { "t": [3.0, 3.7], "input": "down" } ],
+ "onSuccess": "sh1" },
+
+ "sh1": { "clip": "clips/sh1_action.mp4", "death": "clips/sh1_death_pit.mp4",
+ "windows": [ { "t": [6.0, 7.5], "choices": {
+ "left": { "goto": "t2s1" },
+ "up": { "death": "clips/sh1_death_pit.mp4", "goto": "mf1" },
+ "right": { "death": "clips/sh1_death_pit.mp4", "goto": "mf1" }
+ } } ],
+ "onSuccess": "t2s1" },
+ "mf1": { "clip": "clips/mf1_action.mp4", "death": "clips/mf1_death.mp4",
+ "windows": [ { "t": [3.0, 3.7], "input": "down" } ],
+ "onSuccess": "sh1" },
+
+ "t2s1": { "clip": "clips/t2s1_action.mp4", "death": "clips/t2s1_death.mp4",
+ "windows": [ { "t": [3.0, 3.7], "input": "up" } ],
+ "onSuccess": "t2s2", "checkpoint": true },
+ "t2s2": { "clip": "clips/t2s2_action.mp4", "death": "clips/t2s2_death.mp4", "mirrorOk": true,
+ "windows": [ { "t": [2.2, 2.9], "input": "right" }, { "t": [3.6, 4.3], "input": "right" } ],
+ "onSuccess": "t2s3" },
+ "t2s3": { "clip": "clips/t2s3_action.mp4", "death": "clips/t2s3_death.mp4",
+ "collect": { "t": [1.2, 1.7], "input": "up", "label": 2 },
+ "windows": [ { "t": [2.4, 3.1], "input": "left" }, { "t": [4.6, 5.3], "input": "up" } ],
+ "onSuccess": "t2s4" },
+ "t2s4": { "clip": "clips/t2s4_action.mp4", "death": "clips/t2s4_death.mp4",
+ "needledrop": { "t": [1.5, 2.1], "clip": "clips/t2s4_alt.mp4", "bonus": 500 },
+ "windows": [ { "t": [3.2, 3.9], "input": "action" } ],
+ "onSuccess": "t2s5" },
+ "t2s5": { "clip": "clips/t2s5_action.mp4", "death": "clips/t2s5_death.mp4",
+ "windows": [ { "t": [2.5, 3.2], "input": "right" }, { "t": [3.8, 4.5], "input": "action" } ],
+ "onSuccess": "sh2" },
+
+ "sh2": { "random": ["sh2_thin", "sh2_thick"] },
+ "sh2_thin": { "clip": "clips/sh2_action_thin.mp4", "death": "clips/sh2_death.mp4",
+ "windows": [ { "t": [3.0, 4.5], "choices": {
+ "left": { "goto": "t3s1" },
+ "right": { "death": "clips/sh2_death.mp4" }
+ } } ],
+ "onSuccess": "t3s1" },
+ "sh2_thick": { "clip": "clips/sh2_action_thick.mp4", "death": "clips/sh2_death.mp4",
+ "windows": [ { "t": [3.0, 4.5], "choices": {
+ "right": { "goto": "t3s1" },
+ "left": { "death": "clips/sh2_death.mp4" }
+ } } ],
+ "onSuccess": "t3s1" },
+
+ "t3s1": { "clip": "clips/t3s1_action.mp4", "death": "clips/t3s1_death.mp4",
+ "windows": [ { "t": [1.8, 2.4], "input": "right" }, { "t": [2.8, 3.4], "input": "right" }, { "t": [3.8, 4.4], "input": "up" } ],
+ "onSuccess": "t3s2", "checkpoint": true },
+ "t3s2": { "clip": "clips/t3s2_action.mp4", "death": "clips/t3s2_death.mp4",
+ "windows": [ { "t": [3.0, 3.7], "input": "left" } ],
+ "onSuccess": "t3s3" },
+ "t3s3": { "clip": "clips/t3s3_action.mp4", "death": "clips/t3s3_death.mp4", "mirrorOk": true,
+ "windows": [ { "t": [2.2, 2.9], "input": "down" }, { "t": [3.6, 4.3], "input": "action" } ],
+ "onSuccess": "t3s4" },
+ "t3s4": { "clip": "clips/t3s4_action.mp4", "death": "clips/t3s4_death.mp4",
+ "needledrop": { "t": [0.8, 1.4], "clip": "clips/t3s4_alt.mp4", "bonus": 500, "label": 3 },
+ "windows": [ { "t": [1.8, 2.4], "input": "up" }, { "t": [2.8, 3.4], "input": "right" }, { "t": [3.8, 4.4], "input": "action" } ],
+ "onSuccess": "sh3" },
+
+ "sh3": { "clip": "clips/sh3_action.mp4", "death": "clips/sh3_death_post.mp4",
+ "windows": [ { "t": [3.0, 4.5], "choices": {
+ "left": { "death": "clips/sh3_death_acct.mp4" },
+ "up": { "goto": "t4s1" },
+ "right": { "death": "clips/sh3_death_post.mp4" }
+ } } ],
+ "onSuccess": "t4s1" },
+
+ "t4s1": { "clip": "clips/t4s1_action.mp4", "death": "clips/t4s1_death.mp4",
+ "windows": [ { "t": [3.0, 3.7], "input": "action" } ],
+ "onSuccess": "t4s2", "checkpoint": true },
+ "t4s2": { "clip": "clips/t4s2_action.mp4", "death": "clips/t4s2_death.mp4", "mirrorOk": true,
+ "windows": [ { "t": [2.2, 2.9], "input": "right" }, { "t": [3.4, 4.1], "input": "up" } ],
+ "onSuccess": "t4s3" },
+ "t4s3": { "clip": "clips/t4s3_action.mp4", "death": "clips/t4s3_death.mp4", "mirrorOk": true,
+ "windows": [ { "t": [1.8, 2.4], "input": "left" }, { "t": [2.8, 3.4], "input": "right" }, { "t": [3.8, 4.4], "input": "left" } ],
+ "onSuccess": "t4s4" },
+ "t4s4": { "clip": "clips/t4s4_action.mp4", "death": "clips/t4s4_death.mp4",
+ "collect": { "t": [1.5, 2.0], "input": "up", "label": 4 },
+ "windows": [ { "t": [3.4, 3.9], "input": "action" } ],
+ "onSuccess": "t4s5" },
+ "t4s5": { "clip": "clips/t4s5_action.mp4", "death": "clips/t4s5_death.mp4",
+ "windows": [ { "t": [1.5, 3.5], "input": "down", "hold": true }, { "t": [4.0, 4.7], "input": "action" } ],
+ "onSuccess": "sh4" },
+
+ "sh4": { "clip": "clips/sh4_action.mp4", "death": "clips/sh4_death.mp4",
+ "onMiss": "restart", "maxRestarts": 3,
+ "windows": [ { "t": [1.2, 1.9], "input": "down" }, { "t": [2.2, 2.9], "input": "down" }, { "t": [3.2, 3.9], "input": "down" }, { "t": [4.2, 4.9], "input": "down" } ],
+ "onSuccess": "t5s1" },
+
+ "t5s1": { "clip": "clips/t5s1_action.mp4", "death": "clips/t5s1_death.mp4",
+ "windows": [ { "t": [2.0, 2.6], "input": "right" }, { "t": [3.4, 4.0], "input": "right" } ],
+ "onSuccess": "t5s2", "checkpoint": true },
+ "t5s2": { "clip": "clips/t5s2_action.mp4", "death": "clips/t5s2_death.mp4",
+ "needledrop": { "t": [0.8, 1.4], "clip": "clips/t5s2_alt.mp4", "bonus": 500 },
+ "windows": [ { "t": [1.8, 2.4], "input": "left" }, { "t": [2.8, 3.4], "input": "action" }, { "t": [3.8, 4.4], "input": "right" } ],
+ "onSuccess": "t5s3" },
+ "t5s3": { "clip": "clips/t5s3_action.mp4", "death": "clips/t5s3_death.mp4",
+ "collect": { "t": [1.0, 1.5], "input": "up", "label": 5 },
+ "windows": [ { "t": [2.4, 3.0], "input": "up" }, { "t": [3.6, 4.2], "input": "up" } ],
+ "onSuccess": "t5s4" },
+ "t5s4": { "clip": "clips/t5s4_action.mp4", "failGoto": "t5s1",
+ "windows": [ { "t": [3.2, 3.9], "input": "action" } ],
+ "onSuccess": "t5s5" },
+ "t5s5": { "clip": "clips/t5s5_action.mp4", "death": "clips/t5s5_death.mp4",
+ "windows": [ { "t": [1.5, 3.2], "input": "right", "hold": true }, { "t": [3.8, 4.5], "input": "up" } ],
+ "onSuccess": "sh5" },
+
+ "sh5": { "clip": "clips/sh5_action.mp4", "death": "clips/sh5_death.mp4",
+ "windows": [ { "t": [3.0, 4.5], "choices": {
+ "up": { "death": "clips/sh5_death.mp4" },
+ "down": { "goto": "t6s1" },
+ "right": { "goto": "sh5_bonus" }
+ } } ],
+ "onSuccess": "t6s1" },
+ "sh5_bonus": { "clip": "clips/sh5_bonus.mp4", "death": "clips/sh5_death.mp4",
+ "windows": [], "score": 1000, "label": 7, "onSuccess": "t6s1" },
+
+ "t6s1": { "clip": "clips/t6s1_action.mp4", "death": "clips/t6s1_death.mp4",
+ "needledrop": { "t": [0.8, 1.4], "clip": "clips/t6s1_alt.mp4", "bonus": 500, "skipTo": "t6s3" },
+ "windows": [ { "t": [1.8, 2.4], "input": "up" }, { "t": [2.8, 3.4], "input": "right" }, { "t": [3.8, 4.4], "input": "up" } ],
+ "onSuccess": "t6s2", "checkpoint": true },
+ "t6s2": { "clip": "clips/t6s2_action.mp4", "death": "clips/t6s2_death.mp4",
+ "collect": { "t": [1.2, 1.7], "input": "up", "label": 6 },
+ "windows": [ { "t": [3.0, 3.7], "input": "down" } ],
+ "onSuccess": "t6s3" },
+ "t6s3": { "clip": "clips/t6s3_action.mp4", "death": "clips/t6s3_death.mp4", "mirrorOk": true,
+ "windows": [ { "t": [3.2, 3.9], "input": "right" } ],
+ "onSuccess": "t6s4" },
+ "t6s4": { "clip": "clips/t6s4_action.mp4", "death": "clips/t6s4_death.mp4",
+ "windows": [ { "t": [3.4, 4.1], "input": "action" } ],
+ "onSuccess": "sh6" },
+
+ "sh6": { "clip": "clips/sh6_action.mp4", "death": "clips/sh6_death.mp4",
+ "windows": [ { "t": [1.4, 2.0], "input": "down" }, { "t": [2.4, 3.0], "input": "left" }, { "t": [3.4, 4.0], "input": "right" }, { "t": [4.4, 5.0], "input": "action" } ],
+ "onSuccess": "REPLAY" },
+
+ "t7s1": { "clip": "clips/t7s1_action.mp4", "death": "clips/t7s1_death.mp4",
+ "windows": [ { "t": [1.6, 2.2], "input": "left" }, { "t": [2.6, 3.2], "input": "down" }, { "t": [3.6, 4.2], "input": "right" } ],
+ "onSuccess": "t7s2", "checkpoint": true },
+ "t7s2": { "clip": "clips/t7s2_action.mp4", "death": "clips/t7s2_death.mp4",
+ "windows": [ { "t": [1.2, 1.7], "input": "right" }, { "t": [2.2, 2.7], "input": "up" }, { "t": [3.2, 3.7], "input": "left" }, { "t": [4.2, 4.7], "input": "action" } ],
+ "onSuccess": "t7s3" },
+ "t7s3": { "clip": "clips/t7s3_action.mp4", "death": "clips/t7s3_death.mp4",
+ "windows": [ { "t": [3.5, 3.9], "input": "action" } ],
+ "onSuccess": "WIN" }
}
-}
\ No newline at end of file
+}
diff --git a/engine/styles.css b/engine/styles.css
index d7cf479..f32ea0b 100644
--- a/engine/styles.css
+++ b/engine/styles.css
@@ -6,6 +6,7 @@ video { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%
max-width: 100vw; max-height: 100vh; aspect-ratio: 16/9; width: 100%;
opacity: 0; pointer-events: none; }
video.active { opacity: 1; }
+video.mirror { transform: translate(-50%, -50%) scaleX(-1); }
#scanlines { position: absolute; inset: 0; pointer-events: none; z-index: 5;
background: repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0 2px, rgba(0,0,0,.22) 2px 4px);
@@ -17,6 +18,18 @@ video.active { opacity: 1; }
text-shadow: 0 0 2vmin #0ff, 0 0 6vmin #08f; pointer-events: none; }
#cue.show { opacity: 1; animation: pulse .3s ease-in-out infinite alternate; }
#cue.good { color: #0f4; text-shadow: 0 0 2vmin #0f4; animation: pop .25s ease-out; }
+#cue.hold { color: #fa0; text-shadow: 0 0 2vmin #fa0, 0 0 6vmin #f60; }
+
+/* needle-drop flash (optional power-up prompt) */
+#nd { position: absolute; top: 14%; right: 8%; font-size: 8vmin; color: #f0f; z-index: 10;
+ opacity: 0; text-shadow: 0 0 2vmin #f0f, 0 0 6vmin #90f; pointer-events: none; }
+#nd.show { opacity: 1; animation: pulse .18s ease-in-out infinite alternate; }
+
+/* white-label toast */
+#toast { position: absolute; top: 24%; left: 50%; transform: translateX(-50%);
+ font-size: 4vmin; color: #fff; z-index: 10; opacity: 0; letter-spacing: .5vmin;
+ text-shadow: 0 0 2vmin #fff; pointer-events: none; transition: opacity .3s; }
+#toast.show { opacity: 1; }
@keyframes pulse { from { transform: translateX(-50%) scale(1); } to { transform: translateX(-50%) scale(1.18); } }
@keyframes pop { from { transform: translateX(-50%) scale(1.4); opacity: 1; } to { transform: translateX(-50%) scale(1); opacity: 0; } }
@@ -25,6 +38,7 @@ video.active { opacity: 1; }
justify-content: space-between; padding: 0 3vmin; z-index: 10; pointer-events: none; }
#lives { color: #f33; font-size: 4vmin; letter-spacing: .8vmin; text-shadow: 0 0 1.5vmin #f33; }
#score { color: #ff0; font-size: 4vmin; text-shadow: 0 0 1.5vmin #f80; }
+#labels { color: #fff; font-size: 3vmin; letter-spacing: .5vmin; text-shadow: 0 0 1vmin #fff; }
/* full screens */
.screen { position: absolute; inset: 0; z-index: 20; display: flex; flex-direction: column;
diff --git a/production/flow/SHIFTS_SHOTS.md b/production/flow/SHIFTS_SHOTS.md
index 4f77fd5..e1ad0a2 100644
--- a/production/flow/SHIFTS_SHOTS.md
+++ b/production/flow/SHIFTS_SHOTS.md
@@ -21,6 +21,9 @@ Inside the cluttered record store in the daytime, warm light, checkerboard floor
## sh3_death_acct ⭐QUALITY [attach: Dez, The Accountant]
Inside the cluttered record store in the daytime, warm light, checkerboard floor. The Accountant rises vertically through the checkerboard floor beside the counter like a surfacing submarine, adjusts his glasses with one finger, opens his ledger, taps a single line, and Dez folds neatly in half like a closed book and files himself under the counter. The clip ends held on the Accountant sinking back down. Static wide shot. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering.
+## sh4_action [attach: Dez, The Boring Guy]
+Inside the cluttered record store in the daytime, warm light, checkerboard floor. The Boring Guy stands at the counter in beige clothes and a lanyard, talking steadily at Dez with small emphatic hand gestures; four times he pauses and leans in expectantly, and Dez nods; the light through the window shifts slightly as if hours pass. Static two-shot across the counter. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering.
+
## sh4_death [attach: Dez, The Boring Guy]
Inside the cluttered record store in the daytime, warm light, checkerboard floor. Time-lapse: the Boring Guy talks on at the counter as light sweeps day to night to day; cobwebs grow over Dez behind the counter, seasons flicker past the window, and Dez is finally a cheerful skeleton in a red flannel shirt still nodding. The clip ends held on the nodding skeleton. Static two-shot. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering.
diff --git a/production/flow/TRACK5_SHOTS.md b/production/flow/TRACK5_SHOTS.md
index 13d5f46..f20a65e 100644
--- a/production/flow/TRACK5_SHOTS.md
+++ b/production/flow/TRACK5_SHOTS.md
@@ -24,6 +24,10 @@ Inside a computerized hi-fi system rendered as a neon wireframe world, glowing c
## t5s3_death [attach: Dez]
Inside a computerized hi-fi system rendered as a neon wireframe world, glowing cyan and magenta grid floor like a giant equalizer, circuit-board skyscrapers, records as glowing light-discs, black sky with waveform aurora. The deletion wall catches Dez mid-stride and erases him from the feet upward, leaving only a glowing wireframe outline of his running pose that flickers and vanishes. The clip ends held on the empty corridor. Static wide shot. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering.
+## t5s4_action [attach: Dez]
+(No death clip — failing this gate spits Dez back to t5s1, no life lost.)
+Inside a computerized hi-fi system rendered as a neon wireframe world, glowing cyan and magenta grid floor like a giant equalizer, circuit-board skyscrapers, records as glowing light-discs, black sky with waveform aurora. A colossal spinning record platter blocks the corridor like a vault door, a single gap in its groove sweeping around; Dez crouches, waits, and dives through as the gap aligns, the platter slamming shut behind his heels. Static wide shot facing the gate. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering.
+
## t5s5_action [attach: Dez]
Inside a computerized hi-fi system rendered as a neon wireframe world, glowing cyan and magenta grid floor like a giant equalizer, circuit-board skyscrapers, records as glowing light-discs, black sky with waveform aurora. Dez escapes on a light-cycle that is unmistakably a turntable with wheels, trailing a ribbon of neon light, and launches off a glowing ramp. Fast tracking shot alongside. 1980s hand-drawn cel animation, thick ink outlines, painterly gouache backgrounds, film grain. No text, no captions, no watermark; all signs, posters and record sleeves are blank or abstract designs with no lettering.
diff --git a/tools/make_placeholders.py b/tools/make_placeholders.py
index b5ebe3a..818bc72 100644
--- a/tools/make_placeholders.py
+++ b/tools/make_placeholders.py
@@ -1,12 +1,14 @@
#!/usr/bin/env python3
-"""Generate placeholder clips for every scene in engine/scenes.json (plus
-attract/win/gameover) so the whole game is playable before any Flow credits are
-spent. Text is rendered to PNG cards with Pillow (this machine's ffmpeg lacks
-drawtext); ffmpeg composites flash + glyph overlays + a 1kHz beep at exactly each
-QTE window so timing is verifiable by eye/ear. Deaths: red 'DEATH' cards.
+"""Generate placeholder clips for every clip referenced by engine/scenes.json
+(actions, deaths, choice deaths, needle-drop alts, attract/win/gameover/
+needle_skip/stinger) so the whole game is playable before any Flow credits are
+spent. Existing files are SKIPPED — real Flow footage is never overwritten.
-If Pillow is missing from the running interpreter, re-execs into a MODELBEAST
-venv python that has it."""
+Text is rendered to PNG cards with Pillow (this machine's ffmpeg lacks
+drawtext); ffmpeg composites flash + glyph overlays + a 1kHz beep at exactly
+each QTE window so timing is verifiable by eye/ear. Choice windows show all
+option glyphs; hold windows show HOLD; needle-drop flashes magenta top-right;
+white-label collects flash white top-left. Deaths: red 'DEATH' cards."""
import glob
import json
import os
@@ -25,7 +27,6 @@ except ModuleNotFoundError:
ROOT = Path(__file__).resolve().parent.parent / "engine"
CLIPS = ROOT / "clips"
-DUR = 5.0
COLORS = ["#1a2a55", "#1f4d2a", "#4d3a1f", "#3a1f4d", "#1f4d4d"]
GLYPH = {"left": "<<<", "right": ">>>", "up": "^^^", "down": "vvv", "action": "(GO)"}
FONTS = ["/System/Library/Fonts/Supplemental/Arial Bold.ttf",
@@ -42,30 +43,43 @@ def png_card(path, color, text, size=110, fg="white"):
img.save(path)
-def png_glyph(path, text):
+def png_glyph(path, text, size=200, fill="#ffee00"):
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
- f = ImageFont.truetype(FONT, 200) if FONT else ImageFont.load_default()
- d.text((W / 2, H - 220), text, font=f, fill="#ffee00", anchor="mm")
+ f = ImageFont.truetype(FONT, size) if FONT else ImageFont.load_default()
+ d.text((W / 2, H - 220), text, font=f, fill=fill, anchor="mm")
img.save(path)
-def clip(out, base_png, dur, windows=(), tmp=None):
- """base card + per-window white flash, glyph overlay, and beep."""
+def win_glyph(w):
+ if "choices" in w:
+ return " ".join(GLYPH[d] for d in w["choices"])
+ g = GLYPH[w["input"]]
+ return g + " HOLD" if w.get("hold") else g
+
+
+def clip(out, base_png, dur, windows=(), flashes=(), tmp=None):
+ """base card + per-window white flash, glyph overlay, beep; extra flashes
+ are (t0,t1,color) boxes (needle-drop magenta / white-label white)."""
args = ["ffmpeg", "-y", "-loglevel", "error",
"-loop", "1", "-t", str(dur), "-framerate", "24", "-i", str(base_png),
"-f", "lavfi", "-i", f"sine=frequency=1000:sample_rate=44100:duration={dur}"]
- fc, last, vol = [], "0:v", "0"
+ fc, last, vol, vi = [], "0:v", "0", 2
for i, w in enumerate(windows):
t0, t1 = w["t"]
- g = Path(tmp) / f"g{i}.png"
- png_glyph(g, GLYPH[w["input"]])
+ g = Path(tmp) / f"{out.stem}_g{i}.png"
+ png_glyph(g, win_glyph(w), size=140 if "choices" in w or w.get("hold") else 200)
args += ["-loop", "1", "-t", str(dur), "-framerate", "24", "-i", str(g)]
nxt = f"v{i}"
fc.append(f"[{last}]drawbox=t=fill:color=white@0.35:enable='between(t,{t0},{t0 + 0.12})'"
- f"[f{i}];[f{i}][{i + 2}:v]overlay=enable='between(t,{t0},{t1})'[{nxt}]")
- last = nxt
+ f"[f{i}];[f{i}][{vi}:v]overlay=enable='between(t,{t0},{t1})'[{nxt}]")
+ last, vi = nxt, vi + 1
vol = f"if(between(t,{t0},{t1}),1,{vol})"
+ for j, (t0, t1, color, y) in enumerate(flashes):
+ nxt = f"x{j}"
+ fc.append(f"[{last}]drawbox=x={W - 260 if y == 'tr' else 40}:y=40:w=220:h=120:"
+ f"t=fill:color={color}@0.8:enable='between(t,{t0},{t1})'[{nxt}]")
+ last = nxt
fc.append(f"[{last}]format=yuv420p[vout]")
args += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", "1:a",
"-af", f"volume='{vol}':eval=frame",
@@ -78,18 +92,45 @@ def clip(out, base_png, dur, windows=(), tmp=None):
def main():
CLIPS.mkdir(exist_ok=True)
data = json.loads((ROOT / "scenes.json").read_text())
+ deaths, alts = set(), set()
with tempfile.TemporaryDirectory() as tmp:
- def make(name, color, text, dur, windows=()):
+ def make(name, color, text, dur, windows=(), flashes=()):
+ out = CLIPS / name
+ if out.exists():
+ return print("skip (exists)", name)
base = Path(tmp) / (Path(name).stem + ".png")
png_card(base, color, text)
- clip(CLIPS / name, base, dur, windows, tmp)
+ clip(out, base, dur, windows, flashes, tmp)
for i, (sid, s) in enumerate(data["scenes"].items()):
- make(Path(s["clip"]).name, COLORS[i % len(COLORS)], sid.upper(), DUR, s["windows"])
- make(Path(s["death"]).name, "#550000", f"DEATH {sid.upper()}", 2.5)
+ if "random" in s:
+ continue # variants carry their own clips
+ windows = s.get("windows", [])
+ dur = max([5.0] + [w["t"][1] + 0.8 for w in windows])
+ flashes = []
+ if "needledrop" in s:
+ nd = s["needledrop"]
+ flashes.append((nd["t"][0], nd["t"][1], "magenta", "tr"))
+ alts.add(nd["clip"])
+ if "collect" in s:
+ c = s["collect"]
+ flashes.append((c["t"][0], c["t"][1], "white", "tl"))
+ make(Path(s["clip"]).name, COLORS[i % len(COLORS)], sid.upper(), dur, windows, flashes)
+ if s.get("death"):
+ deaths.add(s["death"])
+ for w in windows:
+ for c in w.get("choices", {}).values():
+ if "death" in c:
+ deaths.add(c["death"])
+ for d in sorted(deaths):
+ make(Path(d).name, "#550000", f"DEATH {Path(d).stem.upper()}", 2.5)
+ for a in sorted(alts):
+ make(Path(a).name, "#0a3a5a", Path(a).stem.upper() + " !", 5.0)
make("attract.mp4", "#111111", "RECORD STORE GUY", 4.0)
make("win.mp4", "#004400", "YOU SURVIVED", 4.0)
make("gameover.mp4", "#220022", "GAME OVER", 3.0)
+ make("needle_skip.mp4", "#000000", "~ skip ~", 1.5)
+ make("stinger.mp4", "#2a1a00", "STINGER: MILDRED", 4.0)
if __name__ == "__main__":