TinyGames · how it works
A bubble shooter in the Puzzle Bobble shape. Aim, fire, land three of a colour together to pop them — and anything left hanging with nothing above it falls, which is where most of the points come from. A new row is dealt every six shots. Let the stack reach the line and the run is over.
Open index.html. No build step, no dependencies.
bubbleshooter/
index.html markup
style.css the frame around the canvas
js/engine.js copied unchanged from Overdrive
js/hexgrid.js the rules - no pixels, no engine, no DOM
js/bubbleshooter.js the playable layer: aiming, flight, drawing
test-hexgrid.js node test-hexgrid.js
assets/ Kenney Puzzle Pack II, CC0, recoloured
Bubbles do not sit on a square grid. A plain row holds ten bubbles; a staggered row holds nine and sits half a bubble to the right. That offset is the whole reason a bubble shooter feels different from a match-3, and it is the only genuinely fiddly part of the rules, because a cell's six neighbours depend on whether its own row is staggered:
plain row staggered row
\ / \ /
-- O -- -- O --
/ \ / \
An unstaggered row sits left of the staggered row below it, so its down-left is
(r+1, c-1). A staggered row sits right, so its down-left is (r+1, c). Swap those two
cases and you get a game that mostly works and occasionally refuses to pop a group that is
obviously three in a row — far harder to notice than something plainly broken.
Rather than trust that I got it right, the tests check properties that must hold whatever the implementation:
That last one is the useful one: it checks the logical grid against the geometry it is supposed to describe, so a wrong offset fails immediately.
When a new row is dealt on top, everything moves down one. A ten-wide row cannot fit into
a nine-wide slot, and the first version quietly dropped the last bubble of every row on
every push. The fix is to flip which rows count as staggered at the same moment: a row
moving from r to r+1 then keeps its own width for its whole life on the board.
The test that caught it simply asserts that the old top row is intact one row down.
Popping a group often strands the bubbles that were hanging from it. Those do not pop, they fall, and they are worth more than a pop — 120 each against 50.
orphans() floods out from the top row through occupied neighbours; anything it cannot
reach is loose. That is cross-checked in the tests against a naive
keep-looping-until-nothing-changes implementation over 500 random boards, with an
assertion that the sample really did contain boards with orphans.
Worth recording, because it was wrong first.
The line started as a fraction of the screen height and the grid was thirteen rows. Those
two numbers were picked independently, and they disagreed: the lowest row the grid could
ever reach bottomed out at y=441 while the line sat at y=454. The line was unreachable.
Every loss actually came from pushDown() reporting an overflow — a correct outcome
arrived at invisibly, with a decorative line drawn thirteen pixels below where anything
could ever go.
It is now derived from the grid:
this.deathY = this.oy + this.r * 2 + (Config.ROWS - 1) * this.r * 1.732;
Crossing the line and running out of rows are now the same event, and the line means what it looks like it means. Confirmed: eighteen randomly aimed shots are enough to lose.
Six colours, each carrying a different symbol — dot, ring, triangle, square, cross, star — drawn over the sphere in canvas. Colour alone is a poor way to distinguish six things, and worse if you are colour-blind.
Kenney's Puzzle Pack II ships balls in only four colours, two of which are black and grey, so the sphere is one of theirs with its hue rotated six ways. Rotating hue in HSV keeps the shading and the rim light intact, which a flat recolour would destroy.
The shot moves at 1250 px/s. At 60 Hz that is 20 pixels a frame against a bubble radius of
about 17, so a single step per frame would let a shot pass clean through a bubble between
two frames. stepShot splits each frame into slices no longer than 0.6 of a radius and
tests collision at every slice.
Shots bounce off the two side walls. Aim is clamped to ±1.4 radians so you cannot fire sideways into the wall beside you forever.
Aiming is by pointer move, and the shot fires on release — so on a touch screen you can drag to line it up and watch the guide before committing, rather than firing wherever your thumb first landed. Arrow keys nudge the aim, space fires.
| Popped bubble | 50 |
| Dropped orphan | 120 |
| Board cleared | 2000, then it refills |
Best score is kept in localStorage.
| Drag / move | aim |
| Release, or Space | fire |
| ← → | nudge the aim |
| Esc | pause |
| R | new board |
The same version.json convention as the other two games and the Angular apps, with the
same update check and the same bump-version.py.
Advertisement