Check mate
A chess broadcast fills the screen, and your decision is the game. The moves are real, the commentator whispers, and everyone's mother holds up a sign. Then you give up your queen, the house lights die, and the evaluation bar goes almost all black. Two moves later it's mate. Every scene is code, the game is checked against the rules of chess, and the skill tells your agent which sounds to use and when.
- Frame
- 9:16
- 30-40 s
You need an agent that can run code, such as Claude Code, Claude with code execution, ChatGPT Codex or Cursor, plus Node 20+, ffmpeg and Python 3.
-
Give your agent the skill
Paste it into a new chat. Your agent saves it as a skill and waits for your prompt.
Copy for my agent Copy link -
Fill in the prompt
The decision The safe thing, your queen The risky move, your knight Platform Narrator Ending AI toolsUse the checkmate skill to make a vertical video for Instagram Reels. - **The decision:** [TOPIC - the hard call the game is about, e.g. "the job offer I almost turned down", "closing the product everyone liked"] - **The safe thing, your queen:** [SAFE - one or two words for what everyone said not to give up, e.g. "safe job", "big client"] - **The risky move, your knight:** [RISK - one word for the option that wins, e.g. "offer", "startup", or "you pick it"] - **Who you're playing:** [OPPONENT - e.g. "the job market", "the industry", or "you pick it"] - **In the window:** [WINDOW - "no person: the hall drawn in code", or "my own clip, attached, as the player cam"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a hushed chess commentator", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's hushed chamber brief", or your own idea] - **Ending:** [CTA - organic: "Comment KEYWORD and I'll send you THE THING", with the keyword and what you'll send; or sponsored: the line and the URL] - **AI tools:** [AI TOOLS - e.g. "whatever you already have set up", "HYVIRAL", or "none: animation only, I'll record the voice myself"] - **Avoid:** [AVOID - e.g. no real players or tournaments named, no real companies as the opponent, no claims I can't back up] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and what happens on the board. Keep the skill's game unless I ask for another. If you change it, run it through the frame's own rules check. 2. Build the plan, the clock and the frame, render a silent animatic, and show me the script, the scene sheet and the animatic. Wait for my OK. 3. Generate the voice, music and sound effects with the AI tools I named above. If I left that open, tell me which tools you'll use and wait for my OK. 4. Place a sound on every change, fit the music to the turn, mix voice first, render, and run every QA gate in the skill. 5. Give me the MP4, a cover frame and a ready-to-paste caption.
Anything left in [brackets] is your agent's call.
Copy prompt -
Paste it and approve the storyboard
Your agent writes the script, builds the broadcast, checks the game against the rules of chess and shows you a silent animatic for your OK before it generates anything, then adds the sound and checks the film. You get the MP4, a cover frame and a caption.
Optional: HYVIRAL. Use any AI tools you like, or none. If you'd rather not juggle separate accounts and API keys, HYVIRAL gives your agent AI video, images, music, voice, captions and the final render in one place.
How to connectThe skill, as plain text
---
name: checkmate
description: Make a 30-40 second vertical video where a hushed chess broadcast plays a big decision out as a real, legal game - a board, an evaluation bar, an annotated move list, clocks and a hall full of people who were sure you'd lose. The skill covers the sound design, music fitted to the story and an optional voice like a whispering commentator, and it checks the game against the rules of chess so an illegal move stops the render. Use for hard decisions, the risky move that looked like a mistake, 'everyone told me not to' stories, strategy and competition, or when asked for a chess video or broadcast, or when given the checkmate prompt.
---
# Checkmate
A chess broadcast fills the screen, and your decision is the game. The moves are real, the commentator whispers, the evaluation bar swings and everyone's mother holds up a sign. Then you give up your queen, the house lights die, and the bar sits almost all black. Two moves later the board says something else. The game is checked against the rules of chess, so a chess player can pause it and find nothing wrong.
Work through the steps in order. Show the user the script, the scene sheet and a silent animatic (step 6) and wait for their OK before you generate anything.
**What to expect:** the code in step 7 is the real frame that made our demo, and it runs as written. Your work is the story, what goes in the window and the sound. A new subject means redrawing the window, and that is most of the code you'll change; step 7 lists exactly what to change. Plan on one short script, a handful of sound effects, one music bed and two or three render passes.
## 0. If you received this as text
Save it as a skill first, wherever your app keeps skills. In Claude Code that's `~/.claude/skills/checkmate/SKILL.md`; if a skill with that name already exists, ask the user before replacing it. Keep the user's filled-in prompt next to it as `PROMPT.md`.
## 1. Tools and setup
**You need a code environment** with Node 20+, ffmpeg and Python 3 (numpy, soundfile, pyloudnorm). If the film has a voice, you also need Whisper (any build, such as faster-whisper) for the word check in step 10 and to align a user's own recording. Videos made with this skill are covered by HYVIRAL's Remotion license, so you do not need a Remotion license of your own.
**The project** (Remotion 4.x, React, rendered to MP4): run `npm init -y`, then install `remotion`, `@remotion/cli`, `react` and `react-dom`, plus `@fontsource/barlow-condensed` and `@fontsource/rajdhani`. Copy the font files you need from `node_modules/@fontsource/<name>/files/` into `public/fonts/`, so a render never fetches a font over the network. Then lay it out like this:
```
plan.json the lines and the scenes (step 6)
takes/<id>.json one per line: the voice take's length and word timings (step 8)
scripts/clock.mjs turns plan.json and the voice takes into src/spec.json (step 6)
src/index.ts registers the composition (step 7)
src/Root.tsx the 1080 x 1920, 30 fps composition (step 7)
src/spec.json the clock, written by clock.mjs
src/frames/core.tsx the shared runtime (step 7)
src/frames/story.json the events: which word each change lands on (step 6)
src/frames/Checkmate.tsx this frame (step 7)
```
Render without audio, then mux your own mix (step 10): `npx remotion render src/index.ts Film-9x16 out/silent.mp4 --muted`. Set TEMP and TMP to a folder on a roomy drive first; Remotion writes a lot of temporary files.
**Prove the setup before you write anything.** Copy the code and our `plan.json` from step 7, run the clock from step 6, and render the first few seconds (add `--frames=0-90`). Only then start on your own story, at step 5. The font files you need are named in `FACES` at the top of the frame.
**For the voice, images, music and sound effects, use whatever the user prefers.** Every step works the same with any provider. If the user hasn't said, ask which tools they want to use, or use what is already set up.
- **Your own providers.** A text-to-speech that returns word timestamps (for example ElevenLabs with timestamps), a music model or a licensed track, and a sound-effects generator (for example ElevenLabs sound effects) or a licensed sound library. Keep API keys in environment variables, never in the project. No AI media at all is fine too: the picture is code, the narration can be the user's own recording (align its words with Whisper), and the film works with no voice.
- **HYVIRAL (optional).** If the user has it connected (hyviral.com), one connection covers the media:
| You need | HYVIRAL tool |
|----------|--------------|
| Narration with word timestamps (stock voices or your own cloned voice) | `generate_voiceover` (`content_type: "shorts"`), `manage_voice_clone` |
| Still images | `generate_image` |
| Music bed | `generate_music` |
| Sound library, placing sounds, final render | `video_audio`, `video_export` |
| Long jobs | `get_job_status` (poll until delivered) |
To connect it: in Claude.ai or Claude Desktop, Customize → Connectors → add a custom connector with the URL `https://api.hyviral.com/mcp`; in Claude Code, `claude mcp add --transport http hyviral https://api.hyviral.com/mcp` then `/mcp` to sign in. Start a session with `get_connection_info`. HYVIRAL never runs your code: you render the animation yourself.
## 2. How a frame film works
- **One frame, the whole film.** A frame is a world with fixed parts on screen, and it never cuts away. The story happens to the world: a real game plays out move by move, the evaluation bar swings, the move list marks each moment, the house lights go down and come back. That's why it holds attention: the viewer learns the world in the first second, then watches it change.
- **One clock: the spoken words.** Each line is voiced as its own take with word timings, scenes are laid end to end on those takes, and every change is keyed to a word in `story.json`. Re-voice a line and everything moves with it.
- **A function of time.** Every part of the frame computes what it shows from the current time, with no stored state, `Math.random` or `Date`. Use `hash()` for anything that looks random, so two renders match.
- **Sound off first.** Most people start muted, so the frame and the captions have to tell the whole story on their own. Sound makes it better, never necessary.
- **Safe area.** 1080 x 1920. Platform buttons and captions cover the bottom 440 px, and the right 130 px from about y = 900 down. Keep captions and anything that must be read inside x 60-950, y 250-1480. Above y = 900 the frame can run to the right edge. Below y = 1480, draw only texture. The top 250 px can hold the world's title and small jokes, as long as nothing there has to be read.
- **Pacing rules.** No voice gap over 1.0 s, except one voiceless moment of 2 to 5 s with the music lifted. No still stretch over 1.2 s. Never cut to black. Something moves on the very first frame.
## 3. The world
A hushed chess broadcast, seen as one screen.
- **Palette:** hall `#1e2a2f`, panels `#2b3a40`, dark squares `#6f8aa4`, light squares `#e6e9ee`, brilliant teal `#1baca6` (`#27cfc7` for text), blunder red `#ca3431`, dubious amber `#f7c631`. House lights do the rest: everything dims together.
- **Type:** Barlow Condensed (600 to 800) for the broadcast and captions, Rajdhani (600, 700) for moves, clocks and numbers.
- **Fixed parts, top to bottom:** the title and event (y 70-175), Black's plate (y 188-244), then the evaluation bar (x 60-100), the board (x 108-732, y 250-874) and the right column (x 746-1040) with the move list, the banner and the engine panel; White's plate (y 878-934), the window (y 944-1284), the commentary card that holds the captions (y 1292-1476), and the hall's other boards as texture below y 1500. The right column runs past x 950, which is allowed because it ends above y 900.
- **The game is real, and the code proves it.** `GAME` holds the moves as text. When the film loads, the frame plays them through a full rules check (checks, pins, castling, en passant, promotion) and works out every square from there, so no piece is ever placed by hand. Each piece keeps one identity, so it slides instead of popping.
- **Small jokes live in the broadcast:** the event is the OPEN PLAN OPEN, the header says WHISPERS ONLY, the resume lists its references as "my mother", and the other boards read LANDLORD 1-0 DEPOSIT.
**Real people and real marks, in every version of this film.** The user's own name, brand and product are fine. Never use anyone else's company, agency, team or product name, logo, livery, seal or badge. Never use a real person's name, face or voice without their OK, and never make a real person or a named company the villain, the target of the joke or the one to blame.
## 4. The window
The window is the part of the frame that shows the subject. It has two modes, and the frame must work with either.
- **With no person (the default).** A scene that belongs to the world, drawn in code or from still images. It has to follow the story as well, not just sit there. "No person" means no filmed presenter: small drawn figures are fine.
- **With the user's own clip.** Replace the scene with their footage (`<OffthreadVideo>` from `remotion`, cropped to the window, muted if the narration is separate). Keep their face in the middle third, and take the word timings from their clip with Whisper.
For this world the window is the hall cam, and the director cuts to whatever the commentary is talking about. Ours is drawn in code: a crowd in silhouette, spotlights, a table with a chess clock.
| Story moment | In the hall cam |
|---|---|
| Opening | Spotlights sway over a seated crowd, SILENCE PLEASE on the back wall, the clock on the table |
| The players | A resume comes up to the lens (references: my mother), then a recruiter's card |
| The offer | A letter: OFFER. Less pay. Real work. |
| The pressure | CLOCK CAM: the clock fills the window, its face goes red, the plunger taps faster |
| Everyone | FAMILY SECTION: signs rise out of the crowd. DECLINE. DECLINE, DEAR. CALL ME. |
| The sacrifice | The crowd leans in. The queen falls off the board above, down into the hall, and lands on the table |
| The low point | The house lights go down bank by bank, the signs sink, and one light stays on the queen with SAFE JOB still on it. The crowd gasps once |
| The mate | The lights slam up, flash bulbs, the crowd stands. The signs turn over: ACCEPT. I SAID ACCEPT. CALL ME stays. The wall reads APPLAUSE, QUIETLY |
| The button | The letter comes back with a red ACCEPTED, and the card now reads REMATCH? |
The window also works with the user's own clip: play it inside the window as the player cam, cropped to the frame, and keep the label, the signs and the table clock on top.
## 5. The story
This is the part that makes the film, so write it before you touch anything else. Read every line aloud.
- **Use the world's own events.** Every turn in the topic has to become something that really happens in this world. If a line could sit in any video, rewrite it.
- **Use the voice's deadpan.** The funniest and most moving versions take the world completely seriously.
- **Short lines.** Mostly 5 to 11 words, one idea each, with an occasional two-word punch.
- **Real claims only.** Use a number or a fact only if the user gave it to you or you can source it. Invented details are fine only when the film is plainly a joke or a metaphor.
- **Build to a low point, then a turn.** The low point is a picture event, never silence: the world goes dim or dead while room tone and a designed sound keep running.
- **No em dashes on screen.** Commas, full stops and line breaks do that job.
- **Ending, organic or sponsored.** Ask the user which one, and for an organic ending, what they'll send people. Organic: "Comment KEYWORD and I'll send you THE THING", said in the world's voice, and no link. The keyword stays on screen in the world's own way until the end, because the caption moves on. Sponsored: a direct line and a URL.
- **Length.** Run the clock from step 6 as soon as the script exists, before you build anything. Each extra line adds about 3 s, and even a one-word line takes about 0.5 s. Time follows the words, so a cut only helps if words go: folding two moves into one shorter line works, and joining them word for word saves nothing. Then re-anchor every event in `story.json`.
**The moves.** Map any topic onto these. White is the viewer, and the queen is the safe thing everyone says you can't give up. The film treats a hard decision as a game of chess, and the moves are real. Ours is the job offer you almost turned down, in a hushed broadcast.
| # | Move | What it does | Our line | On the board |
|---|------|--------------|----------|--------------|
| 1 | Opening | The hook: the viewer's life as a broadcast | "Good evening. We join your career at move one." | The pieces drop into place. 1.e4 e5 |
| 2 | The players | Names the two sides in the world's terms | "White opens with a resume. Black, a recruiter." | 2.Nf3 d6 |
| 3 | The safe thing | Puts a label on what's at stake | "White still has the safe job, on its original square." | 3.Bc4 Bg4, an arrow shows the pin, the queen wears a tag: SAFE JOB |
| 4 | The offer | The new option arrives | "An offer arrives. Less pay, real work." | 4.Nc3 g6, and that knight wears a tag: OFFER |
| 5 | Clock | Pressure | "Clock pressure. White has until Friday." | The clock turns red and reads UNTIL FRIDAY, and it runs fast |
| 6 | Everyone | The crowd's advice | "The engine says decline. Everyone's mother says decline." | Engine arrows, BEST MOVE: DECLINE, the list marks 4.Nc3 with "?!", signs rise |
| 7 | The sacrifice | The risky move, and it looks like a mistake | "Oh. White moves the knight. White gives up the queen." | 5.Nxe5, marked "??". 5...Bxd1: the queen falls off the board and the bar swings hard to Black |
| 8 | Low point | The picture goes dead, the world keeps running | "The safe job is gone. The engine gives no chance." | House lights down, the bar nearly all black, CHANCES 0.0%, the crowd gasps once. Music out; taps and murmur keep running |
| 9 | The turn | No new facts, just a better look | "But look at the bishop." | A light finds the bishop on "look". 6.Bxf7+ on "bishop", Ke7, the bar trembles, the engine says THINKING: ONE SEC. Music returns on "bishop" |
| 10 | Mate | The voiceless moment, about 3 s | (music at its fullest, the caption reads CHECKMATE) | 7.Nd5#: the OFFER knight lands, arrows converge, the king tips over, the bar slams white, a card reads 1-0 |
| 11 | The verdict | The last mark | "Seven moves. Best blunder of the decade." | The list counts seven moves, then the "??" on the sacrifice flips to "!!". The banner says BRILLIANT, the engine says SORRY |
| 12 | The button | The last laugh | "White has accepted the offer. Black wants a rematch." | A red ACCEPTED on the letter, REMATCH? on Black's plate and on the card |
| 13 | Call to action | One action | "Comment mate and I'll send you the skill that made this." | The result card turns over: COMMENT MATE, and it stays |
**For other topics:** closing the product everyone liked to build the one you believe in, turning down the big client, moving somewhere nobody thinks you should, dropping the feature that got you here. Keep our game: the same seven moves fit any story where giving up the safe thing wins, and you only change the tags, the plates, the signs and the papers. The trick that makes it land is the tag. The piece labeled OFFER arrives quietly at move four and is the piece that delivers mate, so the viewer has been looking at the answer all along. Say what the queen is in one or two words, and say what the knight is in one word. Our film runs 41 s on the clock's estimate, and a voice may move that a little. For about 36 s, fold moves 1 and 2 into one line and drop the first sentence of move 8.
**If the user needs a different game, check it the same way.** Never type a board position by hand and never trust a game from memory. Write the moves into `GAME`, and let the frame's own check play them: an illegal move, an ambiguous one, or a wrong + or # stops the render and names the move. Then count the plies and give `story.json` one `plyN` event for each, in order. The film needs a game that ends in mate with a sacrifice shortly before it, and a game that short usually has a known name: don't attach a real player's or a real tournament's name to it, in the picture or in the lines.
## 6. Plan and animatic (before generating anything)
**1. Write `plan.json`.** `text` is what the captions show. Add `say` when the spoken version differs, with the same number of words. Scenes hold the lines. `lead` and `tail` are seconds of room around a scene's words, and a voiceless scene has `sec` instead of `lines`.
```json
{
"lines": [
{ "id": "l01", "text": "First line." },
{ "id": "l02", "text": "Comment TOUR and I'll send it to you.", "say": "Comment, tour, and I'll send it to you." }
],
"scenes": [
{ "id": "s01", "lines": ["l01"], "lead": 0.7 },
{ "id": "s02", "sec": 3.4 },
{ "id": "s03", "lines": ["l02"] }
]
}
```
**2. Run the clock.** Write `scripts/clock.mjs` as below, and run it from the project folder. A line with no take yet gets an estimate, so you can see the whole film before generating anything. A real take goes in `takes/<id>.json` as `{ "dur": seconds, "words": [{ "w": "First", "t0": 0, "t1": 0.3 }] }` and replaces the estimate.
```js
// node scripts/clock.mjs plan.json + takes/<id>.json -> src/spec.json (what the frame renders) and clock.json (where each line plays)
import fs from 'node:fs';
const FPS = 30;
const plan = JSON.parse(fs.readFileSync('plan.json', 'utf8'));
const PAUSE = 0.35, LEAD = 0.05, TAIL = 0.25, TAIL_OUT = 1.2;
// No take yet: an estimate for a quick, clear voice, fitted to 53 real takes. A real take in takes/<id>.json replaces it.
function estimate(text) {
const letters = (s) => (s.match(/[\p{L}\p{N}]/gu) ?? []).length;
const stops = (text.match(/[.?!]+(?=\s)/g) ?? []).length, commas = (text.match(/,/g) ?? []).length;
const dur = 0.29 + 0.059 * letters(text) + 0.53 * stops + 0.07 * commas;
const parts = text.split(/\s+/), total = parts.reduce((n, p) => n + letters(p) + 1, 0);
let at = 0;
const words = parts.map((w) => { const d = (dur * (letters(w) + 1)) / total, x = { w, t0: at, t1: at + d }; at += d; return x; });
return { dur, words };
}
let t = 0.1;
const beats = [], placed = [];
for (const s of plan.scenes) {
const from = Math.round(t * FPS);
let end = t + (s.sec ?? 0);
const words = [];
if (s.lines?.length) {
let c = t + (s.lead ?? LEAD);
s.lines.forEach((id, i) => {
const line = plan.lines.find((l) => l.id === id);
const take = fs.existsSync(`takes/${id}.json`) ? JSON.parse(fs.readFileSync(`takes/${id}.json`, 'utf8')) : estimate(line.say ?? line.text);
// Captions show `text`, even when `say` differs ("Comment, gate"), as long as the word counts match.
const shown = line.text.split(/\s+/);
const same = shown.length === take.words.length;
placed.push({ line: id, t0: c, dur: take.dur, scene: s.id });
take.words.forEach((w, k) => words.push({ w: same ? shown[k] : w.w, f0: Math.round((c + w.t0) * FPS) - from, f1: Math.round((c + w.t1) * FPS) - from }));
c += take.dur + (i < s.lines.length - 1 ? PAUSE : 0);
});
end = c + (s.tail ?? TAIL);
}
const dur = Math.max(1, Math.round(end * FPS) - from);
beats.push({ id: s.id, from, dur, words, p: s.p ?? {} });
t = (from + dur) / FPS;
}
const total = Math.round((t + TAIL_OUT) * FPS);
beats[beats.length - 1].dur = total - beats[beats.length - 1].from;
beats[0].dur += beats[0].from;
beats[0].words.forEach((w) => { w.f0 += beats[0].from; w.f1 += beats[0].from; });
beats[0].from = 0;
for (let i = 1; i < placed.length; i++) {
const gap = placed[i].t0 - (placed[i - 1].t0 + placed[i - 1].dur);
const quiet = beats.some((b) => !b.words.length && b.from / FPS < placed[i].t0 && (b.from + b.dur) / FPS > placed[i - 1].t0);
if (gap > 1.0 && !quiet) console.warn(`voice gap ${gap.toFixed(2)} s before ${placed[i].line}: shorten a lead or tail`);
}
fs.mkdirSync('src', { recursive: true });
fs.writeFileSync('src/spec.json', JSON.stringify({ fps: FPS, durationInFrames: total, beats }, null, 1));
fs.writeFileSync('clock.json', JSON.stringify({ fps: FPS, total: total / FPS, lines: placed }, null, 1));
console.log(`${(total / FPS).toFixed(2)} s, ${beats.length} scenes, ${placed.length} lines`);
```
**3. Write `src/frames/story.json`.** Name every change in the story and pin it to a scene, and to a word in it where you can. The frame reads events by name, and a typo fails the render instead of drifting. Every event is checked when the film loads, even one the frame never reads. Our list keeps some events only so a sound can land on them; you can delete those. A `word` matches the first word in its scene that starts with those letters, so "the" also hits "there", and a repeated word can only be reached with `off` from an earlier one.
**4. Build the frame** with the code in step 7, changing the words and schedules to your story. Keep each schedule in time order.
**5. Render a silent animatic.** Make contact sheets at one frame per second with the safe box drawn, and look at every tile: `ffmpeg -i out/silent.mp4 -vf "select='not(mod(n\,30))',drawbox=x=60:y=250:w=890:h=1230:color=red@0.9:t=6,scale=216:-2,tile=8x3" -fps_mode passthrough sheet_%02d.jpg`. Empty tiles at the end of the last sheet are padding, not black frames. A tile that small hides small errors, so also look at about 6 frames at full size, with the window cropped large (`npx remotion still src/index.ts Film-9x16 out/f.png --frame=N`).
**6. Show the user** the script, the scene sheet (one row per scene: its line, what changes on screen, and its seconds) and the animatic. Wait for their OK.
## 7. Build the frame
These files are the real frame that made our demo, with our demo's story in it. You rendered them in step 1 to prove your setup. Now change the story, using the list below. Leave the machinery alone until the film works, and read the code before you change it. Paper, panels and labels are sized to our demo's words, so check anything longer on the contact sheet.
### What to change for your story
Most of the story sits in the constants at the top of the file, and if you keep our game, that's nearly all you touch. Then the window's papers. `core.tsx` and `clock.mjs` don't change.
| To change | Where |
|---|---|
| The game | `GAME`, and one `plyN` event per move in `story.json`. Everything on the board comes from it. Leave the rules section alone |
| The title and the players | `TITLE`, `EVENT`, `WHITE`, `BLACK`, `RESULT`; WHISPERS ONLY is in `Header` |
| What the pieces stand for | `TAGS`. `home` is the square the piece starts on. The first row is the piece that falls off the board |
| The move marks | `GRADES`, `NAG`, and `SACRIFICE_PLY` for the mark that flips |
| The banner and the engine | `BANNER`, `ENGINE`, `DEPTH`, each keyed to events |
| The bar | `EVAL`, in pawns, keyed to events. Over 9 reads as mate |
| Special moves and arrows | `PIN_PLY`, `QUEEN_TAKEN_PLY`, `TURN_PLY`, `SACRIFICE_PLY`, `HERO_HOME` (the piece the light finds) and `ENGINE_ARROWS`, which name squares, so they need your position. The mate's arrows work themselves out |
| The crowd's signs | `SIGNS`: `a` before the mate, `b` after, `x` and `y` in the window |
| The papers | `Props`: resume, offer letter, recruiter's card, the ACCEPTED stamp and REMATCH? |
| The clock's deadline | `hoursLeft`, and UNTIL FRIDAY, REPLIED and REMATCH? in `Plate` |
| The wall sign | `WallSign`: SILENCE PLEASE, then APPLAUSE, QUIETLY |
| The camera and its labels | `KEYS` for where it looks, `CAMS` for what the label says |
| The piece that falls | `FallingQueen` and `FallenQueen` draw `p="Q"`, `QUEEN_X` is where it lands, and `QueenLight` is its light |
| The other boards | `OTHER_BOARDS` |
| The voiceless caption | `MATE_WORD` in the captions section, CHECKMATE |
| The comment keyword | `KEYWORD` at the top (the result card and the caption's highlight read it) and `plan.json` |
- **Scene ids in the code:** none. It only reads events, and voiceless events are found through the last `plyN`, so you can renumber scenes freely.
- **Panels are sized to our words.** The banner and the keyword shrink to fit. Black's plate holds about 20 letters of name, White's fewer because it shares the row with UNTIL FRIDAY, the letter's two lines hold about 11 letters each, the card about 9, and a sign is as wide as its words, so check `SIGNS` against the window.
- **`story.json` events that no longer match a word stop the render,** which is what you want. The move list holds 7 moves by default: `Scoresheet` gives each row 48 px, so a longer game needs a smaller row.
### The code
`plan.json`, our demo's script:
```json
{
"lines": [
{
"id": "l01",
"text": "Good evening. We join your career at move one."
},
{
"id": "l02",
"text": "White opens with a resume. Black, a recruiter."
},
{
"id": "l03",
"text": "White still has the safe job, on its original square."
},
{
"id": "l04",
"text": "An offer arrives. Less pay, real work."
},
{
"id": "l05",
"text": "Clock pressure. White has until Friday."
},
{
"id": "l06",
"text": "The engine says decline. Everyone's mother says decline."
},
{
"id": "l07",
"text": "Oh. White moves the knight. White gives up the queen."
},
{
"id": "l08",
"text": "The safe job is gone. The engine gives no chance."
},
{
"id": "l09",
"text": "But look at the bishop."
},
{
"id": "l10",
"text": "Seven moves. Best blunder of the decade."
},
{
"id": "l11",
"text": "White has accepted the offer. Black wants a rematch."
},
{
"id": "l12",
"text": "Comment mate and I'll send you the skill that made this.",
"say": "Comment, mate, and I'll send you the skill that made this."
}
],
"scenes": [
{
"id": "s01",
"lines": [
"l01"
],
"lead": 0.4,
"tail": 0.05
},
{
"id": "s02",
"lines": [
"l02"
],
"lead": 0.05,
"tail": 0.05
},
{
"id": "s03",
"lines": [
"l03"
],
"lead": 0.05,
"tail": 0.05
},
{
"id": "s04",
"lines": [
"l04"
],
"lead": 0.05,
"tail": 0.05
},
{
"id": "s05",
"lines": [
"l05"
],
"lead": 0.05,
"tail": 0.05
},
{
"id": "s06",
"lines": [
"l06"
],
"lead": 0.05,
"tail": 0.05
},
{
"id": "s07",
"lines": [
"l07"
],
"lead": 0.05,
"tail": 0.05
},
{
"id": "s08",
"lines": [
"l08"
],
"lead": 0.4,
"tail": 0.05
},
{
"id": "s09",
"lines": [
"l09"
],
"lead": 0.1,
"tail": 0.05
},
{
"id": "s10",
"sec": 3.0
},
{
"id": "s11",
"lines": [
"l10"
],
"lead": 0.05,
"tail": 0.05
},
{
"id": "s12",
"lines": [
"l11"
],
"lead": 0.05,
"tail": 0.05
},
{
"id": "s13",
"lines": [
"l12"
],
"lead": 0.05,
"tail": 0.05
}
]
}
```
`src/index.ts`:
```ts
import { registerRoot } from 'remotion';
import { Root } from './Root';
registerRoot(Root);
```
`src/Root.tsx`:
```tsx
import React from 'react';
import { Composition } from 'remotion';
import { Checkmate } from './frames/Checkmate';
import spec from './spec.json';
export const Root: React.FC = () => (
<Composition id="Film-9x16" component={Checkmate} durationInFrames={Math.max(1, (spec as { durationInFrames: number }).durationInFrames)} fps={30} width={1080} height={1920} />
);
```
`src/frames/core.tsx`, the shared runtime:
```tsx
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { continueRender, delayRender, staticFile, useCurrentFrame } from 'remotion';
import spec from '../spec.json';
/* The shared frame runtime. One frame runs through the whole film; the clock comes from the spoken words. */
export const clamp = (x: number, a = 0, b = 1) => Math.min(b, Math.max(a, x));
export const ramp = (t: number, a: number, b: number) => clamp((t - a) / (b - a));
export const easeOut = (x: number) => 1 - Math.pow(1 - x, 3);
export const easeInOut = (x: number) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
export const easeIn = (x: number) => x * x * x;
export const back = (x: number) => { const c = 1.70158; return 1 + (c + 1) * Math.pow(x - 1, 3) + c * Math.pow(x - 1, 2); };
export const hash = (n: number) => { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); };
export type Word = { w: string; t0: number; t1: number; scene: string };
export type SceneT = { id: string; t0: number; t1: number; words: Word[]; p: Record<string, unknown> };
export type StoryEvent = { id: string; scene: string; word?: string; off?: number };
export type StoryJson = { events: StoryEvent[] };
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9']/g, '');
type SpecBeat = { id: string; from: number; dur: number; words: { w: string; f0: number; f1: number }[]; p: Record<string, unknown> };
const FPS = (spec as { fps: number }).fps;
export const SCENES: SceneT[] = (spec as unknown as { beats: SpecBeat[] }).beats.map((b) => ({
id: b.id,
t0: b.from / FPS,
t1: (b.from + b.dur) / FPS,
p: b.p ?? {},
words: b.words.map((w) => ({ w: w.w, t0: (b.from + w.f0) / FPS, t1: (b.from + w.f1) / FPS, scene: b.id })),
}));
export const WORDS: Word[] = SCENES.flatMap((s) => s.words);
export const DURATION = (spec as { durationInFrames: number }).durationInFrames / FPS;
/** The film second of an event: a scene's start, or a word in it, plus an offset. Throws on a typo, so a wrong anchor fails the render. */
export function eventTime(e: StoryEvent): number {
const s = SCENES.find((x) => x.id === e.scene);
if (!s) throw new Error(`event ${e.id}: no scene ${e.scene}`);
let t = s.t0;
if (e.word) {
const w = s.words.find((x) => norm(x.w).startsWith(norm(e.word as string)));
if (!w) throw new Error(`event ${e.id}: no word "${e.word}" in ${e.scene} (${s.words.map((x) => x.w).join(' ')})`);
t = w.t0;
}
return t + (e.off ?? 0);
}
export const makeE = (story: StoryJson) => {
const map = new Map(story.events.map((e) => [e.id, eventTime(e)]));
return (id: string) => {
const v = map.get(id);
if (v === undefined) throw new Error(`unknown event ${id}`);
return v;
};
};
const TimeCtx = createContext(0);
export const useT = () => useContext(TimeCtx);
export const Clock: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const f = useCurrentFrame();
return <TimeCtx.Provider value={f / FPS}>{children}</TimeCtx.Provider>;
};
/** One caption chunk at a time, cut to fit rows of `perRow` cells; `wi` is the word being spoken, or -1. */
export type Chunk = { t0: number; rows: { s: string; map: number[] }[]; words: Word[] };
const clean = (w: string) => w.replace(/[.,;:!?"]/g, '');
export function buildChunks(perRow: number, maxRows: number): Chunk[] {
const chunks: Chunk[] = [];
for (const scene of SCENES) {
let cur: Word[] = [];
const flush = () => {
if (!cur.length) return;
const rows: { s: string; map: number[] }[] = [{ s: '', map: [] }];
cur.forEach((w, i) => {
const up = clean(w.w).toUpperCase();
let r = rows[rows.length - 1];
if (r.s.length && r.s.length + 1 + up.length > perRow) { rows.push({ s: '', map: [] }); r = rows[rows.length - 1]; }
if (r.s.length) { r.s += ' '; r.map.push(-1); }
for (let k = 0; k < up.length; k++) r.map.push(i);
r.s += up;
});
chunks.push({ t0: cur[0].t0, rows, words: cur });
cur = [];
};
for (const w of scene.words) {
const trial = [...cur, w];
let rowsUsed = 1, len = 0;
for (const x of trial) {
const L = clean(x.w).length;
if (len && len + 1 + L > perRow) { rowsUsed += 1; len = L; } else len += (len ? 1 : 0) + L;
}
if (rowsUsed > maxRows) flush();
cur.push(w);
if (/[.!?]$/.test(w.w)) flush();
}
flush();
}
return chunks;
}
/** Loads the frame's fonts before the first frame. The handle is taken inside React: a module-level one can outlive its render. */
export const Fonts: React.FC<{ faces: { family: string; file: string; weight?: string; style?: string }[]; children: React.ReactNode }> = ({ faces, children }) => {
const [handle] = useState(() => delayRender('fonts', { timeoutInMilliseconds: 120000 }));
const [ready, setReady] = useState(false);
const list = useMemo(() => faces, [faces]);
useEffect(() => {
Promise.all(list.map((x) => {
const ff = new FontFace(x.family, `url(${staticFile(`fonts/${x.file}`)})`, { weight: x.weight ?? '400', style: x.style ?? 'normal' });
return ff.load().then((l) => { (document.fonts as unknown as { add: (f: FontFace) => void }).add(l); });
}))
.then(() => document.fonts.ready)
.then(() => { setReady(true); continueRender(handle); })
.catch((e) => { console.error('font load failed', e); setReady(true); continueRender(handle); });
}, [handle, list]);
return ready ? <>{children}</> : null;
};
```
`src/frames/story.json`, our demo's events:
```json
{
"events": [
{
"id": "on",
"scene": "s01"
},
{
"id": "ply1",
"scene": "s01",
"word": "join"
},
{
"id": "ply2",
"scene": "s01",
"word": "move"
},
{
"id": "ply3",
"scene": "s02",
"word": "resume",
"off": -0.25
},
{
"id": "resume",
"scene": "s02",
"word": "resume"
},
{
"id": "ply4",
"scene": "s02",
"word": "recruiter",
"off": -0.15
},
{
"id": "recruiter",
"scene": "s02",
"word": "recruiter"
},
{
"id": "ply5",
"scene": "s03",
"word": "White"
},
{
"id": "ply6",
"scene": "s03",
"word": "safe",
"off": -0.2
},
{
"id": "job",
"scene": "s03",
"word": "job"
},
{
"id": "square",
"scene": "s03",
"word": "original"
},
{
"id": "ply7",
"scene": "s04",
"word": "offer",
"off": -0.15
},
{
"id": "offer",
"scene": "s04",
"word": "offer"
},
{
"id": "less",
"scene": "s04",
"word": "Less"
},
{
"id": "ply8",
"scene": "s04",
"word": "real",
"off": -0.1
},
{
"id": "real",
"scene": "s04",
"word": "real"
},
{
"id": "clock",
"scene": "s05",
"word": "Clock"
},
{
"id": "friday",
"scene": "s05",
"word": "Friday"
},
{
"id": "engine",
"scene": "s06",
"word": "engine"
},
{
"id": "decline",
"scene": "s06",
"word": "decline"
},
{
"id": "mother",
"scene": "s06",
"word": "mother"
},
{
"id": "decline2",
"scene": "s06",
"word": "mother",
"off": 0.8
},
{
"id": "oh",
"scene": "s07",
"word": "Oh"
},
{
"id": "ply9",
"scene": "s07",
"word": "knight",
"off": -0.15
},
{
"id": "gives",
"scene": "s07",
"word": "gives"
},
{
"id": "ply10",
"scene": "s07",
"word": "queen"
},
{
"id": "low",
"scene": "s08"
},
{
"id": "gasp",
"scene": "s08",
"word": "job"
},
{
"id": "gone",
"scene": "s08",
"word": "gone"
},
{
"id": "chance",
"scene": "s08",
"word": "no"
},
{
"id": "look",
"scene": "s09",
"word": "look"
},
{
"id": "ply11",
"scene": "s09",
"word": "bishop"
},
{
"id": "ply12",
"scene": "s10",
"off": 0.25
},
{
"id": "ply13",
"scene": "s10",
"off": 1.0
},
{
"id": "tip",
"scene": "s10",
"off": 1.75
},
{
"id": "card",
"scene": "s10",
"off": 2.25
},
{
"id": "seven",
"scene": "s11",
"word": "Seven"
},
{
"id": "blunder",
"scene": "s11",
"word": "blunder"
},
{
"id": "decade",
"scene": "s11",
"word": "decade"
},
{
"id": "accepted",
"scene": "s12",
"word": "accepted"
},
{
"id": "rematch",
"scene": "s12",
"word": "rematch"
},
{
"id": "cta",
"scene": "s13"
}
]
}
```
`src/frames/Checkmate.tsx`:
```tsx
import React from 'react';
import { AbsoluteFill } from 'remotion';
import story from './story.json';
import { back, buildChunks, clamp, Clock, easeIn, easeInOut, easeOut, Fonts, hash, makeE, ramp, useT, type StoryJson, type Word } from './core';
/* Checkmate: a hushed chess broadcast, where a real game is the story. Board, evaluation bar, annotated move list, clocks and a hall cam
that follows the game. 1080 x 1920. Time comes from the spoken words through story.json, so a re-voice moves every move with its word. */
const E = makeE(story as StoryJson);
/* ---------- the story's strings: change these first ---------- */
const KEYWORD = 'MATE';
/** The whole game. Every square in the film is computed from this line, and an illegal move or a wrong + or # stops the render. */
const GAME = '1.e4 e5 2.Nf3 d6 3.Bc4 Bg4 4.Nc3 g6 5.Nxe5 Bxd1 6.Bxf7+ Ke7 7.Nd5#';
const TITLE = 'THE GAME OF YOUR CAREER', EVENT = 'OPEN PLAN OPEN ROUND 9 BOARD 1';
const WHITE = { name: 'YOU', elo: '1240' }, BLACK = { name: 'THE JOB MARKET', elo: '2810' };
const RESULT = { score: '1-0', line: 'WHITE WINS' };
/** Tags that ride on a piece: `home` is the square the piece starts on. */
const TAGS = [
{ home: 'd1', text: 'SAFE JOB', at: 'job', color: '#f7c631', dx: -8, dy: 62 },
{ home: 'b1', text: 'OFFER', at: 'ply7', color: '#1baca6', dx: -74, dy: 26 },
];
/** The piece the turn is about: the light finds it on `look`. */
const HERO_HOME = 'f1';
/** Ply numbers (1 is White's first move) the film treats specially, and the arrows the telestrator draws. */
const PIN_PLY = 6, QUEEN_TAKEN_PLY = 10, TURN_PLY = 11, SACRIFICE_PLY = 9;
const ENGINE_ARROWS: [string, string, string, number][] = [['h2', 'h3', 'engine', 0], ['e1', 'g1', 'decline', 0], ['f3', 'g1', 'decline', 0.3]];
/** [ply number, mark, event]. A later row for the same ply replaces the mark. */
const GRADES: [number, string, string][] = [[6, '!', 'job'], [7, '?!', 'mother'], [9, '??', 'gives'], [9, '!!', 'blunder'], [8, '??', 'decade']];
const NAG: Record<string, string> = { '?!': '#f7c631', '!': '#9fd3a8', '??': '#ca3431', '!!': '#27cfc7' };
/** The call under the move list: [event, text, color]. */
const BANNER: [string, string, string][] = [['on', 'OPENING BOOK', '#6f8aa4'], ['job', 'KNIGHT PINNED', '#9fd3a8'], ['offer', 'NEW IDEA', '#27cfc7'], ['clock', 'TIME TROUBLE', '#ca3431'], ['mother', 'DUBIOUS', '#f7c631'], ['gives', 'BLUNDER', '#ca3431'], ['ply11', 'CHECK', '#f7c631'], ['ply13', 'CHECKMATE', '#f4f6f7'], ['blunder', 'BRILLIANT', '#27cfc7']];
/** The engine's panel: [event, label, value, color]. It only looks one move ahead, which is why it's wrong. */
const ENGINE: [string, string, string, string][] = [['on', 'BEST MOVE', 'STAY PUT', '#e6e9ee'], ['decline', 'BEST MOVE', 'DECLINE', '#f7c631'], ['gives', 'BEST MOVE', 'NOT THAT', '#ca3431'], ['chance', 'CHANCES', '0.0%', '#ca3431'], ['look', 'THINKING', 'ONE SEC', '#27cfc7'], ['ply11', 'THINKING', 'OH', '#27cfc7'], ['ply13', 'ENGINE SAYS', 'SORRY', '#27cfc7']];
/** The depth it claims to search: [event, depth]. */
const DEPTH: [string, number][] = [['on', 1], ['look', 2], ['ply11', 3], ['ply12', 5], ['ply13', 9]];
/** The bar, in pawns. Above 9 reads as mate. */
const EVAL: [string, number][] = [['on', 0.2], ['ply3', 0.4], ['ply6', 0.7], ['ply8', 0.9], ['decline', 0.6], ['ply9', -2.8], ['ply10', -9.1], ['ply11', -7.4], ['ply12', -6.6], ['ply13', 12]];
const SIGNS = [
{ x: 92, y: 96, a: 'DECLINE', b: 'ACCEPT', rot: -5 },
{ x: 268, y: 66, a: 'DECLINE, DEAR', b: 'ACCEPT, DEAR', rot: 3 },
{ x: 462, y: 98, a: 'DECLINE', b: 'I SAID ACCEPT', rot: -3 },
{ x: 650, y: 62, a: 'CALL ME', b: 'CALL ME', rot: 4 },
{ x: 800, y: 104, a: 'DECLINE', b: 'ACCEPT', rot: -4 },
];
const CAMS: [string, string][] = [['on', 'HALL CAM'], ['resume', 'TABLE CAM'], ['offer', 'TABLE CAM'], ['clock', 'CLOCK CAM'], ['mother', 'FAMILY SECTION'], ['oh', 'HALL CAM'], ['accepted', 'TABLE CAM'], ['cta', 'HALL CAM']];
const OTHER_BOARDS = ['LANDLORD 1-0 DEPOSIT', 'DENTIST 1-0 WEEKEND', 'NEPHEW 0-1 BEDTIME'];
const HALL = '#1e2a2f', PANEL = '#2b3a40', DARK = '#6f8aa4', LIGHT = '#e6e9ee', TEAL = '#1baca6', BRIGHT = '#27cfc7', RED = '#ca3431', AMBER = '#f7c631', INK = '#e6e9ee';
const COND = '"Barlow Condensed", sans-serif', MONO = 'Rajdhani, sans-serif';
const FACES = [
{ family: 'Barlow Condensed', file: 'barlow-condensed-latin-600-normal.woff2', weight: '600' },
{ family: 'Barlow Condensed', file: 'barlow-condensed-latin-700-normal.woff2', weight: '700' },
{ family: 'Barlow Condensed', file: 'barlow-condensed-latin-800-normal.woff2', weight: '800' },
{ family: 'Rajdhani', file: 'rajdhani-latin-600-normal.woff2', weight: '600' },
{ family: 'Rajdhani', file: 'rajdhani-latin-700-normal.woff2', weight: '700' },
];
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
/** The last schedule row whose event has passed, and the row's start time. */
const pick = <T extends [string, ...unknown[]]>(rows: T[], t: number): [T, number] => {
let cur = rows[0], at = E(rows[0][0]);
for (const r of rows) if (t >= E(r[0])) { cur = r; at = E(r[0]); }
return [cur, at];
};
/* ---------- the rules: GAME is played on a real board when the film loads, and an illegal move stops the render ---------- */
type Squares = Map<string, string>;
type Pos = { b: Squares; white: boolean; castle: string; ep: string };
type Mv = { from: string; to: string; taken?: string; promo?: string; rook?: [string, string] };
const FILES = 'abcdefgh';
const sq = (f: number, r: number) => (f >= 0 && f < 8 && r >= 0 && r < 8 ? FILES[f] + (r + 1) : '');
const fr = (s: string): [number, number] => [FILES.indexOf(s[0]), Number(s[1]) - 1];
const isWhite = (p: string) => p === p.toUpperCase();
const ORTHO = [[1, 0], [-1, 0], [0, 1], [0, -1]], DIAG = [[1, 1], [1, -1], [-1, 1], [-1, -1]], ALL = [...ORTHO, ...DIAG];
const LEAPS = [[1, 2], [2, 1], [-1, 2], [-2, 1], [1, -2], [2, -1], [-1, -2], [-2, -1]];
const STEPS: Record<string, [number[][], boolean]> = { R: [ORTHO, true], B: [DIAG, true], Q: [ALL, true], N: [LEAPS, false], K: [ALL, false] };
const startBoard = (): Squares => {
const b: Squares = new Map();
Array.from('RNBQKBNR').forEach((p, f) => { b.set(sq(f, 0), p); b.set(sq(f, 1), 'P'); b.set(sq(f, 6), 'p'); b.set(sq(f, 7), p.toLowerCase()); });
return b;
};
/** The squares of the pieces of one color that attack a square. */
function attackers(b: Squares, s: string, white: boolean): string[] {
const [f, r] = fr(s), out: string[] = [];
for (const dx of [-1, 1]) { const a = sq(f + dx, r + (white ? -1 : 1)); if (a && b.get(a) === (white ? 'P' : 'p')) out.push(a); }
for (const [kind, [dirs, slide]] of Object.entries(STEPS)) {
for (const [dx, dy] of dirs) for (let n = 1; n < 8; n++) {
const a = sq(f + dx * n, r + dy * n);
if (!a) break;
const p = b.get(a);
if (p) { if (p === (white ? kind : kind.toLowerCase())) out.push(a); break; }
if (!slide) break;
}
}
return out;
}
const kingOf = (b: Squares, white: boolean) => [...b].find(([, p]) => p === (white ? 'K' : 'k'))?.[0] ?? '';
const inCheck = (b: Squares, white: boolean) => attackers(b, kingOf(b, white), !white).length > 0;
const RIGHTS: Record<string, RegExp> = { K: /e1|h1/, Q: /e1|a1/, k: /e8|h8/, q: /e8|a8/ };
function play(pos: Pos, m: Mv): Pos {
const b = new Map(pos.b), p = b.get(m.from) as string;
if (m.taken) b.delete(m.taken);
b.delete(m.from);
b.set(m.to, m.promo ? (pos.white ? m.promo : m.promo.toLowerCase()) : p);
if (m.rook) { b.set(m.rook[1], b.get(m.rook[0]) as string); b.delete(m.rook[0]); }
const [f, r0] = fr(m.from), r1 = fr(m.to)[1];
return { b, white: !pos.white, castle: Array.from(pos.castle).filter((c) => !RIGHTS[c].test(m.from + m.to)).join(''), ep: p.toUpperCase() === 'P' && Math.abs(r1 - r0) === 2 ? sq(f, (r0 + r1) / 2) : '' };
}
function legalMoves(pos: Pos): Mv[] {
const { b, white } = pos, out: Mv[] = [];
for (const [s, p] of b) {
if (isWhite(p) !== white) continue;
const [f, r] = fr(s), kind = p.toUpperCase();
if (kind === 'P') {
const d = white ? 1 : -1;
const add = (to: string, taken?: string) => (fr(to)[1] === (white ? 7 : 0) ? ['Q', 'R', 'B', 'N'] : [undefined]).forEach((promo) => out.push({ from: s, to, taken, promo }));
const one = sq(f, r + d), two = sq(f, r + 2 * d);
if (one && !b.has(one)) { add(one); if (r === (white ? 1 : 6) && !b.has(two)) add(two); }
for (const dx of [-1, 1]) {
const to = sq(f + dx, r + d);
if (to && b.has(to) && isWhite(b.get(to) as string) !== white) add(to, to);
else if (to && to === pos.ep) add(to, sq(f + dx, r));
}
continue;
}
const [dirs, slide] = STEPS[kind];
for (const [dx, dy] of dirs) for (let n = 1; n < 8; n++) {
const to = sq(f + dx * n, r + dy * n);
if (!to) break;
const q = b.get(to);
if (q && isWhite(q) === white) break;
out.push({ from: s, to, taken: q ? to : undefined });
if (q || !slide) break;
}
}
const rank = white ? '1' : '8', king = `e${rank}`, safe = (x: string) => !attackers(b, x, !white).length, empty = (...x: string[]) => x.every((y) => !b.has(y + rank));
if (b.get(king) === (white ? 'K' : 'k') && safe(king)) {
if (pos.castle.includes(white ? 'K' : 'k') && empty('f', 'g') && safe(`f${rank}`)) out.push({ from: king, to: `g${rank}`, rook: [`h${rank}`, `f${rank}`] });
if (pos.castle.includes(white ? 'Q' : 'q') && empty('b', 'c', 'd') && safe(`d${rank}`)) out.push({ from: king, to: `c${rank}`, rook: [`a${rank}`, `d${rank}`] });
}
return out.filter((m) => !inCheck(play(pos, m).b, white));
}
const SAN = /^(?:(O-O(?:-O)?)|([KQRBN])?([a-h])?([1-8])?(x)?([a-h][1-8])(?:=([QRBN]))?)([+#])?$/;
type Ply = Mv & { san: string; white: boolean; no: number; check: boolean; mate: boolean; after: Squares };
/** Plays the game. Throws, naming the move, on anything a chess player would object to. */
function replay(game: string): Ply[] {
let pos: Pos = { b: startBoard(), white: true, castle: 'KQkq', ep: '' };
return game.replace(/\d+\.+/g, ' ').trim().split(/\s+/).map((san, i) => {
const where = `GAME move ${Math.floor(i / 2) + 1}${i % 2 ? '...' : '.'}${san}`;
const m = SAN.exec(san);
if (!m) throw new Error(`${where}: can't read this move`);
const found = legalMoves(pos).filter((c) => {
const kind = (pos.b.get(c.from) as string).toUpperCase();
if (m[1]) return !!c.rook && (m[1] === 'O-O') === (c.to[0] === 'g');
return !c.rook && kind === (m[2] ?? 'P') && c.to === m[6] && (!m[3] || c.from[0] === m[3]) && (!m[4] || c.from[1] === m[4]) && !!m[5] === !!c.taken && (c.promo ?? '') === (m[7] ?? '');
});
if (found.length !== 1) throw new Error(`${where}: ${found.length ? 'ambiguous, say which piece' : 'not legal in this position'}`);
const next = play(pos, found[0]), check = inCheck(next.b, next.white), mate = check && !legalMoves(next).length;
if (check !== !!m[8] || mate !== (m[8] === '#')) throw new Error(`${where}: this move is ${mate ? 'mate' : check ? 'check' : 'not a check'}, so its + or # is wrong`);
const ply: Ply = { ...found[0], san, white: pos.white, no: Math.floor(i / 2) + 1, check, mate, after: next.b };
pos = next;
return ply;
});
}
const PLIES = replay(GAME);
const LAST = PLIES[PLIES.length - 1];
if (!LAST.mate) throw new Error('GAME must end in checkmate: the whole film builds to it');
const plyAt = (i: number) => E(`ply${i + 1}`);
PLIES.forEach((_, i) => { if (i && plyAt(i) <= plyAt(i - 1)) throw new Error(`ply${i + 1} must come after ply${i} in story.json`); });
if ((story as StoryJson).events.filter((e) => /^ply\d+$/.test(e.id)).length !== PLIES.length) throw new Error('story.json needs one plyN event per move in GAME');
const MATE_AT = plyAt(PLIES.length - 1), KING_SQ = kingOf(LAST.after, !LAST.white);
/** The arrows that converge on mate: whatever really attacks the king, and each square around it. */
const MATE_LINES: [string, string][] = (() => {
const b = LAST.after, [kf, kr] = fr(KING_SQ), lines: [string, string][] = attackers(b, KING_SQ, LAST.white).map((a) => [a, KING_SQ]);
for (const [dx, dy] of ALL) {
const s = sq(kf + dx, kr + dy), p = s ? b.get(s) : undefined;
if (!s || (p && isWhite(p) !== LAST.white)) continue;
const a = attackers(b, s, LAST.white)[0];
if (a) lines.push([a, s]);
}
return lines;
})();
/* Who stands where, and when: each piece keeps one identity through the film, so it slides instead of popping. */
type Pc = { id: number; p: string; home: string; hops: { at: number; from: string; to: string }[]; dies: number; promo?: [number, string] };
const SLIDE = 0.26, LEAD = SLIDE; // a move starts LEAD early, so it lands on its event
const PIECES: Pc[] = (() => {
const out: Pc[] = [], on = new Map<string, Pc>();
[...startBoard()].forEach(([s, p], id) => { const c: Pc = { id, p, home: s, hops: [], dies: Infinity }; out.push(c); on.set(s, c); });
PLIES.forEach((m, i) => {
const at = plyAt(i) - LEAD, mover = on.get(m.from) as Pc;
if (m.taken) { (on.get(m.taken) as Pc).dies = at + SLIDE; on.delete(m.taken); }
on.delete(m.from); mover.hops.push({ at, from: m.from, to: m.to }); on.set(m.to, mover);
if (m.promo) mover.promo = [at + SLIDE, m.promo];
if (m.rook) { const r = on.get(m.rook[0]) as Pc; on.delete(m.rook[0]); r.hops.push({ at, from: m.rook[0], to: m.rook[1] }); on.set(m.rook[1], r); }
});
return out;
})();
const pieceByHome = (s: string) => PIECES.find((c) => c.home === s) as Pc;
const lastPly = (t: number) => { let n = -1; PLIES.forEach((_, i) => { if (t >= plyAt(i) - LEAD) n = i; }); return n; };
/* ---------- light and time ---------- */
/** The house lights: they go down bank by bank on the low point and come up with the mate. 1 is full. */
const houseAt = (t: number) => {
const a = E('low'), up = ramp(t, MATE_AT, MATE_AT + 0.28);
const down = 0.28 * ramp(t, a, a + 0.08) + 0.26 * ramp(t, a + 0.3, a + 0.38) + 0.28 * ramp(t, a + 0.6, a + 0.68);
return lerp(lerp(1, 0.22, down / 0.82), 1, up) + 0.12 * ramp(t, E('look'), E('look') + 0.3) * (1 - up);
};
const spotAt = (t: number) => ramp(t, E('look') - 0.05, E('look') + 0.16) * (1 - ramp(t, MATE_AT, MATE_AT + 0.25));
const SQ = 78, BOARD = SQ * 8, BX = 108, BY = 250;
const px = (s: string): [number, number] => { const [f, r] = fr(s); return [f * SQ, (7 - r) * SQ]; };
const mid = (s: string): [number, number] => { const [x, y] = px(s); return [x + SQ / 2, y + SQ / 2]; };
const placeAt = (c: Pc, t: number) => {
let s = c.home, hop: Pc['hops'][number] | null = null;
for (const h of c.hops) { if (t < h.at) break; s = h.to; hop = h; }
const [x, y] = px(s);
if (hop && t < hop.at + SLIDE) {
const k = (t - hop.at) / SLIDE, q = easeInOut(k), [x0, y0] = px(hop.from), leap = c.p.toUpperCase() === 'N' ? 26 : 8;
return { x: lerp(x0, x, q), y: lerp(y0, y, q) - Math.sin(k * Math.PI) * leap, lift: Math.sin(k * Math.PI) };
}
return { x, y, lift: 0 };
};
/** Every landing knocks the board a little, a capture more, and the mate a lot. */
const KNOCKS: [number, number][] = PLIES.map((m, i) => [plyAt(i) - LEAD + SLIDE, m.mate ? 11 : m.taken ? 4 : 1.5]);
const knock = (t: number) => KNOCKS.reduce((a, [at, amp]) => (t >= at ? a + amp * Math.sin((t - at) * 52) * Math.exp(-(t - at) * 12) : a), 0);
/** The taps: 1.2 a second, climbing to 4 over the first 4 s of pressure, back to 1.2 through the low point, stopped by the mate. 1 is the hit. */
const tapAt = (t: number) => {
const a = E('clock'), b = E('low');
if (t < a || t >= MATE_AT) return 0;
const count = (u: number) => (u < 4 ? 1.2 * u + 0.35 * u * u : 10.4 + 4 * (u - 4));
const ph = t < b ? count(t - a) : count(b - a) + 1.2 * (t - b);
return Math.exp(-(ph - Math.floor(ph)) * 7);
};
const fmt = (s: number) => { const v = Math.max(0, Math.floor(s)), m = Math.floor(v / 60); return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}:${String(v % 60).padStart(2, '0')}`; };
/** White's other clock: 72 hours until Friday, burning away from the moment the pressure starts. */
const hoursLeft = (t: number) => 72 * 3600 - (Math.min(MATE_AT, t) - E('clock')) * 6400;
/** A side's clock runs only on its own move, 17 x fast so the digits are alive. */
const clockLeft = (white: boolean, start: number, t: number) => {
let left = start;
for (let i = -1; i < PLIES.length; i++) {
if ((i + 1) % 2 === (white ? 0 : 1) && i + 1 < PLIES.length) left -= 17 * Math.max(0, Math.min(t, plyAt(i + 1)) - (i < 0 ? 0 : plyAt(i)));
}
return left;
};
/* ---------- the pieces, drawn in a 100 x 100 box ---------- */
const dot = (x: number, y: number, r: number) => `M${x - r} ${y} a${r} ${r} 0 1 0 ${r * 2} 0 a${r} ${r} 0 1 0 ${-r * 2} 0 Z `;
const FOOT = 'M27 78 H73 Q79 78 79 84 V92 H21 V84 Q21 78 27 78 Z';
const COLLAR = 'M30 62 H70 V68 H30 Z M37 68 H63 C62 72 65 75 70 78 H30 C35 75 38 72 37 68 Z ';
const ART: Record<string, string> = {
P: `${dot(50, 30, 15)} M38 44 H62 V50 H56 C57 60 62 70 68 78 H32 C38 70 43 60 44 50 H38 Z ${FOOT}`,
R: `M28 13 H38 V22 H45 V13 H55 V22 H62 V13 H72 V35 L65 42 H35 L28 35 Z M36 42 H64 C64 56 65 68 71 78 H29 C35 68 36 56 36 42 Z ${FOOT}`,
B: `${dot(50, 11, 6)} M50 16 C63 26 70 36 62 48 H38 C30 36 37 26 50 16 Z M35 48 H65 V55 H58 C58 64 62 72 68 78 H32 C38 72 42 64 42 55 H35 Z ${FOOT}`,
N: `M30 78 C32 66 44 60 51 50 C44 54 38 53 32 57 L24 62 C18 61 14 54 18 49 L37 29 L35 12 L46 22 C66 21 80 44 74 78 Z ${FOOT}`,
Q: `${dot(17, 25, 5.5)}${dot(32, 17, 5.5)}${dot(50, 12, 5.5)}${dot(68, 17, 5.5)}${dot(83, 25, 5.5)} M19 29 L33 48 L33 22 L44 46 L50 17 L56 46 L67 22 L67 48 L81 29 L71 62 H29 Z ${COLLAR}${FOOT}`,
K: `M46.5 3 H53.5 V10 H60 V17 H53.5 V26 H46.5 V17 H40 V10 H46.5 Z M50 27 C58 18 80 20 80 37 C80 47 71 54 69 62 H31 C29 54 20 47 20 37 C20 20 42 18 50 27 Z ${COLLAR}${FOOT}`,
};
const INNER: Record<string, string> = {
P: '', R: 'M36 42 H64', Q: 'M30 62 H70', K: 'M50 27 V60 M30 62 H70',
B: 'M50 26 V38 M44 32 H56', N: `${dot(37, 38, 2.6)} M48 25 C60 32 66 50 64 72`,
};
const PieceArt: React.FC<{ p: string; x: number; y: number; s?: number; rot?: number; o?: number }> = ({ p, x, y, s = SQ / 100, rot = 0, o = 1 }) => {
const w = isWhite(p), k = p.toUpperCase();
return (
<g transform={`translate(${x} ${y}) rotate(${rot} ${50 * s} ${92 * s}) scale(${s})`} opacity={o}>
<ellipse cx="50" cy="93" rx="33" ry="6" fill="rgba(8,14,18,.34)" />
<path d={ART[k]} fill={w ? 'url(#pw)' : 'url(#pb)'} stroke={w ? '#141d22' : '#070b0d'} strokeWidth="3.4" strokeLinejoin="round" />
{INNER[k] ? <path d={INNER[k]} fill="none" stroke={w ? '#55666f' : '#6f8aa4'} strokeWidth="2.6" strokeLinecap="round" /> : null}
</g>
);
};
const Defs: React.FC = () => (
<svg width={0} height={0} style={{ position: 'absolute' }}>
<defs>
<linearGradient id="pw" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#ffffff" /><stop offset="0.55" stopColor="#f0f3f5" /><stop offset="1" stopColor="#b9c5cc" /></linearGradient>
<linearGradient id="pb" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#46555e" /><stop offset="0.5" stopColor="#1e262b" /><stop offset="1" stopColor="#0e1417" /></linearGradient>
<radialGradient id="sheen" cx="0.36" cy="0.26" r="0.95"><stop offset="0" stopColor="#ffffff" stopOpacity="0.13" /><stop offset="0.5" stopColor="#ffffff" stopOpacity="0" /><stop offset="1" stopColor="#0a1820" stopOpacity="0.34" /></radialGradient>
<radialGradient id="bulb"><stop offset="0" stopColor="#ffffff" /><stop offset="0.16" stopColor="#ffffff" stopOpacity="0.85" /><stop offset="1" stopColor="#bfe8ee" stopOpacity="0" /></radialGradient>
<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#1b3a42" /><stop offset="1" stopColor="#10222a" /></linearGradient>
<linearGradient id="cone" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#d9f2ee" stopOpacity="0.28" /><stop offset="1" stopColor="#d9f2ee" stopOpacity="0" /></linearGradient>
<linearGradient id="wood" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#4a3a2c" /><stop offset="0.12" stopColor="#35291f" /><stop offset="1" stopColor="#181210" /></linearGradient>
</defs>
</svg>
);
/* ---------- the board ---------- */
const Arrow: React.FC<{ from: string; to: string; at: number; until?: number; color: string; w?: number; o?: number; d?: number }> = ({ from, to, at, until = Infinity, color, w = 15, o = 0.86, d = 0.18 }) => {
const t = useT();
if (t < at || t > until) return null;
const [x0, y0] = mid(from), [x1, y1] = mid(to), len = Math.hypot(x1 - x0, y1 - y0), a = (Math.atan2(y1 - y0, x1 - x0) * 180) / Math.PI;
const k = easeOut(ramp(t, at, at + d)), L = Math.max(30, (len - 12) * k), fade = until === Infinity ? 1 : 1 - ramp(t, until - 0.16, until);
return (
<g transform={`translate(${x0} ${y0}) rotate(${a})`} opacity={o * fade}>
<path d={`M16 ${-w / 2} L${L - 26} ${-w / 2} L${L - 26} ${-w * 1.25} L${L} 0 L${L - 26} ${w * 1.25} L${L - 26} ${w / 2} L16 ${w / 2} Z`} fill={color} />
</g>
);
};
const Ring: React.FC<{ s: string; at: number; color: string; big?: boolean }> = ({ s, at, color, big }) => {
const t = useT(), k = (t - at) / (big ? 0.8 : 0.42);
if (k < 0 || k > 1) return null;
const [x, y] = mid(s);
return <circle cx={x} cy={y} r={SQ * (0.42 + (big ? 1.7 : 0.45) * easeOut(k))} fill="none" stroke={color} strokeWidth={(big ? 12 : 6) * (1 - k)} opacity={1 - k} />;
};
const Board: React.FC = () => {
const t = useT();
const n = lastPly(t), house = houseAt(t), j = knock(t);
const last = n >= 0 ? PLIES[n] : null;
const checked = last?.check ? kingOf(last.after, !last.white) : '';
const heroXY = placeAt(pieceByHome(HERO_HOME), t), spot = spotAt(t);
const dark = (1 - house) * 0.92;
return (
<div style={{ position: 'absolute', left: BX, top: BY, width: BOARD, height: BOARD, transform: `translate(${j * 0.4}px, ${j}px) scale(${1 + 0.035 * (t >= MATE_AT ? Math.exp(-(t - MATE_AT) * 6) : 0)})`, boxShadow: `0 0 0 3px #101a1f, 0 18px 40px rgba(0,0,0,.5), 0 0 70px rgba(150,205,215,${0.16 * house})` }}>
<svg viewBox={`0 0 ${BOARD} ${BOARD}`} width={BOARD} height={BOARD} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}>
{Array.from({ length: 64 }, (_, i) => { const f = i % 8, r = Math.floor(i / 8); return <rect key={i} x={f * SQ} y={(7 - r) * SQ} width={SQ} height={SQ} fill={(f + r) % 2 ? LIGHT : DARK} />; })}
<rect x="0" y="0" width={BOARD} height={BOARD} fill="url(#sheen)" />
{last ? [last.from, last.to].map((s, i) => <rect key={s} x={px(s)[0]} y={px(s)[1]} width={SQ} height={SQ} fill={AMBER} opacity={i ? 0.52 : 0.34} />) : null}
{checked ? <circle cx={mid(checked)[0]} cy={mid(checked)[1]} r={SQ * 0.58} fill={RED} opacity={0.75 + 0.2 * Math.sin(t * 16)} /> : null}
{t >= E('square') && t < plyAt(SACRIFICE_PLY - 1) ? <rect x={px(TAGS[0].home)[0] + 4} y={px(TAGS[0].home)[1] + 4} width={SQ - 8} height={SQ - 8} fill="none" stroke={AMBER} strokeWidth={6} opacity={0.5 + 0.4 * Math.sin((t - E('square')) * 7)} /> : null}
{PIECES.map((c) => {
const dead = t >= c.dies;
if (dead && t > c.dies + 0.16) return null;
const dealAt = (fr(c.home)[0] + (c.home[1] === '1' || c.home[1] === '8' ? 0 : 2)) * 0.05 + (isWhite(c.p) ? 0 : 0.03);
const deal = back(ramp(t, dealAt, dealAt + 0.22));
const pl = placeAt(c, t);
const u = c.p === (LAST.white ? 'k' : 'K') ? t - E('tip') : -1, rest = u - 0.32;
const rot = u < 0 ? 0 : -86 * easeIn(ramp(u, 0, 0.32)) + (rest > 0 ? 7 * Math.sin(rest * 18) * Math.exp(-rest * 5) : 0);
const p = c.promo && t >= c.promo[0] ? (isWhite(c.p) ? c.promo[1] : c.promo[1].toLowerCase()) : c.p;
if (dead && c.home === TAGS[0].home) return null; // this one falls off the board instead: see FallingQueen
return <PieceArt key={c.id} p={p} x={pl.x} y={pl.y - (1 - deal) * 90 - pl.lift * 8} s={(SQ / 100) * (1 + pl.lift * 0.1)} rot={rot} o={dead ? 1 - (t - c.dies) / 0.16 : ramp(t, dealAt, dealAt + 0.06)} />;
})}
{/* the telestrator: the pin, the engine's safe ideas, the idea it missed, the mate */}
<Arrow from={PLIES[PIN_PLY - 1].to} to={TAGS[0].home} at={E('job') - 0.1} until={E('clock')} color={AMBER} w={13} d={0.3} />
{ENGINE_ARROWS.map(([a, b, ev, off]) => <Arrow key={a + b} from={a} to={b} at={E(ev) + off} until={E('oh')} color={TEAL} w={12} o={0.75} />)}
<Arrow from={PLIES[QUEEN_TAKEN_PLY - 1].from} to={PLIES[QUEEN_TAKEN_PLY - 1].to} at={plyAt(QUEEN_TAKEN_PLY - 1) - 0.5} until={plyAt(QUEEN_TAKEN_PLY - 1) + 0.5} color={RED} d={0.3} />
<Arrow from={PLIES[TURN_PLY - 1].from} to={PLIES[TURN_PLY - 1].to} at={E('look') + 0.05} until={plyAt(TURN_PLY - 1) + 0.25} color={BRIGHT} d={0.4} />
{MATE_LINES.map(([a, b], i) => <Arrow key={a + b} from={a} to={b} at={MATE_AT + 0.08 + i * 0.07} until={E('seven')} color={i === 0 ? RED : BRIGHT} w={i === 0 ? 17 : 12} d={0.14} />)}
{PLIES.map((m, i) => <Ring key={i} s={m.to} at={plyAt(i) - LEAD + SLIDE} color={m.mate ? BRIGHT : m.taken ? RED : '#ffffff'} big={m.mate} />)}
</svg>
{/* square names on the edge */}
{Array.from(FILES).map((f, i) => <div key={f} style={{ position: 'absolute', left: i * SQ + 60, top: BOARD - 22, font: `700 20px/1 ${MONO}`, color: i % 2 ? LIGHT : DARK, opacity: 0.9 }}>{f}</div>)}
{[8, 7, 6, 5, 4, 3, 2, 1].map((r, i) => <div key={r} style={{ position: 'absolute', left: 5, top: i * SQ + 4, font: `700 20px/1 ${MONO}`, color: i % 2 ? LIGHT : DARK, opacity: 0.9 }}>{r}</div>)}
{/* the house lights, with the light that finds the piece the turn is about */}
<div style={{ position: 'absolute', inset: 0, background: spot > 0 ? `radial-gradient(circle ${90 + spot * 42}px at ${heroXY.x + SQ / 2}px ${heroXY.y + SQ / 2}px, rgba(4,9,12,${dark * (1 - spot)}) 62%, rgba(4,9,12,${dark}) 100%)` : `rgba(4,9,12,${dark})`, pointerEvents: 'none' }} />
<Tags />
<Graded />
<ResultCard />
</div>
);
};
const Tags: React.FC = () => {
const t = useT();
return (
<>
{TAGS.map((g) => {
const c = pieceByHome(g.home), at = E(g.at);
if (t < at || t >= c.dies - 0.02) return null;
const pl = placeAt(c, t), sway = Math.sin(t * 2.1 + c.id) * 2.5, mates = c.hops.some((h) => h.to === LAST.to && h.at === MATE_AT - LEAD) && t >= MATE_AT;
const k = back(ramp(t, at, at + 0.2)) * (mates ? 1.14 + 0.5 * Math.exp(-(t - MATE_AT) * 4) : 1);
return <div key={g.home} style={{ position: 'absolute', left: pl.x + g.dx, top: pl.y + g.dy, transform: `rotate(${-4 + sway}deg) scale(${k})`, transformOrigin: '100% 50%', padding: '3px 10px 1px', background: g.color, color: '#10191d', font: `800 25px/28px ${COND}`, letterSpacing: '.03em', whiteSpace: 'nowrap', borderRadius: 4, boxShadow: '0 4px 9px rgba(0,0,0,.45)' }}>{g.text}</div>;
})}
</>
);
};
/** The mark pops onto the moved piece as well as the move list. */
const gradeOf = (ply: number, t: number) => GRADES.reduce((g, [p, v, at]) => (p === ply && t >= E(at) ? v : g), '');
const Graded: React.FC = () => {
const t = useT(), g = gradeOf(SACRIFICE_PLY, t);
if (!g || (t >= E('look') && t < E('blunder'))) return null;
const [x, y] = px(PLIES[SACRIFICE_PLY - 1].to), k = back(ramp(t, g === '!!' ? E('blunder') : E('gives'), (g === '!!' ? E('blunder') : E('gives')) + 0.2));
return <div style={{ position: 'absolute', left: x + SQ - 30, top: y - 14, width: 50, height: 50, borderRadius: 25, transform: `scale(${k})`, background: NAG[g], color: g === '!!' ? '#06211f' : '#fff', font: `800 34px/50px ${COND}`, textAlign: 'center', boxShadow: '0 5px 12px rgba(0,0,0,.5)' }}>{g}</div>;
};
/** It lands on the board as the result, then turns over and asks for the comment, and stays. */
const ResultCard: React.FC = () => {
const t = useT();
const at = E('card'), cta = E('cta');
if (t < at) return null;
const k = back(ramp(t, at, at + 0.24));
const flip = ramp(t, cta - 0.04, cta + 0.32), turned = flip >= 0.5;
const face = turned ? (flip - 1) * 180 : flip * 180;
return (
<div style={{ position: 'absolute', left: 92, top: 386, width: 440, height: 232, transform: `perspective(1500px) rotateX(${face}deg) scale(${lerp(1.45, 1, k)}) rotate(-2deg)`, opacity: ramp(t, at, at + 0.05), background: PANEL, border: `5px solid ${turned ? BRIGHT : LIGHT}`, borderRadius: 14, boxShadow: `0 22px 46px rgba(0,0,0,.65), 0 0 ${turned ? 30 + 18 * Math.sin(t * 3.2) : 0}px rgba(39,207,199,.7)`, textAlign: 'center', color: INK }}>
{turned ? (
<>
<div style={{ font: `700 58px/1 ${COND}`, letterSpacing: '.14em', marginTop: 24 }}>COMMENT</div>
<div style={{ font: `800 ${Math.min(128, 780 / KEYWORD.length)}px/128px ${COND}`, color: BRIGHT, letterSpacing: '.04em', marginTop: 2, whiteSpace: 'nowrap' }}>{KEYWORD}</div>
</>
) : (
<>
<div style={{ font: `700 156px/1 ${MONO}`, marginTop: 8 }}>{RESULT.score}</div>
<div style={{ font: `700 42px/1 ${COND}`, letterSpacing: '.18em', color: BRIGHT, marginTop: -6 }}>{RESULT.line}</div>
</>
)}
</div>
);
};
/** The evaluation bar: white rises from the bottom. It trembles on the turn and slams on the mate. */
const EvalBar: React.FC = () => {
const t = useT(), house = houseAt(t);
let v = EVAL[0][1];
for (const [ev, val] of EVAL.slice(1)) v = lerp(v, val, easeInOut(ramp(t, E(ev), E(ev) + (val > 9 ? 0.2 : ev === `ply${QUEEN_TAKEN_PLY}` ? 0.55 : 0.4))));
const shake = t >= plyAt(TURN_PLY - 1) && t < MATE_AT ? Math.sin(t * 74) * 9 + Math.sin(t * 41) * 6 : 0;
const white = clamp(0.5 + v / 19.5, 0.035, 1), mate = v > 9;
return (
<div style={{ position: 'absolute', left: 60, top: BY, width: 40, height: BOARD, background: '#10171b', borderRadius: 5, overflow: 'hidden', boxShadow: 'inset 0 0 0 2px rgba(0,0,0,.5)' }}>
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: BOARD * white + shake, background: `linear-gradient(90deg,#fff,#cfd8dd)`, filter: `brightness(${0.55 + 0.45 * house})` }} />
<div style={{ position: 'absolute', left: 0, width: 40, top: BOARD / 2 - 1, height: 2, background: RED, opacity: 0.8 }} />
<div style={{ position: 'absolute', left: 0, width: 40, textAlign: 'center', font: `700 21px/1 ${MONO}`, ...(v >= 0 ? { bottom: 8, color: '#10171b' } : { top: 8, color: '#e6e9ee' }) }}>{mate ? (t >= E('card') ? RESULT.score : '#') : `${v > 0 ? '+' : ''}${v.toFixed(1)}`}</div>
</div>
);
};
/* ---------- the broadcast around it ---------- */
const Header: React.FC = () => {
const t = useT(), pulse = 0.55 + 0.45 * Math.sin(t * 4.4);
const k = easeOut(ramp(t, 0, 0.5));
return (
<>
<div style={{ position: 'absolute', left: 60, top: 70, font: `800 74px/1 ${COND}`, color: INK, letterSpacing: '.02em', whiteSpace: 'nowrap', clipPath: `inset(-10% ${k >= 1 ? -10 : (1 - k) * 100}% -10% -2%)` }}>{TITLE}</div>
<div style={{ position: 'absolute', left: 62, top: 148, font: `600 29px/1 ${MONO}`, color: DARK, letterSpacing: '.13em', whiteSpace: 'pre' }}>{EVENT}</div>
<div style={{ position: 'absolute', left: 908, top: 88, display: 'flex', alignItems: 'center', gap: 9, background: RED, borderRadius: 6, padding: '6px 12px 4px', font: `800 34px/1 ${COND}`, color: '#fff', letterSpacing: '.06em' }}>
<span style={{ width: 16, height: 16, borderRadius: 8, background: '#fff', opacity: pulse, marginTop: -3 }} />LIVE
</div>
<div style={{ position: 'absolute', left: 852, top: 148, width: 188, textAlign: 'right', font: `600 25px/1 ${MONO}`, color: DARK, letterSpacing: '.06em', whiteSpace: 'nowrap' }}>WHISPERS ONLY</div>
</>
);
};
const VALUE: Record<string, number> = { P: 1, N: 3, B: 3, R: 5, Q: 9 };
const Captured: React.FC<{ white: boolean; t: number }> = ({ white, t }) => {
const taken = (w: boolean) => PIECES.filter((c) => t >= c.dies && isWhite(c.p) !== w);
const sum = (w: boolean) => taken(w).reduce((a, c) => a + VALUE[c.p.toUpperCase()], 0), lead = sum(white) - sum(!white);
return (
<div style={{ display: 'flex', alignItems: 'center', marginLeft: 12 }}>
{taken(white).map((c) => <svg key={c.id} viewBox="0 0 100 100" width={32} height={32} style={{ marginRight: -8 }}><PieceArt p={c.p} x={0} y={0} s={1} /></svg>)}
{lead > 0 ? <span style={{ font: `700 28px/1 ${MONO}`, color: AMBER, marginLeft: 16 }}>+{lead}</span> : null}
</div>
);
};
const Plate: React.FC<{ top: number; who: { name: string; elo: string }; white: boolean }> = ({ top, who, white }) => {
const t = useT(), n = lastPly(t), mover = n + 1 < PLIES.length ? PLIES[n + 1].white : false;
const running = t < MATE_AT && mover === white;
const pressure = white && t >= E('clock'), stopped = t >= MATE_AT;
const left = pressure ? hoursLeft(t) : clockLeft(white, white ? 5076 : 9663, Math.min(t, MATE_AT));
const flash = pressure && t < plyAt(SACRIFICE_PLY - 1) ? 0.86 + 0.14 * Math.sin(t * 15) : 1;
const rematch = !white && t >= E('rematch');
return (
<div style={{ position: 'absolute', left: 60, top, width: 980, height: 56, background: PANEL, borderRadius: 7, display: 'flex', alignItems: 'center', paddingLeft: 12, boxSizing: 'border-box', boxShadow: '0 5px 14px rgba(0,0,0,.28)' }}>
<div style={{ width: 30, height: 30, borderRadius: 5, background: white ? '#f3f5f7' : '#0f1518', border: '2px solid #5b6d76', boxSizing: 'border-box' }} />
<div style={{ font: `700 41px/56px ${COND}`, color: INK, marginLeft: 13, letterSpacing: '.01em' }}>{who.name}</div>
<div style={{ font: `600 30px/56px ${MONO}`, color: '#8fa3ae', marginLeft: 12 }}>{who.elo}</div>
<Captured white={white} t={t} />
{rematch ? <div style={{ marginLeft: 16, padding: '2px 12px 0', background: AMBER, color: '#1a1a12', font: `800 32px/40px ${COND}`, borderRadius: 5, transform: `scale(${back(ramp(t, E('rematch'), E('rematch') + 0.2))})` }}>REMATCH?</div> : null}
{pressure && t >= E('friday') ? <div style={{ position: 'absolute', left: 460, top: 0, width: 214, textAlign: 'right', font: `800 36px/56px ${COND}`, color: t >= E('accepted') ? BRIGHT : stopped ? '#8fa3ae' : '#ff6a5e', letterSpacing: '.03em', whiteSpace: 'nowrap' }}>{t >= E('accepted') ? 'REPLIED' : 'UNTIL FRIDAY'}</div> : null}
<div style={{ position: 'absolute', left: 686, top: 0, width: 204, height: 56, textAlign: 'center', borderRadius: 7, background: pressure && !stopped ? RED : running ? LIGHT : '#1b262b', color: pressure && !stopped ? '#fff' : running ? '#10191d' : '#8fa3ae', font: `700 46px/59px ${MONO}`, letterSpacing: '.01em', filter: `brightness(${flash})` }}>{fmt(left)}</div>
<div style={{ position: 'absolute', left: 924, top: 17, width: 22, height: 22, borderRadius: 11, background: running ? (pressure ? '#ff6a5e' : BRIGHT) : '#1b262b', boxShadow: running ? `0 0 16px ${pressure ? '#ff6a5e' : BRIGHT}` : 'inset 0 0 0 2px #3b4c54' }} />
</div>
);
};
const Scoresheet: React.FC = () => {
const t = useT(), n = lastPly(t), house = houseAt(t);
const rows = Array.from({ length: Math.ceil(PLIES.length / 2) }, (_, i) => i);
const sweep = t >= E('seven') && t < E('seven') + 1.0 ? Math.floor(ramp(t, E('seven'), E('seven') + 0.85) * rows.length) : -1;
const cell = (i: number) => {
if (i >= PLIES.length || t < plyAt(i) - LEAD) return <span />;
const g = gradeOf(i + 1, t), pop = ramp(t, g === '!!' ? E('blunder') : E('gives'), (g === '!!' ? E('blunder') : E('gives')) + 0.2);
return (
<span style={{ color: n === i ? '#fff' : INK, opacity: ramp(t, plyAt(i) - LEAD, plyAt(i) - LEAD + 0.1) }}>
{PLIES[i].san}
{g ? <b style={{ display: 'inline-block', color: NAG[g], marginLeft: 3, transform: i + 1 === SACRIFICE_PLY ? `scale(${lerp(1.9, 1, back(pop))})` : undefined }}>{g}</b> : null}
</span>
);
};
return (
<div style={{ position: 'absolute', left: 746, top: BY, width: 294, height: 382, background: PANEL, borderRadius: 8, overflow: 'hidden', filter: `brightness(${0.5 + 0.5 * house})` }}>
<div style={{ height: 40, font: `700 26px/42px ${COND}`, letterSpacing: '.16em', color: '#8fa3ae', paddingLeft: 16, background: '#223036' }}>MOVES</div>
{rows.map((r) => {
const hot = sweep >= r || (t >= E('blunder') && (r + 1) * 2 - 1 === SACRIFICE_PLY);
const glow = hot && sweep < 0;
return (
<div key={r} style={{ position: 'absolute', left: 0, right: 0, top: 44 + r * 48, height: 46, display: 'grid', gridTemplateColumns: '30px 134px 118px', paddingLeft: 10, alignItems: 'center', font: `700 35px/46px ${MONO}`, color: INK, background: glow ? 'rgba(39,207,199,.2)' : sweep >= r ? 'rgba(247,198,49,.2)' : 'transparent', whiteSpace: 'nowrap' }}>
<span style={{ color: '#7d919c', fontSize: 28, opacity: r * 2 <= n ? 1 : 0 }}>{r + 1}</span>
{cell(r * 2)}
{cell(r * 2 + 1)}
</div>
);
})}
</div>
);
};
const Banner: React.FC = () => {
const t = useT(), house = houseAt(t);
const [row, at] = pick(BANNER, t), k = ramp(t, at, at + 0.2);
return (
<div style={{ position: 'absolute', left: 746, top: BY + 392, width: 294, height: 78, borderRadius: 8, overflow: 'hidden', background: row[2], filter: `brightness(${0.62 + 0.38 * house})`, transform: `perspective(600px) rotateX(${(1 - easeOut(k)) * -78}deg)`, textAlign: 'center', color: row[2] === RED ? '#fff' : '#0f1a1e', font: `800 ${Math.min(50, 560 / row[1].length)}px/82px ${COND}`, letterSpacing: '.02em', whiteSpace: 'nowrap' }}>{row[1]}</div>
);
};
const EngineBox: React.FC = () => {
const t = useT(), house = houseAt(t);
const [row, at] = pick(ENGINE, t), [dep] = pick(DEPTH, t);
const k = back(ramp(t, at, at + 0.2));
return (
<div style={{ position: 'absolute', left: 746, top: BY + 480, width: 294, height: 144, background: '#172126', borderRadius: 8, boxShadow: `inset 0 0 0 2px ${t >= E('decline') ? row[3] : '#2b3a40'}`, boxSizing: 'border-box', padding: '11px 16px', filter: `brightness(${0.68 + 0.32 * house})` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', font: `700 25px/1 ${MONO}`, letterSpacing: '.08em', color: '#8fa3ae' }}><span>ENGINE</span><span>DEPTH {dep[1]}</span></div>
<div style={{ font: `600 25px/1 ${MONO}`, color: '#8fa3ae', marginTop: 8, letterSpacing: '.06em' }}>{row[1]}</div>
<div style={{ font: `800 58px/1 ${COND}`, color: row[3], transform: `scale(${k})`, transformOrigin: '0 50%', whiteSpace: 'nowrap' }}>{row[2]}</div>
</div>
);
};
/* ---------- the window: the hall, drawn in code ---------- */
const WW = 980, WH = 340, TABLE = 278, QUEEN_X = 236;
type View = { s: number; fx: number; fy: number };
const WIDE: View = { s: 1.0, fx: 490, fy: 170 };
const KEYS: { at: string; off?: number; d: number; v: View }[] = [
{ at: 'on', d: 0.01, v: WIDE },
{ at: 'resume', off: -0.3, d: 0.45, v: { s: 1.34, fx: 40, fy: 300 } },
{ at: 'recruiter', off: -0.35, d: 0.45, v: { s: 1.3, fx: 890, fy: 320 } },
{ at: 'ply5', d: 0.5, v: WIDE },
{ at: 'offer', off: -0.1, d: 0.5, v: { s: 1.5, fx: 600, fy: 270 } },
{ at: 'clock', off: -0.1, d: 0.45, v: { s: 2.05, fx: 490, fy: 262 } },
{ at: 'engine', off: -0.2, d: 0.5, v: WIDE },
{ at: 'mother', off: -0.25, d: 0.5, v: { s: 1.14, fx: 490, fy: 30 } },
{ at: 'oh', d: 0.4, v: WIDE },
{ at: 'low', off: 0.1, d: 2.4, v: { s: 1.5, fx: 300, fy: 320 } },
{ at: 'look', off: -0.1, d: 0.6, v: WIDE },
{ at: 'tip', d: 0.5, v: { s: 1.14, fx: 490, fy: 30 } },
{ at: 'accepted', off: -0.4, d: 0.4, v: { s: 1.5, fx: 600, fy: 270 } },
{ at: 'rematch', off: -0.6, d: 0.4, v: { s: 1.3, fx: 890, fy: 320 } },
{ at: 'cta', d: 0.7, v: WIDE },
];
const camAt = (t: number) => {
let v = KEYS[0].v;
for (const k of KEYS.slice(1)) {
const at = E(k.at) + (k.off ?? 0);
if (t < at) break;
const q = easeInOut(ramp(t, at, at + k.d));
v = { s: lerp(v.s, k.v.s, q), fx: lerp(v.fx, k.v.fx, q), fy: lerp(v.fy, k.v.fy, q) };
}
return v;
};
const CROWD = [{ n: 14, y: 146, r: 16, c: '#14262d', rim: 0.22 }, { n: 11, y: 188, r: 22, c: '#0e1b20', rim: 0.34 }, { n: 8, y: 238, r: 30, c: '#080f13', rim: 0.5 }].flatMap((row, ri) => Array.from({ length: row.n }, (_, i) => ({
key: `${ri}-${i}`, ri, x: ((i + 0.5) * WW) / row.n + (hash(i + ri * 31) - 0.5) * 30, y: row.y + hash(i * 3 + ri) * 9, r: row.r * (0.9 + hash(i + 9 + ri) * 0.2), c: row.c, rim: row.rim, bun: hash(i * 7 + ri * 13) > 0.6, ph: hash(i + 77 + ri) * 6.28,
})));
const FLASHES = Array.from({ length: 16 }, (_, i) => ({ x: 40 + hash(i + 301) * 900, y: 110 + hash(i + 401) * 110, off: hash(i + 501) * 1.6 }));
const Hall: React.FC = () => {
const t = useT(), house = houseAt(t);
const a = E('gasp');
const gasp = t >= a ? Math.exp(-(t - a) * 3.4) * Math.min(1, (t - a) / 0.07) : 0;
const lean = ramp(t, E('oh'), plyAt(QUEEN_TAKEN_PLY - 1));
const up = easeOut(ramp(t, MATE_AT + 0.15, MATE_AT + 0.9));
const clap = t > E('tip') ? 0.5 + 0.5 * Math.sin(t * 19) : 0;
const sway = Math.sin(t * 0.5) * 22;
return (
<svg viewBox={`0 0 ${WW} ${WH}`} width={WW} height={WH} style={{ position: 'absolute', left: 0, top: 0 }}>
<rect x="0" y="0" width={WW} height={WH} fill="url(#wall)" />
{Array.from({ length: 13 }, (_, i) => <rect key={i} x={i * 82 + 6} y="0" width="2" height={TABLE} fill="#0c1a20" opacity="0.7" />)}
<g opacity={0.25 + 0.75 * house}>
<polygon points={`${250 + sway},-10 ${310 + sway},-10 ${520},${TABLE} ${90},${TABLE}`} fill="url(#cone)" />
<polygon points={`${670 - sway},-10 ${730 - sway},-10 ${890},${TABLE} ${460},${TABLE}`} fill="url(#cone)" />
</g>
{CROWD.map((p) => {
const row = (p.ri + 1) / 3, breath = Math.sin(t * 1.1 + p.ph) * 1.4;
const stagger = hash(p.ph * 9) * 0.4;
const rise = easeOut(ramp(t, MATE_AT + 0.1 + stagger, MATE_AT + 0.6 + stagger)) * 24 * row;
const y = p.y + breath - gasp * 15 * row - lean * 4 * row - rise - 2.5 * clap * row * up;
const shoulder = `M${p.x - 2.3 * p.r} ${y + 4.4 * p.r} C${p.x - 2.3 * p.r} ${y + 1.5 * p.r} ${p.x - 1.2 * p.r} ${y + 1.0 * p.r} ${p.x} ${y + 1.0 * p.r} C${p.x + 1.2 * p.r} ${y + 1.0 * p.r} ${p.x + 2.3 * p.r} ${y + 1.5 * p.r} ${p.x + 2.3 * p.r} ${y + 4.4 * p.r} Z`;
const rim = `rgba(190,232,232,${Math.min(0.95, p.rim * (0.55 + 0.45 * house) + gasp * 0.6)})`;
const hands = Math.max(gasp * 1.4, up);
const hy = gasp > up ? y + p.r * 0.9 : y - p.r * 1.4 - clap * 3;
const body = (
<>
<path d={shoulder} />
{p.bun ? <circle cx={p.x + p.r * 0.2} cy={y - p.r * 1.12} r={p.r * 0.46} /> : null}
<ellipse cx={p.x} cy={y} rx={p.r} ry={p.r * 1.14} />
{hands > 0.12 && p.ri > 0 ? <><ellipse cx={p.x - p.r * (0.55 - clap * 0.2)} cy={hy} rx={p.r * 0.34} ry={p.r * 0.42} /><ellipse cx={p.x + p.r * (0.55 - clap * 0.2)} cy={hy} rx={p.r * 0.34} ry={p.r * 0.42} /></> : null}
</>
);
return (
<g key={p.key}>
<g fill={rim} transform={`translate(${p.ri - 1} -2.4)`}>{body}</g>
<g fill={p.c}>{body}</g>
</g>
);
})}
{FLASHES.map((f, i) => { const at = MATE_AT + 0.1 + f.off, k = (t - at) / 0.14; return k >= 0 && k <= 1 ? <circle key={i} cx={f.x} cy={f.y} r={46 + 40 * k} fill="url(#bulb)" opacity={1 - k} /> : null; })}
<rect x="0" y={TABLE} width={WW} height={WH - TABLE} fill="url(#wood)" />
</svg>
);
};
/** The back-wall sign. It keeps its dignity through the applause. */
const WallSign: React.FC = () => {
const t = useT(), lit = t >= E('tip');
return <div style={{ position: 'absolute', left: 330, top: 16, width: 320, textAlign: 'center', font: `700 31px/42px ${COND}`, letterSpacing: '.14em', color: lit ? BRIGHT : '#d5e4e6', background: '#0d1a1f', border: `2px solid ${lit ? BRIGHT : '#3b5058'}`, borderRadius: 5, opacity: 0.35 + 0.65 * houseAt(t), whiteSpace: 'nowrap', boxSizing: 'border-box' }}>{lit ? 'APPLAUSE, QUIETLY' : 'SILENCE PLEASE'}</div>;
};
/** A sign rises from the family section. It turns over once the game says they were right all along. */
const Sign: React.FC<{ s: (typeof SIGNS)[number]; i: number }> = ({ s, i }) => {
const t = useT(), at = E('mother') + 0.05 + (i * (E('decline2') - E('mother') + 0.3)) / SIGNS.length;
if (t < at) return null;
const k = back(ramp(t, at, at + 0.22)), fl = ramp(t, MATE_AT + 0.5 + i * 0.09, MATE_AT + 0.8 + i * 0.09), turned = fl >= 0.5;
const down = ramp(t, E('low'), E('low') + 0.45) * (1 - easeOut(ramp(t, MATE_AT + 0.05 + i * 0.05, MATE_AT + 0.35 + i * 0.05)));
return (
<div style={{ position: 'absolute', left: s.x, top: s.y + (1 - k) * 120 + down * 150, transform: `rotate(${s.rot + Math.sin(t * 1.4 + i) * 1.6}deg) rotateY(${(turned ? fl - 1 : fl) * 180}deg)`, transformOrigin: '50% 100%', opacity: 1 - down }}>
<div style={{ padding: '4px 13px 1px', background: '#efe9d8', color: '#17202a', font: `800 33px/40px ${COND}`, whiteSpace: 'nowrap', borderRadius: 3, boxShadow: '0 5px 10px rgba(0,0,0,.5)' }}>{turned ? s.b : s.a}</div>
<div style={{ width: 6, height: 40, background: '#6b523a', margin: '0 auto' }} />
</div>
);
};
const Paper: React.FC<{ at: number; rest: number; x: number; y: number; w: number; h: number; rot: number; style?: React.CSSProperties; children: React.ReactNode }> = ({ at, rest, x, y, w, h, rot, style, children }) => {
const t = useT();
if (t < at - 0.05) return null;
const k = back(ramp(t, at - 0.05, at + 0.2)), r = easeInOut(clamp(rest));
const dy = (TABLE + 44 - (y + h)) * r + (1 - k) * (WH - y + 20);
return <div style={{ position: 'absolute', left: x, top: y, width: w, height: h, transformOrigin: '50% 100%', transform: `translateY(${dy}px) rotate(${rot}deg) scale(${lerp(1, 0.56, r)})`, background: '#f3efe2', color: '#1a2228', boxShadow: '0 10px 18px rgba(0,0,0,.55)', boxSizing: 'border-box', overflow: 'hidden', ...style }}>{children}</div>;
};
/* Papers come up to the lens for their line, then settle on the table so the hall stays visible. */
const Props: React.FC = () => {
const t = useT(), rematch = t >= E('rematch'), cta = ramp(t, E('cta'), E('cta') + 0.5);
return (
<>
<Paper at={E('resume')} rest={ramp(t, E('ply5'), E('ply5') + 0.45)} x={38} y={112} w={150} h={196} rot={-3} style={{ padding: '12px 14px' }}>
<div style={{ font: `800 36px/1 ${COND}`, letterSpacing: '.03em' }}>RESUME</div>
{[120, 104, 112, 86].map((w, i) => <div key={i} style={{ width: w, height: 6, background: '#a9aea9', marginTop: i ? 9 : 12 }} />)}
<div style={{ font: `600 22px/1.05 ${COND}`, marginTop: 13 }}>References: my mother</div>
</Paper>
<Paper at={E('offer')} rest={ramp(t, E('clock') - 0.1, E('clock') + 0.3) * (1 - ramp(t, E('accepted') - 0.45, E('accepted') - 0.1)) + cta} x={592} y={100} w={174} h={212} rot={2} style={{ padding: '10px 14px' }}>
<div style={{ font: `800 40px/1 ${COND}`, color: TEAL, letterSpacing: '.04em' }}>OFFER</div>
<div style={{ height: 3, background: '#1a2228', margin: '6px 0 8px' }} />
<div style={{ font: `700 36px/1.08 ${COND}`, opacity: ramp(t, E('less'), E('less') + 0.1) }}>Less pay.</div>
<div style={{ font: `700 36px/1.08 ${COND}`, opacity: ramp(t, E('real'), E('real') + 0.1) }}>Real work.</div>
{t >= E('accepted') ? <div style={{ position: 'absolute', left: 10, top: 130, padding: '4px 8px 0', border: `4px solid ${RED}`, color: RED, font: `800 38px/1.05 ${COND}`, whiteSpace: 'nowrap', transform: `rotate(-11deg) scale(${lerp(2.2, 1, easeIn(ramp(t, E('accepted'), E('accepted') + 0.12)))})`, opacity: 0.94 }}>ACCEPTED</div> : null}
</Paper>
<Paper at={E('recruiter')} rest={ramp(t, E('ply5'), E('ply5') + 0.45) * (1 - ramp(t, E('rematch') - 0.6, E('rematch') - 0.3)) + cta} x={770} y={204} w={120} h={92} rot={-3} style={{ padding: '10px 6px', textAlign: 'center' }}>
{rematch ? <div style={{ font: `800 31px/70px ${COND}`, color: RED, whiteSpace: 'nowrap' }}>REMATCH?</div> : (
<>
<div style={{ font: `800 24px/1 ${COND}` }}>RECRUITER</div>
<div style={{ font: `600 21px/1.02 ${COND}`, marginTop: 5 }}>let's circle back</div>
</>
)}
</Paper>
</>
);
};
/** The physical clock on the table. It reads what the plate reads, and the plunger shows the taps. */
const TableClock: React.FC = () => {
const t = useT(), tap = tapAt(t), hot = t >= E('clock');
const left = hot ? hoursLeft(t) : clockLeft(true, 5076, t);
const rings = hot && t < MATE_AT ? tap : 0;
return (
<div style={{ position: 'absolute', left: 390, top: 176, width: 200, height: 108 }}>
<div style={{ position: 'absolute', left: 30, top: -14 + tap * 9, width: 40, height: 22, background: hot ? RED : '#8c9aa1', borderRadius: '6px 6px 0 0' }} />
<div style={{ position: 'absolute', left: 130, top: -14, width: 40, height: 22, background: '#59686f', borderRadius: '6px 6px 0 0' }} />
<div style={{ position: 'absolute', left: 50 - 34 * (1 - rings) - 4, top: -4 - 34 * (1 - rings), width: 2 * 34 * (1 - rings) + 8, height: 2 * 34 * (1 - rings) + 8, borderRadius: '50%', border: `3px solid ${RED}`, opacity: rings * 0.9 }} />
<div style={{ position: 'absolute', left: 0, top: 8, width: 200, height: 100, background: 'linear-gradient(#2d3a40,#141c20)', borderRadius: 10, boxShadow: '0 12px 18px rgba(0,0,0,.55)', filter: `brightness(${0.6 + 0.4 * houseAt(t)})` }} />
{[0, 1].map((i) => <div key={i} style={{ position: 'absolute', left: 10 + i * 94, top: 30, width: 86, height: 56, background: '#0a1013', borderRadius: 4, textAlign: 'center', font: `700 ${i === 0 && hot ? 25 : 27}px/58px ${MONO}`, color: i === 0 && hot ? '#ff6a5e' : '#cfe7e4', whiteSpace: 'nowrap', overflow: 'hidden' }}>{i === 0 ? fmt(left) : fmt(clockLeft(false, 9663, Math.min(t, MATE_AT)))}</div>)}
</div>
);
};
/** It falls off the board into the hall, lands on the table with its tag still on, and lies there in its own light. */
const FALL = 0.62, WINDOW_TOP = 944;
const fallOf = () => { const c = pieceByHome(TAGS[0].home), [x, y] = px(TAGS[0].home); return { at: c.dies - 0.02, x0: BX + x, y0: BY + y, x1: 60 + QUEEN_X - 5, y1: WINDOW_TOP + 186 }; };
const QueenLight: React.FC = () => {
const t = useT(), k = ramp(t, E('low') + 0.5, E('low') + 0.8) * (1 - ramp(t, E('look'), E('look') + 0.4));
return k > 0 ? <svg viewBox={`0 0 ${WW} ${WH}`} width={WW} height={WH} style={{ position: 'absolute', left: 0, top: 0, opacity: k }}><polygon points={`${QUEEN_X - 24},-10 ${QUEEN_X + 44},-10 ${QUEEN_X + 150},${WH} ${QUEEN_X - 90},${WH}`} fill="url(#cone)" /><ellipse cx={QUEEN_X + 30} cy={300} rx={118} ry={22} fill="#d9f2ee" opacity={0.16} /></svg> : null;
};
const FallenQueen: React.FC = () => {
const t = useT(), q = fallOf(), u = t - q.at - FALL;
if (u < 0) return null;
const hop = Math.abs(Math.sin(u * 11)) * Math.exp(-u * 7) * 18;
return (
<>
<svg viewBox="0 0 100 100" width={100} height={100} style={{ position: 'absolute', left: QUEEN_X - 5, top: 186 - hop, overflow: 'visible' }}><PieceArt p="Q" x={0} y={0} s={1} rot={-82} /></svg>
<div style={{ position: 'absolute', left: QUEEN_X + 50, top: 266 - hop, padding: '2px 9px 0', background: AMBER, color: '#10191d', font: `800 25px/27px ${COND}`, whiteSpace: 'nowrap', borderRadius: 4, transform: 'rotate(-2deg)' }}>{TAGS[0].text}</div>
</>
);
};
const FallingQueen: React.FC = () => {
const t = useT(), q = fallOf(), k = (t - q.at) / FALL;
if (k < 0 || k >= 1) return null;
const size = lerp(78, 100, k);
return <svg viewBox="0 0 100 100" width={size} height={size} style={{ position: 'absolute', left: lerp(q.x0, q.x1, k), top: lerp(q.y0, q.y1, easeIn(k)), overflow: 'visible' }}><PieceArt p="Q" x={0} y={0} s={1} rot={-82 * k} /></svg>;
};
const WindowFrame: React.FC = () => {
const t = useT(), v = camAt(t), house = houseAt(t);
const tx = v.fx * (1 - v.s), ty = v.fy * (1 - v.s);
const [cam, at] = pick(CAMS, t), k = ramp(t, at, at + 0.14);
return (
<div style={{ position: 'absolute', left: 60, top: WINDOW_TOP, width: WW, height: WH, overflow: 'hidden', background: '#091216', borderRadius: 8 }}>
<div style={{ position: 'absolute', left: 0, top: 0, width: WW, height: WH, transformOrigin: '0 0', transform: `translate(${tx}px, ${ty}px) scale(${v.s})` }}>
<Hall />
<WallSign />
{SIGNS.map((s, i) => <Sign key={i} s={s} i={i} />)}
<Props />
<div style={{ position: 'absolute', inset: 0, background: `rgba(4,9,12,${(1 - house) * 0.62})` }} />
<QueenLight />
<FallenQueen />
<TableClock />
</div>
<div style={{ position: 'absolute', left: 12, top: 12, display: 'flex', alignItems: 'center', gap: 8, padding: '4px 11px 2px', background: 'rgba(8,16,20,.82)', borderRadius: 5, font: `700 25px/1 ${COND}`, letterSpacing: '.1em', color: INK, whiteSpace: 'nowrap', opacity: k }}>
<span style={{ width: 11, height: 11, borderRadius: 6, background: RED, opacity: 0.45 + 0.55 * Math.abs(Math.sin(t * 2.2)), marginTop: -2 }} />{cam[1]}
</div>
<div style={{ position: 'absolute', inset: 0, boxShadow: 'inset 0 0 40px rgba(0,0,0,.55)', borderRadius: 8, pointerEvents: 'none' }} />
</div>
);
};
/* ---------- captions: the commentary card ---------- */
const PER_ROW = 28;
const MATE_WORD: Word = { w: 'CHECKMATE', t0: MATE_AT, t1: MATE_AT + 2.2, scene: 'mate' };
const CHUNKS = [...buildChunks(PER_ROW, 2), { t0: MATE_AT, rows: [{ s: MATE_WORD.w, map: Array.from(MATE_WORD.w, () => 0) }], words: [MATE_WORD] }].sort((a, b) => a.t0 - b.t0);
const Captions: React.FC = () => {
const t = useT();
const idx = CHUNKS.reduce((a, c, i) => (t >= c.t0 - 0.06 ? i : a), -1);
const c = idx >= 0 ? CHUNKS[idx] : null;
const big = c?.words[0] === MATE_WORD;
return (
<div style={{ position: 'absolute', left: 60, top: 1292, width: 980, height: 184, background: PANEL, borderRadius: 8, boxShadow: `inset 6px 0 0 ${TEAL}`, boxSizing: 'border-box', padding: '10px 26px', overflow: 'hidden' }}>
<div style={{ font: `700 24px/1 ${COND}`, letterSpacing: '.2em', color: BRIGHT }}>COMMENTARY</div>
{c ? (
<div style={{ width: 864, marginTop: big ? -4 : 6 }}>
{c.rows.map((row, ri) => (
<div key={ri} style={{ font: big ? `800 138px/146px ${COND}` : `700 72px/74px ${COND}`, letterSpacing: big ? '.05em' : '.005em', whiteSpace: 'pre', color: big ? '#fff' : INK }}>
{Array.from(row.s).map((ch, i) => {
const w = row.map[i] >= 0 ? c.words[row.map[i]] : null;
const live = w && t >= w.t0 - 0.03 && t < w.t1 + 0.08;
const kw = t >= E('cta') && w && w.w.toUpperCase().startsWith(KEYWORD);
const said = !w || t >= w.t0 - 0.04;
return <span key={i} style={{ color: big ? '#fff' : live || kw ? BRIGHT : undefined, opacity: said ? 1 : 0.3 }}>{ch}</span>;
})}
</div>
))}
</div>
) : null}
</div>
);
};
/* ---------- below the safe area: the other boards in the hall, only texture ---------- */
const MINI_PIECES = Array.from('RNBQKPPPPrnbqkppp');
const MINIS = OTHER_BOARDS.map((label, i) => {
const size = 306, q = size / 8, used = new Set<number>();
return (
<div key={label} style={{ position: 'absolute', left: 60 + i * 337, top: 1502, width: size, opacity: 0.62 }}>
<svg viewBox={`0 0 ${size} ${size}`} width={size} height={size} style={{ display: 'block', boxShadow: '0 12px 26px rgba(0,0,0,.45)' }}>
{Array.from({ length: 64 }, (_, k) => <rect key={k} x={(k % 8) * q} y={Math.floor(k / 8) * q} width={q} height={q} fill={(k % 8 + Math.floor(k / 8)) % 2 ? DARK : '#c6ccd3'} />)}
{MINI_PIECES.map((p, k) => {
let cell = Math.floor(hash(k * 17 + i * 53) * 64);
while (used.has(cell)) cell = (cell + 11) % 64;
used.add(cell);
return <PieceArt key={k} p={p} x={(cell % 8) * q} y={Math.floor(cell / 8) * q} s={q / 100} />;
})}
</svg>
<div style={{ font: `700 34px/46px ${COND}`, color: '#a9bac3', whiteSpace: 'nowrap' }}>{label}</div>
</div>
);
});
/* ---------- the film ---------- */
export const Checkmate: React.FC = () => (
<Clock>
<Fonts faces={FACES}>
<Stage />
</Fonts>
</Clock>
);
const Stage: React.FC = () => {
const t = useT(), house = houseAt(t);
return (
<AbsoluteFill style={{ background: HALL, overflow: 'hidden' }}>
<Defs />
<AbsoluteFill style={{ background: `radial-gradient(90% 46% at 40% 26%, rgba(111,138,164,${0.22 * house}), transparent 70%)` }} />
<Header />
<Plate top={188} who={BLACK} white={false} />
<EvalBar />
<Scoresheet />
<Banner />
<EngineBox />
<Plate top={BY + BOARD + 4} who={WHITE} white />
<Board />
<WindowFrame />
{MINIS}
<AbsoluteFill style={{ background: `rgba(3,7,9,${(1 - house) * 0.5})`, pointerEvents: 'none' }} />
<AbsoluteFill style={{ background: 'radial-gradient(120% 80% at 50% 45%, transparent 66%, rgba(4,8,10,.42) 100%)', pointerEvents: 'none' }} />
<Captions />
<FallingQueen />
</AbsoluteFill>
);
};
```
## 8. Voice (optional)
A voice is worth adding when the world has a speaker of its own, because the deadpan delivery is half the joke. Skip it for a sound-off post, or when the user would rather not have one: the film still works.
**The persona:** a hushed chess commentator, very soft, close to the mic, unhurried, the voice of someone trying not to disturb the players. They treat your career as a serious game and never once raise their voice, not even for the queen.
**When to use it:** on every line except the mate, which has no voice at all. "Oh." is the only surprise in the whole read: a small breath, not a shout. Keep the room very small and dry, with a touch of warmth around 160 Hz, so the whisper sits in front of the hall. Whispered voices lose consonants, so listen for "mate" in the call to action; commas around it in the spoken text help.
- **Never clone or imitate a real person.** Audition 3 or 4 voices on two lines of the script and let the user pick, or use the user's own voice.
- **One take per line,** with word timestamps. Pass the neighboring lines as context if your provider allows it, so the delivery flows. Trim each take to its speech, and tighten any pause inside it longer than 0.5 s.
- **Listen for your call to action.** Keywords blur easily. Commas around one in the spoken text ("Comment, gate, and...") usually fix it. Check the final mix with Whisper, line by line.
- **With no voice.** Keep the estimated timings as the clock. If there's music, put the captions on its grid. Raise the effects and the music: they carry the film now. Make sure the words stay on screen long enough to read, about 0.3 s a word. The estimate runs close to that, so add `tail` after a line that feels rushed.
## 9. Sound
The picture tells the story without sound, so a film can ship silent. Sound is what makes each change land, though, so add it unless the user says not to. This section says what to use and when it plays. Add it after the user approves the animatic.
**Effects: make a set for this world.** Generate them with the sound-effects tool the user chose, or pick from a licensed library. Prompt the thing itself, and keep it warm, never bright or harsh.
| Sound | Length | Prompt idea | When it plays |
|-------|--------|-------------|---------------|
| piece down | 0.4 s | a weighted wooden chess piece set down on a wooden board, one soft felted tock, close and dry | each move, `ply1` to `ply12`, as it lands |
| capture | 0.7 s | a wooden chess piece knocking another off a board, a small hard clack and a short rattle | `ply9`, `ply11` |
| clock tap | 0.3 s | one press of a chess clock's plunger, a small dry plastic clack | from `clock` to `ply13`. About one a second, climbing to four a second over 4 s, then back to one through the low point. `tapAt` in the frame holds the rhythm |
| telestrator | 0.5 s | a felt pen drawing one quick line on glass, soft and short | every arrow: `job`, `engine`, `decline`, `look` |
| sign | 0.5 s | a cardboard sign lifted quickly, a soft card flap | each sign on `mother`, and again as they turn over after the mate |
| queen falls | 1.4 s | a wooden chess piece dropped onto a table from a height, one hard knock, one bounce, a short roll | `ply10`, with the knock about 0.6 s after the capture |
| gasp | 1.5 s | a small seated audience all drawing breath at once, quiet, close, in a hushed hall, no voices | `gasp`, once |
| hall lights | 0.6 s | a bank of heavy stage lights switched off, one low relay clunk, warm and dark | three times from `low`, 0.3 s apart |
| spotlight | 1 s | a theater spotlight switched on, a soft mechanical clunk and a faint hum | `low` plus 0.5 s, and `look` |
| mate | 2 s | a heavy wooden piece slammed down on a board in a silent hall, a deep warm thud with a long soft tail | `ply13`, the loudest thing in the film |
| king tips | 1 s | a wooden king tipping over on a board, a small knock and a short roll | `tip` |
| flash bulbs | 1.5 s | a few old camera flash bulbs popping across a hall, small and soft | `ply13` plus 0.2 s |
| quiet applause | 4 s | a small audience clapping politely and very softly, restrained, in a large hall | `tip` to `blunder`, fading under the voice |
| stamp | 0.6 s | a rubber stamp pressed on a letter on a wooden table, a short dull thud | `accepted` |
| room murmur | 10 s | a large hushed hall, a few people shifting in their seats, a distant cough, air, no words, no music | the bed, all the way through, and the only thing left on the low point besides the taps |
- **Place a sound on every visible change,** at the same event the picture uses, so a re-voice moves both.
- **Beds run under everything:** a world without room tone feels empty, and room tone is what keeps the low point alive.
- **The same sound twice within 80 ms:** keep the louder one.
- **Warm, never bright:** low-pass effects to about 1.5-3.5 kHz if they bite.
**Music: one instrumental bed.** Describe the sound, never an artist. Generators ignore requested timestamps, so fit the track instead: find its real downbeats from bar-to-bar energy jumps, pick the bar where it lifts, place that bar on the story's turn, and stretch (keeping the pitch, within about 4 %) so a second downbeat lands on the voiceless moment.
**The brief:** "Hushed chamber instrumental: solo cello and a sparse felt-muted upright piano, slow, with a quiet pulse like a clock, tension gathering for a long stretch, then a warm restrained lift as low strings join and the piano opens up, ending resolved, no drums, no vocals."
**When it plays:** low under the opening, and it tightens through the clock and the signs. It drains to nothing over 0.7 s on `low`, stays out so only the taps and the murmur remain, and comes back on the word "bishop", on a bar where the track lifts. It's at its fullest on the voiceless mate, with a downbeat on the knight's landing, then sits under the verdict and the button and resolves on the call to action.
**Mix, voice first.**
- **Voice:** compress lightly so consonants stay off the limiter, about -14 LUFS.
- **Music:** about 16 to 20 LU under the voice while it speaks, lifted in gaps and on the voiceless moment.
- **Effects:** 7 to 10 LU under the voice overall, ducked about 6 dB under speech, so nothing masks a word.
- **Master:** -14 LUFS integrated, sample-peak ceiling about -2.4 dBFS. AAC encoding adds about 0.8 dB, so check the true peak of the final file. Give the master a 10 ms fade-in, or every view opens on a click.
## 10. Render and QA
1. **Render** without audio. Mux your mix onto it, trimmed to the exact length (`-t`, never `-shortest`), as H.264 at high quality with AAC 320k and `+faststart`.
2. **Sync and length:** the frame count matches the clock, and a cross-correlation of the muxed audio against your mix shows an offset of 0 ms.
3. **Contact sheets:** one frame per second with the safe box drawn. Look at every sheet: nothing cut off, nothing important outside the box, no empty or black frames, and the time of day and the story still agree.
4. **Determinism:** render about 10 spread frames twice with `npx remotion still src/index.ts Film-9x16 out/a.png --frame=N`, and compare each pair with `cmp`; they should match.
5. **Loudness:** -14 LUFS integrated, true peak at or below -1 dBTP.
6. **Words:** transcribe the mix line by line with Whisper. A difference on a plain word is a masked or swallowed word.
7. **Gaps and stills:** no voice gap over 1.0 s outside the voiceless moment (`clock.mjs` warns about these), and no still stretch over 1.2 s or black stretch (`ffmpeg -i out/silent.mp4 -vf "freezedetect=n=0.001:d=1.2,blackdetect=d=0.1" -an -f null -` prints any). A small moving corner can hide a freeze from that check, so trust the contact sheets over it.
For a silent film, skip the checks that need audio (the offset in 2, and 5 and 6) and use `out/silent.mp4` as the final file.
## 11. Traps
- **An illegal game:** this is the one frame a stranger can fact-check by pausing. Keep the check on, and never replace `GAME` with hand-placed pieces. A move that fails the check is telling you the story has to change, so don't argue with it.
- **A chess player's objection:** a real engine sees this trick instantly. Ours is wrong on purpose, and the panel says so: DEPTH 1, climbing as the trick appears. Keep that joke, or take the word "engine" off the screen.
- **Pinned to the wrong piece:** on our board the knight is pinned to the queen, and the banner says KNIGHT PINNED. Terms matter here, because the people who pause will notice.
- **A black low point:** the lights drop, but the one light on the queen, the red clock and the taps keep going. It's dark and it's never black.
- **Nothing to read on the board:** the moves alone don't tell the story to someone who doesn't play. The tags, the banner and the engine's panel carry it, so check them with the sound off.
- **Too many papers in the window:** a paper comes up to the lens for its line and settles on the table afterward. If it stays up, it hides the crowd and the signs.
| Symptom | Fix |
|---------|-----|
| A few random frames show text at the wrong size | moving text as HTML, not SVG `<text>`, under per-frame transforms |
| A shape keeps a style from an earlier frame | a negative CSS length was rejected: clamp at 0 |
| A render dies on a font timeout | take `delayRender` inside React, as `core.tsx` does, never at module level |
| Sound drifts against the picture | mux your own mix with `-t` and check the offset by cross-correlation |
| The music's lift lands late | fit by real downbeats; do not trust requested timestamps |
| Two renders of a frame differ slightly along a panel's edge | a large blurred `box-shadow` isn't exactly repeatable: use a shadow with no blur on big panels |
| A caption breaks after "a.m." | The caption chunker (`buildChunks`, or the frame's own if it has one) ends a chunk at any word ending in a full stop: write "am" in `text`, or break the chunks yourself |
## 12. Deliver
The MP4, a cover frame and a ready-to-paste caption. For organic posts the caption repeats the comment keyword and carries no link.
The prompt
# The prompt First give your agent the checkmate skill (paste the skill text and ask it to save it as a skill). Then replace everything in [BRACKETS] below and paste it in. **Tip:** use any AI tools you like for the voice, music and sound effects; the skill works the same with all of them, or with none (every scene is code, and the film works with no voice). If you'd rather not set up separate AI accounts and API keys, HYVIRAL (optional) is one way to get them behind one connection (hyviral.com; in Claude.ai → Customize → Connectors → add `https://api.hyviral.com/mcp`). --- Use the checkmate skill to make a vertical video for [PLATFORM: Instagram Reels / TikTok / YouTube Shorts]. - **The decision:** [TOPIC - the hard call the game is about, e.g. "the job offer I almost turned down", "closing the product everyone liked"] - **The safe thing, your queen:** [SAFE - one or two words for what everyone said not to give up, e.g. "safe job", "big client"] - **The risky move, your knight:** [RISK - one word for the option that wins, e.g. "offer", "startup", or "you pick it"] - **Who you're playing:** [OPPONENT - e.g. "the job market", "the industry", or "you pick it"] - **In the window:** [WINDOW - "no person: the hall drawn in code", or "my own clip, attached, as the player cam"] - **Length:** [LENGTH - e.g. about 35 seconds] - **Voice:** [VOICE - e.g. "a hushed chess commentator", "my own voice", or "no voice: captions and sound only"] - **Music:** [MUSIC - e.g. "the skill's hushed chamber brief", or your own idea] - **Ending:** [CTA - organic: "Comment KEYWORD and I'll send you THE THING", with the keyword and what you'll send; or sponsored: the line and the URL] - **AI tools:** [AI TOOLS - e.g. "whatever you already have set up", "HYVIRAL", or "none: animation only, I'll record the voice myself"] - **Avoid:** [AVOID - e.g. no real players or tournaments named, no real companies as the opponent, no claims I can't back up] Work in this order: 1. Write the script on the skill's moves, with one line for each turn and what happens on the board. Keep the skill's game unless I ask for another. If you change it, run it through the frame's own rules check. 2. Build the plan, the clock and the frame, render a silent animatic, and show me the script, the scene sheet and the animatic. Wait for my OK. 3. Generate the voice, music and sound effects with the AI tools I named above. If I left that open, tell me which tools you'll use and wait for my OK. 4. Place a sound on every change, fit the music to the turn, mix voice first, render, and run every QA gate in the skill. 5. Give me the MP4, a cover frame and a ready-to-paste caption.
More skills
All skillsDepartures
A split-flap board where every turn in your story is a status change.
Cover Story
Your subject is the star of a magazine cover, issue after issue.
Mission Control
A launch console treats your small project like a space launch.