My browser game Void Parry has one core verb: you reflect enemy bullets back at a boss. The first fight teaches it. A slow white bullet flies at you, time slows down, a ring pulses around your character, and a keycap says R.
The lesson looked fine when I played it, so I thought it worked. Then I wrote a test that plays fight 1 like a new player. It presses R the moment the prompt appears. Often the lesson did not end until its 25-second timeout.
The numbers that didn’t line up
Four values, each fine on its own:
Thing Value Prompt appears when the bullet is 115 px away Lesson bullet speed during bullet time 150 px/s × 0.35 ≈ 52 px/s Reflect bubble lifetime 0.28 s of game time ≈ 0.8 s real time in bullet time Reflect reach 28 px + bullet radius ≈ 39 pxPress when the prompt appears and the bullet still has 76 px to travel. That takes about 1.5 s real time, and the bubble lasts 0.8 s. The bubble was gone before the bullet arrived. Then the cooldown kicked in (0.9 s of game time, about 2.6 s real time in bullet time), so a second press did nothing either.
A new player learns exactly the wrong lesson here: “the button doesn’t work.”
Bullet time made it worse, not better. Slowing the world down also slowed my timers down. The prompt fired early by design (“give them time to react”), and the early press missed.
The fix
The fix is two lines. During the lesson only, the reflect lasts three times longer and the cooldown drops to 0.25 s:
const lesson = trainingNow();
P.shieldT = SH_T * (lesson ? 3 : 1);
P.shieldCd = lesson ? 0.25 : SH_CD;
Enter fullscreen mode Exit fullscreen mode
Now a press on the prompt always catches the bullet. The lesson ends 3-5 s into the game instead of timing out.
The test that caught it
The test drives the real game in headless Chrome with Playwright. It uses real key presses, not by calling the game’s functions:
// fresh player: empty localStorage, fight 1 starts on load
const near = await page.evaluate(() => {
const P = __vp.P;
return __vp.eb().some(e =>
Math.hypot(e.x - P.x, e.y - (P.y - 20)) < 75 &&
(e.x - P.x) * e.vx + (e.y - (P.y - 20)) * e.vy < 0); // approaching
});
if (near && (Math.random() < 0.7 || lessonBulletOnScreen)) await page.keyboard.press("KeyR");
Enter fullscreen mode Exit fullscreen mode
It then asserts three things:
- the lesson ends before 20 s;
- fight 1 is won within 35-150 s;
- there are at least 8 bullets on screen after the lesson.
That last check exists because an audit had called my first fight “nearly empty”.
The same test also showed the fight was too hard for a clumsy bot (four full deaths), so I tuned it with numbers instead of by feel.
Takeaways
- Test the tutorial at the moment the prompt appears. Timing windows, slow motion and cooldowns multiply. Each can be right on its own and still be wrong together.
- Bullet time slows your own timers too. If you slow the world, check every duration that runs on game time.
- A bot doesn’t need to be good to be useful. A 70%-accurate bot that moves at random found a bug that my own playtesting walked past.
The game and this test were written with an AI coding assistant; the bug and the numbers are from the real build.