From 7620c72f01643a080544afc201228e9041f1821a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 12 Aug 2026 03:48:10 +0000 Subject: [PATCH] games: smooth the ball in pong and breakout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate things were making the ball jiggle, and only one of them was about the ball. The clock was re-armed off every keypress. A held arrow key does not arrive as one key, it arrives as a burst of repeats, and each one cancelled the pending tick and started the period again — so at any repeat rate faster than the tick, the ball stopped dead for as long as you steered and then lurched. Measured on pong at a 30/s repeat: 0 ticks in four seconds. The clock now runs on its own deadline, and a keypress may only pull the next tick nearer, never push it away. Chess still gets its fast reply; the ball no longer waits for you. Pong and breakout ticked at 55 and 50ms. The ball can only be drawn on whole cells, so at fifteen columns a second it advanced a cell on six ticks out of seven and stood still on the seventh — and that stalled frame, three times a second, is the jiggle. Both now tick at 16ms with the speeds scaled to match, so the ball is in the same place at the same moment and the stall is 16ms instead of 55. Jitter between column steps drops from 21ms to 6ms, worst stall from 112ms to 66ms. The tuned constants are kept as they were, written per 55ms and scaled, because they are what makes the machine beatable off the end of the paddle and not from the middle. Redrawing erased the whole board and wrote it again, twenty-one rows for a ball that moved one cell — the erase is a visible blink, and over the pit's socket it is the delay. Only changed rows are written now, skipped over with a cursor-down rather than a newline so a frame at the bottom of the screen cannot scroll it. 15.8 KiB/s down to 2.1 KiB/s. The clock and the painter are shared, so every real-time game in the cabinet gets the same two fixes. Co-Authored-By: Claude Opus 5 (1M context) --- src/games-breakout.mjs | 34 ++++++++--- src/games-pong.mjs | 45 ++++++++++++--- src/games.mjs | 128 ++++++++++++++++++++++++++++++++++------- test/games.test.mjs | 121 +++++++++++++++++++++++++++++++++++++- 4 files changed, 286 insertions(+), 42 deletions(-) diff --git a/src/games-breakout.mjs b/src/games-breakout.mjs index 798e21b..3486a14 100644 --- a/src/games-breakout.mjs +++ b/src/games-breakout.mjs @@ -16,13 +16,31 @@ export const BRICK_TOP = 1; export const PADDLE_W = 7; export const PADDLE_ROW = HEIGHT - 1; -const PADDLE_STEP = 2; +const PADDLE_STEP = 2; // a keypress, not a tick — unchanged by the tick rate + +/** + * How often the wall is stepped. See the note in games-pong.mjs: the ball can + * only be drawn on whole cells, so smoothness comes from ticking often enough + * that the frames where it has not crossed into the next one go by too fast to + * read as a stall. Those frames are identical, and `runGame` does not write + * identical frames, so the extra ticks cost nothing on the wire. + * + * Ticking finer buys this game a second thing: at 0.34 rows a tick the ball + * used to cross a whole brick row between two samples, so which side it bounced + * off was a guess. It now samples inside every row it enters. + */ +export const TICK_MS = 16; + +/** The speeds below are still written per 50ms, the rate this was tuned at. */ +const SCALE = TICK_MS / 50; const LIVES = 3; -const BASE_VX = 0.62; -const BASE_VY = 0.34; // rows per tick — half of vx, because a row is two columns -const SPIN = 0.5; -const LEVEL_UP = 1.12; +const BASE_VX = 0.62 * SCALE; +const BASE_VY = 0.34 * SCALE; // half of vx, because a row is two columns +const SPIN = 0.5 * SCALE; +const MAX_VX = 1.4 * SCALE; +const MIN_VX = 0.15 * SCALE; // never let it go vertical and unsteerable +const LEVEL_UP = 1.12; // a multiplier on pace, so it does not scale /** Top rows are worth more, which is what makes the ball worth risking. */ export const ROW_POINTS = [50, 40, 30, 20, 10]; @@ -98,8 +116,8 @@ export function step(state) { ball.y = PADDLE_ROW - 1; ball.vy = -Math.abs(ball.vy); // The steering wheel: the further out you take it, the flatter it leaves. - ball.vx = clamp(ball.vx + (off / (PADDLE_W / 2)) * SPIN * 0.5, -1.4, 1.4); - if (Math.abs(ball.vx) < 0.15) ball.vx = ball.vx < 0 ? -0.15 : 0.15; + ball.vx = clamp(ball.vx + (off / (PADDLE_W / 2)) * SPIN * 0.5, -MAX_VX, MAX_VX); + if (Math.abs(ball.vx) < MIN_VX) ball.vx = ball.vx < 0 ? -MIN_VX : MIN_VX; } } @@ -129,7 +147,7 @@ export const BREAKOUT = { title: "BREAKOUT", blurb: "dig a channel up the side and let the ball do the rest", keys: "← → paddle · space launch · q quit", - tickMs: 50, + tickMs: TICK_MS, create({ rng = Math.random } = {}) { const state = { diff --git a/src/games-pong.mjs b/src/games-pong.mjs index f78287f..97feb30 100644 --- a/src/games-pong.mjs +++ b/src/games-pong.mjs @@ -20,11 +20,38 @@ export const YOU_COL = 2; export const THEM_COL = WIDTH - 3; export const TARGET = 7; // first to this many -const SERVE_SPEED = 0.85; -const MAX_SPEED = 1.7; -const SPIN = 0.55; // how much the edge of the paddle bends the ball -const THEM_SPEED = 0.3; // slow enough that a ball into the corner beats it -const YOU_STEP = 1; +/** + * How often the table is stepped. + * + * The board is a grid of characters, so wherever the ball really is it can only + * ever be drawn on a whole cell — and what reads as smooth is not the size of + * that step but the evenness of it. At 55ms a ball crossing fifteen columns a + * second advances a cell on six ticks out of seven and stands still on the + * seventh, and that one stalled frame, arriving three times a second, is the + * jiggle. Ticking at 16ms does not move the ball anywhere different at any + * given moment; it shrinks the stall from 55ms to 16ms, which is under what the + * eye reads as a stop. It is close to free, too: a tick that leaves the ball in + * the same cell renders an identical frame, and `runGame` never writes one of + * those to the terminal. + */ +export const TICK_MS = 16; + +/** + * The speeds below are still written per 55ms — the rate this game was tuned + * at — and scaled to the tick. Keeping the tuned numbers legible matters more + * than saving a multiply: they are what makes the machine beatable off the end + * of the paddle and not from the middle, and that balance is the game. + */ +const SCALE = TICK_MS / 55; + +const SERVE_SPEED = 0.85 * SCALE; +const MAX_SPEED = 1.7 * SCALE; +const SPIN = 0.55 * SCALE; // how much the edge of the paddle bends the ball +const THEM_SPEED = 0.3 * SCALE; // slow enough that a ball into the corner beats it +const FLAT = 0.08 * SCALE; // below this a rally has gone flat +const NUDGE = 0.12 * SCALE; // and this is the angle it is put back at +const MAX_VY = 0.5 * SCALE; +const YOU_STEP = 1; // a keypress, not a tick — the same either way const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); @@ -35,7 +62,7 @@ export function serve(state, toward) { y: HEIGHT / 2, vx: toward * SERVE_SPEED, // Never dead flat: a ball with no angle is a rally nobody can lose. - vy: (state.rng() < 0.5 ? -1 : 1) * (0.15 + state.rng() * 0.2), + vy: (state.rng() < 0.5 ? -1 : 1) * (0.15 + state.rng() * 0.2) * SCALE, }; return state; } @@ -52,10 +79,10 @@ const catches = (top, y) => y >= top - 0.5 && y <= top + PADDLE - 0.5; function returned(ball, top, dir) { const offset = (ball.y - (top + (PADDLE - 1) / 2)) / (PADDLE / 2); ball.vx = dir * Math.min(MAX_SPEED, Math.abs(ball.vx) * 1.06); - ball.vy = clamp(offset * SPIN * ASPECT + ball.vy * 0.3, -0.5, 0.5); + ball.vy = clamp(offset * SPIN * ASPECT + ball.vy * 0.3, -MAX_VY, MAX_VY); // Never let a rally go flat. A ball with no angle is one the machine can park // in front of forever, and a rally that cannot end is not a game. - if (Math.abs(ball.vy) < 0.08) ball.vy = (ball.vy < 0 ? -1 : 1) * 0.12; + if (Math.abs(ball.vy) < FLAT) ball.vy = (ball.vy < 0 ? -1 : 1) * NUDGE; return ball; } @@ -102,7 +129,7 @@ export const PONG = { title: "PONG", blurb: "first to seven, and the angle is all in where you hit it", keys: "↑ ↓ move · q quit", - tickMs: 55, + tickMs: TICK_MS, create({ rng = Math.random } = {}) { const state = { diff --git a/src/games.mjs b/src/games.mjs index 68510e8..40aced6 100644 --- a/src/games.mjs +++ b/src/games.mjs @@ -194,9 +194,20 @@ const ESC = { hideCursor: "\x1b[?25l", showCursor: "\x1b[?25h", up: (n) => (n > 0 ? `\x1b[${n}A` : ""), + down: (n) => (n > 0 ? `\x1b[${n}B` : ""), eraseDown: "\x1b[0J", + eraseLine: "\x1b[K", }; +/** + * How many ticks a late clock is allowed to make up in one go. + * + * Enough to ride out a garbage collection or a busy event loop without the game + * quietly running slow; small enough that a laptop coming back from sleep + * resumes the game rather than fast-forwarding the ball across the board. + */ +const CATCH_UP = 4; + /** * Play one game until `q`. * @@ -214,46 +225,115 @@ export async function runGame(game, deps = {}) { // turn, deterministically, with no timers left running after the assertion. setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = (t) => clearTimeout(t), + // The clock the tick deadline is measured against. Injectable for the same + // reason the timer is: a test should be able to say what time it is. + now = () => Date.now(), } = deps; const ctx = { rng }; let state = game.create(ctx); - let height = 0; let timer = null; let closed = false; + /** The lines currently on the screen, so a redraw can write only the changes. */ let painted = null; const draw = () => { if (closed) return; - const text = frame({ + const lines = frame({ title: game.title, status: game.status(state), rows: game.render(state), keys: state.over ? `${game.keys} · ${bone("r")} again` : game.keys, - }); + }).split("\n"); + // A frame identical to the one already on the screen is not written at all. // Chess idles on its clock while it is your move, and repainting the same // board twice a second is exactly the flicker that would make it feel busy. - if (text === painted) return; - output.write(`${ESC.up(height)}${ESC.eraseDown}${text}\n`); - painted = text; - height = text.split("\n").length; + // It is also what lets the fast games tick at 60Hz for free: a ball that has + // not crossed into a new cell yet produces the same frame, and the same + // frame costs nothing. + const same = painted?.length === lines.length && lines.every((l, i) => l === painted[i]); + if (same) return; + + if (!painted || painted.length !== lines.length) { + // First frame, or one that changed height: there is nothing to diff + // against, so clear what was there and lay the whole thing down. + output.write(`${ESC.up(painted?.length ?? 0)}${ESC.eraseDown}${lines.join("\n")}\n`); + } else { + // Only the rows that actually changed are written. A pong ball crossing a + // cell touches two rows out of twenty-one, and repainting the other + // nineteen is both the blink you can see — a full repaint has to erase + // first, and for that instant the board is not there — and, over the + // pit's socket, twenty times the bytes standing between a tick and a + // moved ball. Rows are skipped with a cursor-down rather than a newline + // so that a frame sitting at the bottom of the screen cannot scroll it. + let out = ESC.up(lines.length); + let row = 0; + for (let i = 0; i < lines.length; i++) { + if (lines[i] === painted[i]) continue; + out += `${ESC.down(i - row)}\r${lines[i]}${ESC.eraseLine}`; + row = i; + } + output.write(`${out}${ESC.down(lines.length - row)}\r`); + } + painted = lines; }; + const tickMs = () => (typeof game.tickMs === "function" ? game.tickMs(state) : game.tickMs); const stop = () => { if (timer !== null) { clearTimer(timer); timer = null; } }; - const schedule = () => { + + /** When the next tick is due. Null means the clock is not running. */ + let dueAt = null; + + /** + * Arm the clock for the next tick, on its own deadline. + * + * Sleeping a full period from the moment the last tick *finished* is how a + * game ends up running slower than the rate it asked for: the work and the + * timer's own overshoot are added to every period, and the error accumulates. + * Counting from a deadline instead keeps the ball on real time. + */ + const arm = () => { stop(); - if (!game.tickMs || state.over) return; - const ms = typeof game.tickMs === "function" ? game.tickMs(state) : game.tickMs; - timer = setTimer(() => { - timer = null; - if (closed || state.over) return; - state = game.tick(state, ctx) || state; - draw(); - schedule(); - }, ms); + if (!game.tickMs || state.over || closed) return; + if (dueAt === null) dueAt = now() + tickMs(); + timer = setTimer(onTick, Math.max(0, dueAt - now())); + }; + + /** + * What a keypress is allowed to do to the clock: bring the next tick nearer, + * never push it away. + * + * Both halves matter. Chess runs its clock slowly while it is your move and + * quickly once it is the engine's, so the move you just made has to be able to + * pull the next tick forward or the reply arrives a beat late. But a key that + * could push the tick back is how the ball used to stall: a held arrow arrives + * as a burst of repeats, and re-arming on each one kept the next tick + * permanently a full period away, for as long as you held the key down. + */ + const nudge = () => { + if (!game.tickMs || state.over || closed) return; + const soonest = now() + tickMs(); + if (timer !== null && dueAt !== null && dueAt <= soonest) return; + dueAt = soonest; + arm(); }; + function onTick() { + timer = null; + if (closed || state.over) return; + const ms = tickMs(); + // One step for the tick that just came due, plus any whole ticks the event + // loop was too busy to deliver, so a stall shows up as a jump rather than + // as the whole game quietly slowing down and speeding back up. + const late = Math.max(0, now() - dueAt); + const steps = Math.min(CATCH_UP, 1 + Math.floor(late / ms)); + dueAt += steps * ms; + for (let i = 0; i < steps && !state.over; i++) state = game.tick(state, ctx) || state; + draw(); + arm(); + } + const wasRaw = Boolean(input.isRaw); const restore = () => { if (closed) return; @@ -271,16 +351,20 @@ export async function runGame(game, deps = {}) { if (key === "quit" || key === "q" || key === "escape") { restore(); resolve(); return; } if (key === "r" && (state.over || game.restartable !== false)) { state = game.create(ctx); + dueAt = null; // a new game starts its clock from now, not from the old one draw(); - schedule(); + arm(); continue; } if (state.over) continue; // a finished board takes r and q, nothing else state = game.onKey(state, key, ctx) || state; draw(); - // A key can end a real-time game (a hard drop into the ceiling) or start - // one moving again, so the clock is re-armed off every keypress. - if (game.tickMs) schedule(); + // A key can end a real-time game — a hard drop into the ceiling — so the + // clock is stopped when that happens. Otherwise see `nudge`. + if (game.tickMs) { + if (state.over) stop(); + else nudge(); + } } } @@ -296,7 +380,7 @@ export async function runGame(game, deps = {}) { process.on("SIGTERM", onSignal); draw(); - schedule(); + arm(); await done; restore(); process.off("SIGINT", onSignal); diff --git a/test/games.test.mjs b/test/games.test.mjs index b8c20ef..1892f82 100644 --- a/test/games.test.mjs +++ b/test/games.test.mjs @@ -33,6 +33,7 @@ import { } from "../src/games-breakout.mjs"; import { PONG, PADDLE, YOU_COL, TARGET as PONG_TARGET, WIDTH as WIDTH_P, HEIGHT as HEIGHT_P, + TICK_MS as PONG_TICK_MS, } from "../src/games-pong.mjs"; import { TANK, TARGET as TANK_TARGET, drive as driveTank, isWall as isYardWall, lineOfSight, quarterTurn, stepToward, @@ -1222,12 +1223,14 @@ test("a wall can be cleared, and cannot be cleared by leaving the paddle alone", if (want < state.paddle) BREAKOUT.onKey(state, "left"); else if (want > state.paddle) BREAKOUT.onKey(state, "right"); }; + // Budgets are in ticks and a tick is 16ms, so these are about five minutes of + // play with a paddle that never misses, and about two and a half without one. for (let seed = 1; seed <= 5; seed++) { - const state = drive(BREAKOUT, BREAKOUT.create({ rng: seeded(seed) }), 6000, tracking); + const state = drive(BREAKOUT, BREAKOUT.create({ rng: seeded(seed) }), 20000, tracking); assert.ok(state.level > 1, `seed ${seed} never cleared a wall`); } for (let seed = 1; seed <= 5; seed++) { - const state = drive(BREAKOUT, BREAKOUT.create({ rng: seeded(seed) }), 3000, (s) => { + const state = drive(BREAKOUT, BREAKOUT.create({ rng: seeded(seed) }), 10000, (s) => { if (s.stuck) BREAKOUT.onKey(s, "space"); }); assert.ok(state.over, `seed ${seed} survived without touching the paddle`); @@ -1248,7 +1251,10 @@ test("the breakout board is drawn to size", () => { test("a serve is never dead flat", () => { for (let seed = 1; seed <= 20; seed++) { const state = PONG.create({ rng: seeded(seed) }); - assert.ok(Math.abs(state.ball.vy) > 0.1, "a flat serve is a rally nobody can lose"); + // Measured in rows per second rather than rows per tick, so that this says + // something about the game rather than about the rate it happens to run at. + const rowsPerSecond = (Math.abs(state.ball.vy) * 1000) / PONG_TICK_MS; + assert.ok(rowsPerSecond > 2, `a flat serve is a rally nobody can lose (${rowsPerSecond})`); assert.ok(Math.abs(state.ball.vx) > 0); } }); @@ -2525,3 +2531,112 @@ test("r starts another game once the last one is over", async () => { input.emit("data", "\x03"); assert.equal(await done, 0); }); + +/** A clock and a timer a test can hold still, plus a count of the arming. */ +function fakeClock(start = 1000) { + const c = { at: start, fire: null, armed: 0, cleared: 0 }; + c.now = () => c.at; + c.setTimer = (fn) => { c.armed++; c.fire = fn; return c.armed; }; + c.clearTimer = () => { c.cleared++; c.fire = null; }; + /** Let `ms` of wall clock go by and then let the timer go off. */ + c.tick = (ms) => { c.at += ms; const go = c.fire; c.fire = null; go?.(); }; + return c; +} + +test("a keypress does not postpone the next tick", async () => { + // The bug this replaces: the clock was re-armed off every keypress, and a held + // arrow key arrives as a burst of repeats rather than as one key. Each repeat + // pushed the next tick a full period into the future, so the ball stalled for + // exactly as long as you held the paddle down and then lurched. It read as the + // ball jiggling; it was the ball being stopped. + const { input, output } = fakeIO(); + const clock = fakeClock(); + const done = runGame(PONG, { input, output, rng: seeded(5), ...clock }); + await new Promise((r) => setImmediate(r)); + + const armed = clock.armed; + for (let i = 0; i < 20; i++) input.emit("data", "\x1b[B"); // hold the down arrow + assert.equal(clock.armed, armed, "a keypress re-armed a clock that was already running"); + assert.equal(clock.cleared, 0, "and cancelled the tick that was already pending"); + + input.emit("data", "q"); + await done; +}); + +test("a keypress may still bring the clock forward", async () => { + // The other side of the rule above. A game whose tick rate depends on its + // state — chess thinks in 80ms once it is the engine's move and idles at 400ms + // while it is yours — has to be able to pull the next tick nearer when a key + // changes which of those it is, or the reply lands a beat late. + const { input, output } = fakeIO(); + const clock = fakeClock(); + const slow = { ...HANGMAN, tickMs: (s) => (s.hurry ? 20 : 5000), tick: (s) => s }; + const done = runGame(slow, { input, output, rng: seeded(9), ...clock }); + await new Promise((r) => setImmediate(r)); + + const armed = clock.armed; + input.emit("data", "a"); // hangman takes the letter; nothing about the clock changes + assert.equal(clock.armed, armed, "a key that did not change the rate re-armed anyway"); + + slow.tickMs = () => 20; // now the same key means a much shorter period + input.emit("data", "b"); + assert.equal(clock.armed, armed + 1, "a key that shortened the period did not pull the tick in"); + + input.emit("data", "q"); + await done; +}); + +test("a tick that arrives late makes up the ticks it missed", async () => { + const { input, output } = fakeIO(); + const clock = fakeClock(); + let ticks = 0; + const spy = { ...PONG, tick: (s, ctx) => { ticks++; return PONG.tick(s, ctx); } }; + const done = runGame(spy, { input, output, rng: seeded(3), ...clock }); + await new Promise((r) => setImmediate(r)); + + ticks = 0; + clock.tick(PONG_TICK_MS * 3); // the event loop was busy for three periods + assert.equal(ticks, 3, "a late clock should keep real time, not quietly run slow"); + + // But only up to a point: a laptop coming back from sleep should resume the + // game rather than fast-forward the ball the length of the table. + ticks = 0; + clock.tick(PONG_TICK_MS * 500); + assert.ok(ticks > 0 && ticks <= 8, `a long stall fast-forwarded ${ticks} ticks`); + + input.emit("data", "q"); + await done; +}); + +test("a redraw writes only the rows that changed, and lands back where it started", async () => { + const { input, output, written } = fakeIO(); + const clock = fakeClock(); + const done = runGame(PONG, { input, output, rng: seeded(2), ...clock }); + await new Promise((r) => setImmediate(r)); + const whole = written.join(""); + + // Tick until the ball crosses into a new cell, and weigh what that cost. + let update = ""; + for (let i = 0; i < 60 && !update; i++) { + written.length = 0; + clock.tick(PONG_TICK_MS); + update = written.join(""); + } + assert.ok(update, "the ball never moved"); + assert.ok(!update.includes("\x1b[0J"), "erasing the board before redrawing it is the blink"); + assert.ok( + update.length * 3 < whole.length, + `a moved ball rewrote ${update.length} bytes of a ${whole.length}-byte board`, + ); + + // The frame is redrawn in place by walking up over it and back down, so the + // rows have to add up exactly. If they ever do not, the board walks off up the + // screen a line at a time — slowly enough that only a long game would show it. + let net = 0; + for (const [, n, dir] of update.matchAll(/\x1b\[(\d+)([AB])/g)) net += (dir === "A" ? -1 : 1) * Number(n); + net += (update.match(/\n/g) ?? []).length; + assert.equal(net, 0, "a redraw moved the cursor off the frame it started on"); + + input.emit("data", "q"); + await done; +});