An opponent that predicts the bounce, but not too well
A Pong opponent worth playing needs two things: an accurate prediction of where the ball lands, and a deliberate error. The first is one triangle wave; the second is redrawn on every return.
An opponent that cannot predict is boring. An opponent that predicts perfectly cannot be beaten. Pong AI needs exactly two things: get the landing point right, and be wrong a little every time.
Predicting the landing without simulating
If you step the ball forward and check collisions to find the future, prediction becomes “simulate ahead” — slow, and liable to drift away from the real physics. Cheaper is to unfold the court along both walls into an infinite strip: in that unfolded space the ball travels a straight line, so the landing point is just the end of that line, folded back into the court.
The fold is a triangle wave:
const low = radius;
const high = courtH - radius;
const span = high - low;
const period = span * 2;
const folded = ((raw - low) % period + period) % period;
return low + (folded <= span ? folded : period - folded);
One evaluation, no loop, and it does not care which way the ball is going. There is a subtle trap: when the vertical speed is zero you must return the current y directly, otherwise the fold hands back values like 20.000000000000007. It changes nothing on screen, but it makes equality assertions in the test suite fail at random.
Put the error in the right place
The AI aims at landing + bias. The bias is redrawn each time the player returns the ball, not jittered every frame: per-frame jitter just makes the paddle shiver, which does not read as a misjudgement. Redrawing per rally produces the rhythm you want — one return misread, the next one read correctly.
The bias is about a tenth of the court height. Too small and it never loses; too large and it looks like it is not thinking. The player’s paddle is slightly faster than the AI’s, so the way to win is not a speed contest — it is hitting to the far corner and making the opponent cover extra ground while carrying its error.
Rules and rendering, separated
The rules live in src/games/pong/logic/game.ts: zero imports, no DOM, and step(state, { target, dt }) returns a new state with the RNG state carried inside it. The same seed plus the same two hundred frames always produces the same result — and that property is an assertion in the test suite rather than a comment.
Rendering does three jobs: map the 100×60 abstract coordinates onto the canvas, read the current theme colours out of CSS variables, and write the score into the HUD. Paddle and ball colours come from --c-accent and --c-text, so switching the site to dark mode repaints the court.
The ball is a square, and not only out of laziness: nothing on this site has rounded corners, and a circle would immediately look like it came from somewhere else.
Closing
The value of an AI is not strength, it is legibility. The player has to feel that the opponent is calculating, and feel that it can be wrong. Get both of those right and the difficulty never has to be cranked up through raw speed.

Comments
…