Skip to content

Commit d3bc6a6

Browse files
ralyodioclaude
andauthored
games: pace the ball off its own clock, and play both games faster (#378)
The ball in pong and breakout was still slow and still jiggled after the tick rate and the half-row grid were both fixed. Measured, two things were left, and they were separate. The jiggle was cadence. Rounding the true position to the lattice every tick moves the drawn ball when it crosses a column edge or a half-row edge, and those two are on unrelated schedules. When their periods are close but not equal — which is what a near-diagonal is, and what breakout launches at — they beat: breakout held a cell for 16ms, then 80ms, then 48ms, several times a second, and pong ran 16/32/48/64. The steps were the right size and the ball still looked like it was struggling, because nothing was moving at a rate. Whenever both crossings landed on the same tick it also lurched a diagonal 1.41 units in one frame. So the true position no longer decides when the ball is drawn, only which way it owes a step. Steps are paid out by a clock running at the ball's own speed, so it moves every 1/speed ticks at any angle, trailing the truth by under half a character. Worst jump is now exactly one unit, and the longest stall drops from 64ms to 32ms in pong and 80ms to 48ms in breakout. The slowness was just slowness: the old ball crossed the board in about three seconds. Both games now scale every tuned speed by a single PACE, the machine and the spin along with the ball, so the balance that makes a ball into the corner beat the pong machine is exactly the one that was tuned — only the clock it is played against changes. That takes the drawn ball from 20 steps a second to 43 in both games. Breakout's paddle goes to three columns a press to stay ahead of the faster ball. The new test pins the cadence rather than the speed, and fails against the old sampling even at the new pace. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3af786b commit d3bc6a6

4 files changed

Lines changed: 202 additions & 30 deletions

File tree

src/games-breakout.mjs

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// decides the angle it leaves at, so the paddle is a steering wheel rather than
55
// a wall. Without that you cannot dig a channel up the side of the wall, and
66
// digging a channel is the entire reason anybody still plays this.
7-
import { ballCell } from "./games-draw.mjs";
7+
import { advanceBall, drawnBall, drawnCell, snapBall } from "./games-draw.mjs";
88
import { acid, amber, bone, danger, rgb } from "./ui.mjs";
99

1010
export const WIDTH = 40;
@@ -17,7 +17,11 @@ export const BRICK_TOP = 1;
1717

1818
export const PADDLE_W = 7;
1919
export const PADDLE_ROW = HEIGHT - 1;
20-
const PADDLE_STEP = 2; // a keypress, not a tick — unchanged by the tick rate
20+
// A keypress, not a tick, so this is unchanged by the tick rate — but it does
21+
// have to keep up with the ball, and a ball taken off the end of the paddle now
22+
// crosses a column in under two ticks. Three columns a press stays ahead of it
23+
// at a terminal's key-repeat rate; two only just did.
24+
const PADDLE_STEP = 3;
2125

2226
/**
2327
* How often the wall is stepped. See the note in games-pong.mjs: the ball can
@@ -32,8 +36,20 @@ const PADDLE_STEP = 2; // a keypress, not a tick — unchanged by the tick rate
3236
*/
3337
export const TICK_MS = 16;
3438

39+
/**
40+
* How hard the wall is played, against the pace it was first tuned at.
41+
*
42+
* Launched, the old ball took three seconds to cross the board and two and a
43+
* half to fall the height of it, which is a slow enough ball that you can put
44+
* the paddle under it and go and make a cup of tea. It also meant the drawn
45+
* ball moved twenty times a second, and twenty steps a second does not read as
46+
* travel however even they are. Everything below scales together, so a ball off
47+
* the end of the paddle leaves at the angle it always did.
48+
*/
49+
const PACE = 1.9;
50+
3551
/** The speeds below are still written per 50ms, the rate this was tuned at. */
36-
const SCALE = TICK_MS / 50;
52+
const SCALE = (TICK_MS / 50) * PACE;
3753

3854
const LIVES = 3;
3955
const BASE_VX = 0.62 * SCALE;
@@ -67,6 +83,7 @@ export function brickAt(wall, x, y) {
6783
function rest(state) {
6884
state.ball = { x: state.paddle + PADDLE_W / 2, y: PADDLE_ROW - 1, vx: 0, vy: 0 };
6985
state.stuck = true;
86+
state.drawn = drawnBall(state.ball.x, state.ball.y);
7087
return state;
7188
}
7289

@@ -82,8 +99,12 @@ export function launch(state) {
8299
export function step(state) {
83100
if (state.stuck) {
84101
// A ball that has not been launched rides the paddle, so moving before you
85-
// serve aims the serve.
102+
// serve aims the serve. It is carried rather than travelling, so it is put
103+
// where the paddle is rather than paced there — a stationary ball earns no
104+
// steps, and would otherwise sit still while the paddle slid out from under
105+
// it.
86106
state.ball.x = state.paddle + PADDLE_W / 2;
107+
snapBall(state.drawn, state.ball.x, state.ball.y);
87108
return state;
88109
}
89110

@@ -122,11 +143,16 @@ export function step(state) {
122143
}
123144
}
124145

146+
advanceBall(state.drawn, ball);
147+
125148
if (ball.y > PADDLE_ROW) {
126149
state.lives--;
127150
if (state.lives <= 0) {
128151
state.lives = 0;
129152
state.over = `out of balls · ${state.score} points`;
153+
// The last ball is left where it went, below the board and so off it,
154+
// rather than resting on the row it fell past.
155+
snapBall(state.drawn, ball.x, ball.y);
130156
return state;
131157
}
132158
rest(state);
@@ -198,9 +224,9 @@ export const BREAKOUT = {
198224
}
199225

200226
for (let i = 0; i < PADDLE_W; i++) put(state.paddle + i, PADDLE_ROW, bone("▀"));
201-
// Drawn on half-rows, so the ball steps the same distance down the wall as
202-
// it does across it. See games-draw.mjs.
203-
const ball = ballCell(state.ball.x, state.ball.y);
227+
// Drawn on half-rows and on its own even clock, so the ball steps the same
228+
// distance down the wall as across it, and at a rate. See games-draw.mjs.
229+
const ball = drawnCell(state.drawn);
204230
put(ball.col, ball.row, (state.stuck ? amber : bone)(ball.glyph));
205231

206232
return grid.map((row) => row.map((cell) => cell ?? " ").join(""));

src/games-draw.mjs

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,27 +24,92 @@
2424
// one, and the corner it turns is half as wide. It is also a better ball than
2525
// `●` was — a square pixel moving on a square grid, rather than a round dot
2626
// snapping between cells twice its own height apart.
27+
//
28+
// Half blocks fix the size of the steps. They do not fix when the steps happen,
29+
// which turned out to be the other half of it — see `drawnBall` below.
2730

2831
/**
29-
* Where to draw a ball whose true position is (x, y) in board coordinates.
32+
* The ball's position in half-rows — the unit it is actually drawn in.
3033
*
3134
* `y` is a row centre, so row r covers y from r - 0.5 up to r + 0.5: below the
32-
* centre the ball is in the top half of the cell, at or above it the bottom.
33-
* Returns the cell to write into and the half block to write there.
35+
* centre the ball is in the top half of that cell, at or above it the bottom.
36+
* Half-rows and columns are the same size on screen, so this and the column
37+
* together are a square lattice, and a step is a step whichever way it goes.
3438
*/
35-
export function ballCell(x, y) {
36-
const col = Math.round(x);
39+
export const halfRow = (y) => {
3740
const row = Math.round(y);
38-
return { col, row, glyph: y < row ? "▀" : "▄" };
41+
return y < row ? row * 2 : row * 2 + 1;
42+
};
43+
44+
/**
45+
* A drawn ball, released on its own even clock.
46+
*
47+
* Equal pitch fixed the size of the ball's steps but not their timing, and the
48+
* timing is the rest of the jiggle. Rounding the true position to the lattice
49+
* moves the ball whenever it happens to cross a column edge or a half-row edge,
50+
* and those two are on unrelated schedules: a ball with the two periods close
51+
* but not equal — which is what a near-diagonal is, and what breakout launches
52+
* at — beats between them. Measured, breakout held a cell for 16ms, then 80ms,
53+
* then 48ms, five times a second. The steps were the right size and still the
54+
* ball looked like it was struggling, because nothing was moving at a rate.
55+
*
56+
* So the true position is not what is drawn. It decides only which way the
57+
* drawn ball owes a step; when that step is paid is decided by a clock that
58+
* ticks at the ball's own speed. `owed` accrues at |vx| + 2|vy| lattice units
59+
* per tick — the distance the true ball covers, measured the way the drawn one
60+
* has to travel it — and a whole unit buys one step. The drawn ball therefore
61+
* moves every 1/speed ticks whatever angle it is on, trailing the true one by
62+
* under a unit, which is under half a character.
63+
*
64+
* It cannot fall behind: the same accrual that paces the ball also lets it pay
65+
* two steps in a tick when the ball is genuinely moving that fast.
66+
*/
67+
export function drawnBall(x, y) {
68+
return snapBall({ col: 0, half: 0, owed: 0 }, x, y);
3969
}
4070

4171
/**
42-
* The ball's position in half-rows — the unit it is actually drawn in.
72+
* Put the drawn ball exactly where the real one is, with no debt either way.
4373
*
44-
* Only the tests use this, to assert that a step down the board is the same
45-
* size as a step across it.
74+
* For the moves that are not travel and so have nothing to smooth: a serve, a
75+
* fresh ball on the paddle, the ball riding a paddle that is being aimed.
4676
*/
47-
export const halfRow = (y) => {
48-
const row = Math.round(y);
49-
return y < row ? row * 2 : row * 2 + 1;
50-
};
77+
export function snapBall(drawn, x, y) {
78+
drawn.col = Math.round(x);
79+
drawn.half = halfRow(y);
80+
drawn.owed = 0;
81+
return drawn;
82+
}
83+
84+
/** Far enough apart that the ball was put there rather than travelled there. */
85+
const TELEPORT = 4;
86+
87+
/** Pay out whatever steps the ball has earned this tick. */
88+
export function advanceBall(drawn, ball) {
89+
const col = Math.round(ball.x);
90+
const half = halfRow(ball.y);
91+
if (Math.abs(col - drawn.col) + Math.abs(half - drawn.half) >= TELEPORT) return snapBall(drawn, ball.x, ball.y);
92+
93+
drawn.owed += Math.abs(ball.vx) + Math.abs(ball.vy) * 2;
94+
while (drawn.owed >= 1) {
95+
const dcol = col - drawn.col;
96+
const dhalf = half - drawn.half;
97+
if (dcol === 0 && dhalf === 0) break;
98+
// Whichever axis is further behind goes first, which is what keeps a
99+
// diagonal a staircase instead of a sideways run and then a drop.
100+
if (Math.abs(dcol) >= Math.abs(dhalf)) drawn.col += Math.sign(dcol);
101+
else drawn.half += Math.sign(dhalf);
102+
drawn.owed -= 1;
103+
}
104+
// A ball that has caught up banks at most one step, so that standing still
105+
// for a moment cannot be turned into a lurch later.
106+
if (drawn.owed > 1) drawn.owed = 1;
107+
return drawn;
108+
}
109+
110+
/** The cell and half block to write for a drawn ball. */
111+
export const drawnCell = (drawn) => ({
112+
col: drawn.col,
113+
row: drawn.half >> 1,
114+
glyph: drawn.half % 2 === 0 ? "▀" : "▄",
115+
});

src/games-pong.mjs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
// A flat return it will always get; one taken off the end of your paddle it will
88
// not. That is the whole game, and it is why the angle off the paddle depends on
99
// where the ball hit it.
10-
import { ballCell } from "./games-draw.mjs";
10+
import { advanceBall, drawnBall, drawnCell, snapBall } from "./games-draw.mjs";
1111
import { acid, bone, danger, dim } from "./ui.mjs";
1212

1313
export const WIDTH = 44;
@@ -37,13 +37,25 @@ export const TARGET = 7; // first to this many
3737
*/
3838
export const TICK_MS = 16;
3939

40+
/**
41+
* How hard the table is played, against the pace it was first tuned at.
42+
*
43+
* The old pace put the ball across the table in just under three seconds. That
44+
* is not a ball being hit, it is a ball being carried, and it was also why the
45+
* drawn ball only moved twenty times a second — too few steps for any of them
46+
* to be smooth. Everything below is scaled by this, the machine along with the
47+
* ball, so the balance is exactly the one that was tuned; only the clock it is
48+
* played against changes.
49+
*/
50+
const PACE = 1.9;
51+
4052
/**
4153
* The speeds below are still written per 55ms — the rate this game was tuned
4254
* at — and scaled to the tick. Keeping the tuned numbers legible matters more
4355
* than saving a multiply: they are what makes the machine beatable off the end
4456
* of the paddle and not from the middle, and that balance is the game.
4557
*/
46-
const SCALE = TICK_MS / 55;
58+
const SCALE = (TICK_MS / 55) * PACE;
4759

4860
const SERVE_SPEED = 0.85 * SCALE;
4961
const MAX_SPEED = 1.7 * SCALE;
@@ -65,6 +77,8 @@ export function serve(state, toward) {
6577
// Never dead flat: a ball with no angle is a rally nobody can lose.
6678
vy: (state.rng() < 0.5 ? -1 : 1) * (0.15 + state.rng() * 0.2) * SCALE,
6779
};
80+
// A serve is a ball put on the table, not a ball that travelled there.
81+
state.drawn = drawnBall(state.ball.x, state.ball.y);
6882
return state;
6983
}
7084

@@ -106,6 +120,12 @@ export function step(state) {
106120

107121
if (ball.x < 0) { state.theirs++; point(state, 1); }
108122
else if (ball.x > WIDTH - 1) { state.yours++; point(state, -1); }
123+
// Anything else is the ball travelling, which is the only thing the drawn
124+
// ball is asked to follow — a serve puts it back itself.
125+
else advanceBall(state.drawn, ball);
126+
// A match ends with the ball where it went out, off the table and so off the
127+
// board, rather than parked on the edge it left by.
128+
if (state.over) snapBall(state.drawn, ball.x, ball.y);
109129

110130
// The machine: idle in the middle until the ball is on its half, then chase
111131
// the ball's row. Perfect tracking here would make the game unloseable for it,
@@ -165,9 +185,9 @@ export const PONG = {
165185

166186
for (const row of paddleRows(state.you)) put(YOU_COL, row, acid("█"));
167187
for (const row of paddleRows(state.them)) put(THEM_COL, row, danger("█"));
168-
// Drawn on half-rows, so the ball steps the same distance up the table as
169-
// it does across it. See games-draw.mjs.
170-
const ball = ballCell(state.ball.x, state.ball.y);
188+
// Drawn on half-rows and on its own even clock, so the ball steps the same
189+
// distance up the table as across it, and at a rate. See games-draw.mjs.
190+
const ball = drawnCell(state.drawn);
171191
put(ball.col, ball.row, bone(ball.glyph));
172192

173193
return grid.map((row, y) => row.map((cell, x) => (

test/games.test.mjs

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,13 @@ import {
3030
import {
3131
BREAKOUT, BRICK_ROWS, BRICK_COLS, BRICK_TOP, BRICK_W, PADDLE_W, PADDLE_ROW, ROW_POINTS,
3232
brickAt, bricksLeft, buildWall, WIDTH as WIDTH_B, HEIGHT as HEIGHT_B,
33+
TICK_MS as BREAKOUT_TICK_MS,
3334
} from "../src/games-breakout.mjs";
3435
import {
3536
PONG, PADDLE, YOU_COL, TARGET as PONG_TARGET, WIDTH as WIDTH_P, HEIGHT as HEIGHT_P,
3637
TICK_MS as PONG_TICK_MS,
3738
} from "../src/games-pong.mjs";
38-
import { ballCell, halfRow } from "../src/games-draw.mjs";
39+
import { drawnBall, drawnCell, halfRow } from "../src/games-draw.mjs";
3940
import {
4041
TANK, TARGET as TANK_TARGET, drive as driveTank, isWall as isYardWall, lineOfSight, quarterTurn, stepToward,
4142
WIDTH as WIDTH_T, HEIGHT as HEIGHT_T,
@@ -1249,10 +1250,12 @@ test("the breakout board is drawn to size", () => {
12491250

12501251
/* --------------------------------------------------------------- the ball */
12511252

1253+
const cellAt = (x, y) => drawnCell(drawnBall(x, y));
1254+
12521255
test("a ball above the middle of its row is drawn in the top half of it", () => {
1253-
assert.deepEqual(ballCell(4, 8), { col: 4, row: 8, glyph: "▄" });
1254-
assert.deepEqual(ballCell(4, 7.7), { col: 4, row: 8, glyph: "▀" });
1255-
assert.deepEqual(ballCell(4, 8.3), { col: 4, row: 8, glyph: "▄" });
1256+
assert.deepEqual(cellAt(4, 8), { col: 4, row: 8, glyph: "▄" });
1257+
assert.deepEqual(cellAt(4, 7.7), { col: 4, row: 8, glyph: "▀" });
1258+
assert.deepEqual(cellAt(4, 8.3), { col: 4, row: 8, glyph: "▄" });
12561259
// The halves meet at the row centre and the cell at its edges, with no gap
12571260
// and no row that two different heights round into the wrong way.
12581261
assert.equal(halfRow(7.6) + 1, halfRow(8.0), "the two halves of a row are adjacent");
@@ -1292,12 +1295,70 @@ test("a ball crossing a row is drawn twice on the way", () => {
12921295
// falling one whole row passes through two drawn positions, not one.
12931296
const seen = new Set();
12941297
for (let y = 7.5; y < 8.5; y += 0.05) {
1295-
const { row, glyph } = ballCell(3, y);
1298+
const { row, glyph } = cellAt(3, y);
12961299
seen.add(`${row}${glyph}`);
12971300
}
12981301
assert.equal(seen.size, 2, "a row is two steps tall, not one");
12991302
});
13001303

1304+
/**
1305+
* Play a game and report every move the *drawn* ball made: how far it went, and
1306+
* how many ticks it had stood still first.
1307+
*/
1308+
function drawnMoves(game, state, ticks, act = () => {}) {
1309+
const moves = [];
1310+
let previous = null;
1311+
let last = 0;
1312+
for (let t = 0; t < ticks && !state.over; t++) {
1313+
act(state);
1314+
state = game.tick(state) || state;
1315+
if (state.over) break;
1316+
const at = { col: state.drawn.col, half: state.drawn.half };
1317+
if (previous && (at.col !== previous.col || at.half !== previous.half)) {
1318+
const jump = Math.hypot(at.col - previous.col, at.half - previous.half);
1319+
// A serve puts the ball back in the middle; that is not a step.
1320+
if (jump < 4) moves.push({ jump, waited: t - last });
1321+
last = t;
1322+
}
1323+
previous = at;
1324+
}
1325+
return moves;
1326+
}
1327+
1328+
test("the drawn ball moves at a rate, not whenever it happens to cross a line", () => {
1329+
// The two fixes before this one got the ball's steps to the right size and
1330+
// still left it looking like it was struggling, because the steps were not
1331+
// evenly spaced: rounding the true position moves the ball when it crosses a
1332+
// column edge or a half-row edge, and those are on unrelated schedules. Pong
1333+
// stepped after 16ms, then 64ms; breakout ran 16, 80, 48, five times a
1334+
// second. What is pinned here is the cadence, which is the thing that was
1335+
// actually wrong — see `drawnBall` in games-draw.mjs.
1336+
const cases = [
1337+
["pong", PONG, PONG_TICK_MS, () => PONG.create({ rng: seeded(9) }), () => {}],
1338+
["breakout", BREAKOUT, BREAKOUT_TICK_MS, () => BREAKOUT.create({ rng: seeded(4) }), (s) => { if (s.stuck) BREAKOUT.onKey(s, "space"); }],
1339+
];
1340+
1341+
for (const [name, game, tickMs, create, act] of cases) {
1342+
const moves = drawnMoves(game, create(), 6000, act);
1343+
assert.ok(moves.length > 100, `${name}: not enough of a rally to measure (${moves.length})`);
1344+
1345+
// One lattice unit at a time. A diagonal taken in a single frame is the
1346+
// lurch the old sampler produced whenever both crossings landed together.
1347+
const worst = Math.max(...moves.map((m) => m.jump));
1348+
assert.equal(worst, 1, `${name}: the ball jumped ${worst.toFixed(2)} units at once`);
1349+
1350+
// Nothing the eye can read as a stop. Three ticks is 48ms; the old sampler
1351+
// sat still for four and five.
1352+
const stalled = Math.max(...moves.map((m) => m.waited));
1353+
assert.ok(stalled <= 3, `${name}: the ball stood still for ${stalled * tickMs}ms`);
1354+
1355+
// And it has to be moving. Twenty steps a second — where both games were —
1356+
// is a ball being carried rather than hit.
1357+
const perSecond = 1000 / ((moves.reduce((n, m) => n + m.waited, 0) / moves.length) * tickMs);
1358+
assert.ok(perSecond > 35, `${name}: only ${perSecond.toFixed(1)} steps a second`);
1359+
}
1360+
});
1361+
13011362
/* ------------------------------------------------------------------- pong */
13021363

13031364
test("a serve is never dead flat", () => {

0 commit comments

Comments
 (0)