Skill: Test the game
Play-test the game in a real browser to check that a change works, reproduce a bug, or see what is on screen. Use whenever you changed gameplay, input,...
Written for blit386 1.8.0. The rest of blit386.dev documents the newest engine, so an API you find there may be missing from a game pinned to 1.8.0.
Open the game, press keys, and read the game's numbers to check that it does what you expect.
The game draws everything into one <canvas>, so a page snapshot or accessibility tree shows nothing useful: there are
no buttons or text on the page to read. Read the game's state through JavaScript instead of guessing from pixels.
There are two ways to do that, with the same results:
- In a browser tool that can run JavaScript in the page and press keys, such as the browser pane in the Claude desktop app. Follow the browser steps below.
- From the terminal, with
npx blit play. This works in any assistant, including Cursor: Cursor's built-in browser can open the page and take screenshots, but it cannot run JavaScript or press keys, so it cannot read the game's state.
When to use
Use this after a change to gameplay, input, or drawing, to reproduce a bug the user described, or when the user says "test it", "try it", "play it", or "does it work now?"
From the terminal (works in any assistant)
npx blit play --seed 42 wait:1000 state hold:ArrowLeft:500 state shotThat starts the dev server, opens the game in the Chrome or Edge on this computer, focuses the canvas, runs the steps in order, and prints one JSON line per step.
- Steps:
wait:<ms>,press:<key>,hold:<key>:<ms>,move:<x>:<y>,click:<x>:<y>(game pixels; holds the button 100 ms),state,shot[:<file.png>](saved underscreenshots/, ignored by git),eval:<expression>. - Options:
--seed <n>,--backend software,--url <url>(use a dev server that is already running),--headed(show the browser window).npx blit play --helplists them all. - The first time, it may say it needs the
playwright-corepackage and print the command that adds it. Run that command once, then try again. It uses the browser you already have and never downloads one. - Timing is steadier than a browser tool: all steps run in one process, with no pause between tool calls.
In a browser tool
-
Start the dev server with the
runskill (npm run dev). Leave it running. -
Open the game with a fixed seed:
http://localhost:5173/?nosplash&seed=42(use the port the server printed).seed=42makes every random roll the same on every run, so two tests can be compared. Any whole number works.nosplashskips the BLIT386 splash. A dev build already skips it, so this matters for a built game you open withvite preview. If the game'sconfigure()setsisSplashEnabled, that setting wins over the URL.- Add
&backend=softwarewhen the browser has no WebGPU, or to test what players without WebGPU get.softwareis the only value it accepts.
-
Give the canvas keyboard focus without clicking it: run
document.querySelector('canvas').focus()in the page. A click makes the pointer active, and the starter game then steers the paddle with the mouse and ignores the keys. -
Read the state. Run this JavaScript in the page:
window.BT.testState();It returns
{ ticks, backend, state }, wherestateis whatever the game'stestState()method returns - the starter game gives plain numbers, for example{ score, lives, paddle: { x, y, ... }, items: [...] }. Read it twice about a second apart:ticksshould have grown by about 60.A game written against an engine before 1.8.0 has no
testState(); the starter'swindow.__game.state()returns the same plain object there.It returns
{ ticks, backend, state }, wherestateis whattestState()returned (ornullwhen the game has no such method). Seeuse-dev-mode. -
Press keys, then read the state again. Keys use
KeyboardEvent.codenames:KeyA,KeyD,ArrowLeft,Space. Player 0 uses W, A, S, D and Space out of the box; the starter game also maps the left and right arrow keys.-
A quick tap from your browser tool's key-press action is enough for things that react to one press (
BT.isPressed: jump, fire, menu confirm). -
Movement that happens while a key is held (
BT.isDown, like the starter's paddle) needs a real hold. A tap is usually over before the game's next step and does nothing. Hold the key from JavaScript instead:const canvas = document.querySelector('canvas'); canvas.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyA', bubbles: true })); await new Promise((resolve) => setTimeout(resolve, 500)); // hold for half a second canvas.dispatchEvent(new KeyboardEvent('keyup', { code: 'KeyA', bubbles: true }));
Synthetic pointer events need a
pointerType('mouse','touch', or'pen'); an empty one is treated as the mouse, and touch or pen contacts are only tracked when the event says so.The game runs in real time (about 60 steps a second), so read the state afterward instead of assuming how far something moved.
-
-
Grab an exact frame when you need to see the picture:
await window.__game.frame(); // a PNG data URL of the next frameThis is sharper than a browser screenshot: it is the game's own pixels, not scaled by the page. It matches
BT.outputSize, though, which for the starter game is640x480- every game pixel a 2x2 block. For a 1:1 frame atBT.displaySize(320x240), the size that pixel comparisons want, use the engine call directly (engine 1.7.1+):await window.BT.captureFrame({ size: 'display' }); // a PNG Blob, one pixel per game pixelSee
save-a-screenshotfor the difference between the two sizes. -
Freeze the game on an exact moment when timing matters (engine 1.8.0+). A live game keeps running between your tool calls, so two reads never describe the same tick.
BT.renderAtreplays the game from the start on a fresh copy of the game class and stops the loop on that frame; nothing moves until you say so:while (!window.BT.isReady) await new Promise((r) => setTimeout(r, 16)); // init finished, loop exists await window.BT.renderAt(3.0); // tick 180 at 60 FPS - update() ran 180 times, render() once window.BT.testState(); // the state at exactly tick 180 await window.BT.captureFrame({ size: 'display' }); // the frame at exactly tick 180 await window.BT.renderAt(3.5, { from: 'current' }); // on to 3.5 s without replaying (never backwards) await window.BT.resume(); // let it play again from thereIt works in a hidden or headless page too (no animation frame needed), and the same seed gives the same frame every time. It only lands on the right picture if the game moves from
BT.ticks/BT.timeSeconds/BT.random, neverDate.now()orperformance.now(). Key presses do not replay -renderAtchecks what the game does on its own.
What a healthy run looks like
- From the terminal: the last line shows
"errors":[]and the exit code is 0. Any error the page logs fails the run. - In a browser tool:
ticksclimbs between two reads, and the browser console shows no errors. - The numbers you meant to change moved the way you expected (the score went up after a catch, the paddle's
xwent down whileKeyAwas held). - The same seed and the same steps give the same result. Seeded values (where blocks appear, how far they fell by a given tick) match exactly across runs. Values you steered can differ by a step or two, because a key press lands in real time, not on an exact tick.
Notes
- The starter game's
testState()method lives insrc/game.*; its dev-onlywindow.__game.state()just calls it. Add a field totestState()when you need to check something new, for examplelevel: this.level. Keep it plain numbers, strings, and arrays so it can be printed. - For a game of your own, the same
testState()method is all you need (engine 1.8.0+): no global to assign and nodeclare globalblock, andwindow.BT.testState()reads it. The terminalstatestep prints it too, and falls back towindow.__game.state()for a game without the method. - An older game without
window.__gamestill haswindow.BTin a dev build:BT.ticks,BT.activeBackend, andBT.captureFrame()(it returns a PNGBlob; pass{ size: 'display' }on engine 1.7.1+ for a 1:1 frame) work straight away, and the terminalstatestep falls back toBT.ticks. Seeuse-dev-mode. - Both
window.__gameandwindow.BTexist only while the dev server runs the game. A built game has neither. ?seed=only helps if the game rolls dice withBT.random, notMath.random(). Seeuse-random.- If the browser pane or tab is hidden, the browser slows the game down or stops it completely:
ticksclimbs slowly or not at all, anddocument.visibilityStatereadshidden. Show the pane again, or usenpx blit play, before testing anything that depends on timing. - If nothing happens when you press keys, the canvas has lost focus, the pointer has taken over the paddle, a key that
must be held was only tapped, or the game is still on a title screen waiting for a press (check the state). Focus the
canvas again with
document.querySelector('canvas').focus(), keep the mouse off it, and hold movement keys as in step 5.
Skill: Structure a game
Show the shape of a BLIT386 game (configure, init, update, render) and set the expectation that the engine draws, reads input, and plays sound while you...
Skill: Use dev mode
Gate debug HUDs, cheat keys, verbose logging, and test fixtures on `BT.isDevMode` (engine 1.5.0+) instead of a hand-rolled flag, and expose game state for...