v1.2 — The bat now hits where it looks like it is
v1.1 gave the bat a body: acceleration, a top speed, inertia. This release is about the other half of that sentence — what happens when that body meets the ball. I rewrote that part from scratch, and on the way I found out the bat had been moving twice per frame for two releases.
The short version: nothing about the bounce angles changed. Everything about whether there is a bounce did.
Three complaints that turned out to be one bug Three notes I'd been carrying for months, all filed under "feel", none reproducible on demand:
The ball goes through the bat. Rare — mostly on Impossible, mostly with the curved bat. The ball bounces back from behind the bat. A gift I didn't earn. I get the bat there in time and nothing happens. Different symptoms, one root: the collision test had nothing to do with the shape on the screen.
The old test was a box — |ball.x − bat.x| < batW/2 + r, same in y — evaluated once, at the end of the frame, against the bat's final position. Three consequences fall straight out of that:
Square corners. A box reaches about 1.4× further diagonally than a circle does. A ball passing beside the tip, visibly clear of it, bounced anyway. Depth. The band was a full bat thickness plus a ball radius deep — 43 px at 720p, roughly five frames of travel. A ball that had already passed the bat still counted as a hit, and the code then parked it back on the front face. That's complaint 2, precisely. One point in time. On Impossible the ball tops out at 1100 px/s. Let the frame rate dip a little and that's ~27 px between two samples, against a body 26 px thick. Complaint 1: the ball was never sampled inside the bat, so as far as the game was concerned it never touched it. The collision shape is now the drawn body The bat is drawn as fillRoundedRect(0, 0, w, h, w/2) — which is to say a capsule: a line segment with a radius, its ends closed by half-circles. The curved bat is that same capsule bent along an arc.
So that's what it now collides as. There's a spine (a vertical segment for the straight bat; for the curved one, an arc concentric with the front face), and the ball touches the bat when its centre comes within batW/2 + ballRadius of that spine. No corners, no depth band, and no second geometry that can quietly drift away from the art. The ball bounces where the bat looks like it is.
On top of the shape there are three rules, and which one applies is pure geometry:
The field-facing side — the front face plus the front half of the rounded caps — keeps the classic position-based bounce, untouched: vy = 0.78 · (dy/half) · speed. A ball arriving from the field always lands here, so the feel and the full angle range (±51°, right out to the tips) are exactly what they were. The back side — in practice, the back of a rounded end — now does a true reflection off the surface normal. This isn't a save: the normal points away from the field, so the ball leaves outward. What you see is that you just clipped it with the end of the bat and flicked it away. In the old code this was either a magic save or a silent pass-through, depending on the angle. Already touching last frame? Not a hit. The bat moves along its own plane, and that plane never moves sideways. A real bat couldn't scoop back a ball that got behind it, and this one can't either — the most a late yank can do is shove it further out, under the back-side rule. And the test is swept: instead of one sample at the end of the frame, it walks the segment from the ball's previous position to its current one and takes the first point that touches. I threw 18,216 trajectories at it that geometrically intersect the bat's body (0–80°, 500 and 1100 px/s, straight and curved, normal and shrunk bat). None get through.
The hard part: the bat is moving too This is where the rewrite stopped being a geometry exercise.
A swept test is easy when one body moves. Here both do, and the bat is frequently the faster of the two — it crosses the entire field in about a third of a second. My first version swept only the ball and asked every geometry question against the bat's current pose. That made two things worse:
A bat yanked onto a slow ball swept straight past it. The ball's segment was nearly a point, and the bat's motion — the whole reason the two met — wasn't in the test at all. Worse, the "were they already touching last frame?" guard asked with the current pose as well. On the first frame of a fast approach, the bat's new position already overlapped the ball's old position, so the guard answered "yes, this is an old contact" and discarded a hit that had never happened. The bat arrives, and nothing happens: complaint 3. The fix is that every geometry function takes a bat pose as an argument. The guard asks with the previous pose; the sweep interpolates the bat alongside the ball. What gets tested is the relative motion of the two bodies inside the frame.
Which creates one more problem, and it's the subtle one. The hit happens at some fraction t through the frame, at a bat pose that no longer exists by the time the frame ends. Place the ball at the raw contact point and it sits wrong against the bat that's about to be drawn; place it at the frame's end position and the outgoing angle gets computed from a dy the collision never saw. So the ball is placed at its contact position relative to the bat, then carried along by whatever the bat did for the rest of the frame. The angle is computed where the hit actually happened, and the ball still ends up resting on the visible surface.
And then it turned out the bat was moving twice per frame With the sweep in, the ghost hits mostly stopped. Mostly. The survivors had a signature in the black box (below): the same ball, in consecutive frames, at essentially the same place, flipping from didn't-reach to already-touching. There was no frame in between where they touched. The bat had teleported over the ball.
The bat is positioned by hand — we compute a target, move the sprite, push the new position into the physics body. Phaser's frame order is:
UPDATE → Body.preUpdate records prevFrame = position our update(): bat.y += Δ, then updateFromGameObject(), which updates the body's position — but not prevFrame handleBatHits() sweeps from the old y to the new one POST_UPDATE → Body.postUpdate computes dy = position − prevFrame (that's our Δ) and does gameObject.y += dy Step 4 moves the bat again, by the same amount, after collision detection has run. So the bat's real path each frame was one checked half followed by an unchecked teleport of equal length. That teleport is where the bat walked through the ball. It's also why the guard kept misfiring: the pose it read as "previous" was a full step ahead of where the sweep had ended.
One line fixes it — body.moves = false. The bat is ours to move; the engine shouldn't integrate it on top of us.
Which means every bat speed setting had secretly been doing double duty for two releases. So the numbers are re-tuned to land where they were actually felt, rather than where they were written:
Mouse / finger drag 2000 px/s was effectively 2400 · ramp ~0.11 s Keyboard up / down 1200 px/s was effectively 1500 The pace should read as roughly what you're used to. The difference is that every pixel of it is now checked for collisions — and the bat can no longer slide past its top and bottom limits, which it could, because half its travel happened after the clamp too.
Five pixels of forgiveness Going from a box to a capsule takes away reach at the tips. Those corners were dishonest, but they were also there, and you'd learned to play with them. So the hit radius is 5 px larger than the drawn body (at 720p; it scales with everything else).
That's the only forgiveness in the entire system, and it's deliberately the boring kind: it applies equally in every direction, so it can't bring back square corners or the "bounced from behind" depth. You can't see it, either — after the bounce the ball is placed on the real surface, so what reaches your eye isn't a gap, it's contact.
The tools that found all of this Two dev-only keys, because "it feels wrong sometimes" is not a bug report anyone can act on:
D draws the actual hit shape, built from the same spine function the collision uses — so the overlay can't drift away from the truth it is supposed to be showing. The last second and a half of decisions are marked with circles: green for a hit, red for a hit that didn't happen. B dumps the last ~400 frames of collision decisions into the daily log. For every ball that came near: the outcome (HIT:front, HIT:back-reflection, NONE:didn't-reach, NONE:not-approaching, SKIP:already-touching) and one number — the gap between the ball's centre and the hit radius during that frame's sweep. That number is the whole point. A large positive gap means the player was late. A gap near zero, or negative, next to a miss result means the code is wrong. Without it, all three complaints stay "hmm, that felt off", and I spend a release tuning constants that were never the problem.
Also in 1.2: the game starts faster Not glamorous, but it's the first thing anybody experiences.
The code bundle went from 1.89 MB to 1.49 MB (465 → 421 kB gzipped). The engine had been shipping a physics system the game doesn't use; the build now aliases to the arcade-only Phaser. Boot no longer downloads pictures you can't see yet. The 15 language flags and the menu wordmark — ~314 kB across 16 requests — used to load behind the loading bar. They now load in the screens that actually use them, and a first-time player, who lands straight in a level, downloads neither. The browser tab icon was 321 kB. The full 512×512 image was fetched on every page load, to be shrunk into a 16 px tab. It's 2.3 kB now. The images themselves are pre-optimised: flags from SVG to PNG (~122 kB → ~16 kB), wordmark 184 kB → 81 kB. Same pixels, minus the vector rasterising on every load. One honest note about measurement: the portal's loading timer starts while the bundle is already executing, so it never included the JS download in the first place. The real time-to-play was always longer than the number I was looking at — which is exactly why the bundle finally got some attention.
Two fixes The game no longer freezes if you change difficulty in the menu and hit START. The curved bat's visual was referencing an object that had already been destroyed, so the new game's setup died halfway through and everything stopped where it stood. (Same cause: with the curved bat, restarting from the menu could also leave you with no visible bat at all.) A rocket that took out an armoured bonus block used to leave its icon floating on the field forever — every level after that, mid-screen. The bonus inside was lost too, because the rocket treated the block as if it were still just armoured. Rockets now handle bonus blocks as bonus blocks, the way the ball and the cannon always have. Level changes also sweep up any orphaned icon, as a net. The demo here is rebuilt on 1.2. If the bat still does something you don't believe — through the ball, from behind it, or the "I was there in time" one — I'd like to know which, and roughly what the ball was doing at the time. Thanks to the black box, that's now a question I can answer with a log instead of a shrug.


Discussion