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; +});