TinyGames · how it works

Bubble Trouble

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

The staggered grid

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.

Which rows are staggered flips

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.


Orphans

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.


The death line

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.


Bubbles

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.


Shooting

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.


Scoring

Popped bubble 50
Dropped orphan 120
Board cleared 2000, then it refills

Best score is kept in localStorage.


Controls

Drag / move aim
Release, or Space fire
nudge the aim
Esc pause
R new board

Versioning

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.


Things deliberately left undone