Record a game from its very first frame
game-maker / gate (push) Successful in 4m18s
game-maker / suite (push) Successful in 18m17s

Start recording has to set up its encoders first. It asks the browser
whether it can encode H.264 at the size and hooks in the sound tap,
both asynchronously, and it only took its size at the first frame.
Meanwhile the game ran on unrecorded. A caption that fades in from
alpha 0 at the start was already visible in the first recorded frame.

- A run now waits while a recording sets up its encoders, at most 3 s,
  as it already waits for its videos at Play. That holds in the editor
  and in play.html.
- The recording takes its size at Play, right after the game view has
  the world, rather than at the first frame. A Start recording in
  On start is so ready before the first tick.
- A recording started in the middle of a game holds the picture for
  that moment. A sound that already plays goes on.

Tests:
- tsc, eslint, Prettier clean
- e2e 67 on melektaus-atx: ALL PASSED, 11 checks (67a-ii new). The
  first recorded frame is at game time 0.0168 s, the first frame.
- Without the wait 67a-ii is red: the first recorded frame is at
  0.1667 s, so 10 frames were lost.
- Gate on melektaus-atx: green, 305 files, 4740 tests, run on this
  commit and the one before together.
- Not checked in the browser: a start in the middle of a game, and
  play.html.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-13 10:11:27 +02:00
1 parent 2ba41c1bd0
commit 3443368855
7 files changed
+68 -6

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2061 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2062 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+4 -1
View File
@@ -1540,7 +1540,10 @@ export class Editor implements PlayHost {
playing: boolean;
paused: boolean;
gamePaused: boolean;
/** Held at the start until the Game tab's driven videos have a picture (#404). */
/**
* Held at the start until the Game tab's driven videos have a picture (#404), or while a
* recording sets up its encoders.
*/
waiting: boolean;
time: number;
sceneId: string;
+7 -3
View File
@@ -127,9 +127,12 @@ export class PlaySession {
return this.world?.paused ?? false;
}
/** Held at its start until the Game tab has a picture of its driven videos (#404). */
/**
* Held at its start until the Game tab has a picture of its driven videos (#404), and while a
* recording sets up its encoders.
*/
get isWaiting(): boolean {
return this.videoWait !== null;
return this.videoWait !== null || this.recorder.isStarting;
}
/** Pause or resume; before Play it switches whether Play starts paused. */
@@ -235,6 +238,7 @@ export class PlaySession {
this.host.gameView.setWorld(this.run.world);
this.host.showGame();
this.waitForVideos();
this.recorder.prepare();
if (paused) {
this.state.setPaused(true);
this.applyPause(false);
@@ -331,7 +335,7 @@ export class PlaySession {
const run = this.run;
if (!run) return;
const world = run.world;
if ((this.state.paused && !this.stepping) || this.waitsForVideos()) {
if ((this.state.paused && !this.stepping) || this.waitsForVideos() || this.recorder.isStarting) {
// Keys and clicks that happen while paused are dropped instead of piling up – otherwise
// resuming would deliver every one of them in a single frame.
this.input.endFrame();
+3 -1
View File
@@ -78,6 +78,7 @@ export class Player {
this.input.attach();
this.screen.setWorld(this.run.world);
this.waitForVideos();
this.recorder.prepare();
if (this.run.world.stopRequested) this.stop();
}
@@ -103,7 +104,8 @@ export class Player {
tick(dt: number): void {
const run = this.run;
if (!run) return;
if (this.waitsForVideos()) {
// A recording that sets up its encoders holds the run too: its frames meanwhile would be lost.
if (this.waitsForVideos() || this.recorder.isStarting) {
this.input.endFrame();
run.world.dropPointerEdges();
this.screen.redraw();
+13
View File
@@ -44,6 +44,19 @@ export class GameRecorder implements RecordingSink {
return this.take !== null;
}
/**
* Whether a recording still sets up its encoders: the run waits for it, or the frames meanwhile
* are lost – the first of a game that starts recording in `On start` among them.
*/
get isStarting(): boolean {
return this.take?.isStarting ?? false;
}
/** The view shows the world now: a recording started in `On start` takes its size already. */
prepare(): void {
if (this.host.view()) this.take?.prepare();
}
start(settings: RecordingSettings): string {
if (this.take)
return `Start recording is ignored: "${this.take.name}" is recording already – Stop recording ends it`;
+18
View File
@@ -21,6 +21,8 @@ const FOLD_BYTES = 16 * 1024 * 1024;
const LAST_SOUND_MS = 300;
/** How long a stop waits for the frames still on their way to the encoder. */
const LAST_FRAMES_MS = 500;
/** How long a run waits for the encoders to be set up, see `isStarting`. */
const START_WAIT_MS = 3000;
/** A recording once it is made. */
export interface MadeRecording {
@@ -120,6 +122,8 @@ export class Take {
private heldSince: number | null = null;
private lastFrameAt: number | null = null;
private failure: string | null = null;
/** When the encoders began to be set up, by `performance.now()`. */
private startingAt = 0;
constructor(
readonly name: string,
@@ -148,6 +152,7 @@ export class Take {
if (!size) return;
this.size = size;
this.state = 'starting';
this.startingAt = performance.now();
void this.openEncoders().then(
() => {
if (this.state !== 'starting') return;
@@ -234,6 +239,19 @@ export class Take {
tap.node.port.onmessage = (event: MessageEvent<Float32Array[]>) => this.heard(event.data);
}
/**
* Whether the encoders are still being set up, at most `START_WAIT_MS`: no frame can be taken
* meanwhile, so a run waits for it rather than playing its first moments unrecorded.
*/
get isStarting(): boolean {
return this.state === 'starting' && performance.now() - this.startingAt < START_WAIT_MS;
}
/** Takes the size now, when there is a picture, rather than at the first frame (`capture`). */
prepare(): void {
if (this.state === 'waiting') this.open();
}
setPaused(paused: boolean): void {
this.stand(() => (this.paused = paused));
}
+22
View File
@@ -62,6 +62,19 @@ export async function recording(t) {
// 67a. Recorded from the start of the run, at 1080p and 30 fps, with the master's sound. The window
// is smaller than 1080p: the frame is drawn into a texture of its own (`PictureReader`).
// The game time is written down each frame until the first frame is taken.
await evaluate(`(() => {
window.__rc67 = { at: null };
const look = () => {
const take = __gm.recording().takes[0];
if (take?.lastFrameAt != null) {
window.__rc67.at = __gm.playInfo().time;
return;
}
requestAnimationFrame(look);
};
requestAnimationFrame(look);
})()`);
await play();
await sleep(1200);
await press('v');
@@ -81,6 +94,15 @@ export async function recording(t) {
JSON.stringify(first),
);
// 67a-ii. The run waits while the recording sets up its encoders: a recording started in
// `On start` has the first frames of the game, not what the game shows a moment later.
const firstAt = await json('window.__rc67.at');
check(
'a recording started in On start takes the first frames of the run',
typeof firstAt === 'number' && firstAt <= 3 / 60,
JSON.stringify({ firstAt }),
);
// 67b. A second start while one records is ignored, and says so in the log.
await press('g');
await sleep(300);