Frankenbit Game Jam II — Breakout: paddle/ball/bricks, live-development sprint
  • JavaScript 87.7%
  • Shell 11.1%
  • HTML 1.2%
Find a file
Engineer a9aeb570b2
All checks were successful
deploy / deploy (push) Successful in 1s
fix(harness): §11's self-test was ERASING the suite's red (#64 review 4100)
check_needle raised FAILED itself, so the self-test — which fires an IMPOSSIBLE needle on
purpose, and must go red — had to clear it afterwards. `FAILED=0` does not clear "the
self-test's red". It clears THE SUITE'S RED. A genuine §2 failure caught forty minutes
earlier was erased here, and the tail printed PASS and exited 0.

  THE POSITIVE CONTROL THAT PROVES THE INSTRUMENT CAN FAIL POISONED THE AGGREGATE.

Found by @surveyor, who ran the real bytes of the block with FAILED=1 pre-set and watched
the suite go green. Reproduced here on the shipped bytes vs the fix:

  848ba02 (shipped)  FAILED=1 in -> FAILED=0 out   <- a real red, ERASED
  fixed              FAILED=1 in -> FAILED=1 out

It is §10's lesson one layer up, and the comment on the clobbering line said "do not let
it condemn the suite" — naming the narrow intent while the code did the broad thing. The
same shape as #53's reaper, where the comment named the guard and the guard was the bug.

check_needle now reports into NEEDLE_RED and NEVER touches the accumulator. FAILED is
raised by the CALLER, at the one place that knows whether a red was expected: the real
needle loop convicts, the self-test does not. That also un-breaks the SELF_LOG_COUNT<2
guard, whose FAILED=1 the same line was clobbering.

  A FUNCTION THAT REPORTS CANNOT ALSO CONVICT.
2026-07-14 11:45:34 +02:00
.forgejo/workflows test(harness): track the gates — the browser harnesses belong in the game (#27) (#32) 2026-07-13 17:12:19 +02:00
assets assets: repo avatar — a wall with a hole punched through it (#50) 2026-07-13 21:27:59 +02:00
harness fix(harness): §11's self-test was ERASING the suite's red (#64 review 4100) 2026-07-14 11:45:34 +02:00
src fix(dev-hook): make BOTH doors loud. Delete neither. (#14) (#42) 2026-07-13 18:01:05 +02:00
test docs(test): the mirror guards the SIGN, not the magnitude — the over-claim shipped to main (#35) 2026-07-13 16:40:48 +02:00
tools feat(assets): key art for the showcase — drawn with the game's own tokens 2026-07-13 14:00:47 +02:00
.gitignore fix(harness): audit-controls.sh strands nothing on SIGKILL + asserts VERDICT_REACHED in the EXIT trap (#51) (#52) 2026-07-13 21:18:16 +02:00
CONTRACT.md feat(core): game engine — swept physics, paddle english, state machine 2026-07-13 13:27:27 +02:00
index.html fix(shell): 148vh -> 138vh — the footer was clipped at 1080p 2026-07-13 13:51:42 +02:00
package.json refactor(harness): openPage + directory-wide audit — the boundary redrawn against two callers (#36) (#37 structural half) (#39) 2026-07-13 18:54:59 +02:00
README.md docs(README): correct a false claim against the bus — cc never existed 2026-07-13 14:25:15 +02:00

BREAKOUT

Game Jam II — frankenbit crew. 2-hour build. Close ~15:12.

You are breaking out of Alcatraz. The bricks are the cell wall. The ball is what you throw at it. The paddle is the searchlight beam sweeping the yard.

That's the whole idea. It costs nothing to implement — it's a palette, a few nouns, and a title card. Don't build a story. Build Breakout.


⚠️ THIS FILE IS THE CONTRACT

At kickoff I sent the contract to one chamber while believing I had sent it to seven, and nothing anywhere said otherwise — the send returned ok: true. Five chambers built blind. This file is source-of-truth; a bus message is not. If the bus and this file disagree, this file wins — and tell Herald so it gets fixed here.

Correction (post-jam). I originally wrote here that "the bus dropped a cc fan-out and queues hit their 5/5 cap." The substrate was innocent; I wrote the bug. There is no cc parameter in tmux-tell — fan-out is to: as an array (#158). I invented cc, the unknown field was silently ignored, and the message went to the single to: recipient exactly as I had actually addressed it. It reached 1 of 7 because I only ever addressed 1. The send returned ok: true — a true sentence about a question I hadn't meant to ask. And the queued: 2 I read as "2 recipients got it" is the recipient's queue depth after the insert (internal/store/messages.go:73) — a depth, not a count.

(Second correction, same hour: I first "fixed" the above by blaming capSenderBacklog and claiming my one-at-a-time sends caused the queue pressure. Both also wrong. queued is recipient depth, and the array form emits one row per recipient — it is a convenience, not a pressure reduction. I produced two more errors of the identical shape inside the correction for the first one, and caught them only because a later response returned queued: 5, which is impossible under the mechanism I had just asserted. The data refuted me in the act of publishing the refutation.)

The rule this file states still stands. The reason is just the honest one: a send that silently reaches nobody looks precisely like a send that worked.


Canonical geometry — do not invent your own

CANVAS   960 x 640      // fixed. scale with CSS, never with layout.
COLS     12   ROWS  8   // matches src/levels.js exactly
MARGIN_X 48             // 960 - 2*48 = 864 ;  864 / 12 = 72
CELL_W   72   CELL_H 24
BRICK_W  68   BRICK_H 20        // 4px gutter
TOP_Y    80                     // HUD lives above this

x = MARGIN_X + col * CELL_W
y = TOP_Y     + row * CELL_H

12 × 8. This matches src/levels.js as it stands right now — verified against the tree, not against a brief.

Correction, and it's mine. For about ten minutes this file said 10 columns. I'd found Carpenter's first levels.js (5 designed levels at 10 wide, symbol format) and ruled that the tested work should win over a number I'd invented. That was right at the moment I checked — but he had already converted to the 12×8 numeric grid while I was writing, so the thing I was protecting no longer existed. The premise of the decision evaporated, so the decision had to go with it. If you read "10 cols" anywhere, it is stale. 12 × 8 is correct.

The state object — one source of truth

Engineer owns it. Everyone else READS it. Nobody else mutates it.

{
  phase: 'ready' | 'playing' | 'levelclear' | 'gameover',
  paddle: { x, y, w, h },                  // x = LEFT edge
  ball:   { x, y, vx, vy, r, stuck },      // stuck = riding the paddle pre-launch
  bricks: [ { col, row, x, y, w, h, hp, type, alive } ],   // 'wall' | 'reinforced'
  score, lives, level,
  events: [ ... ]                          // drained + cleared every tick
}
  • Engineer — pure logic + step(dt). No canvas calls, ever.
  • Shipwright — pure render(ctx, state). No mutation, ever.

That seam is what lets everyone work at once. Don't cross it.

state.events — the juice + audio hook (Engineer's design, adopted)

One array, drained and cleared every tick. Nobody diffs brick arrays. Both the renderer's juice and the audio layer read the same drain:

{ type:'brick-hit',  col, row, x, y, hp, destroyed }   // destroyed:false = cracked but alive
{ type:'paddle-hit', x, offset }                       // offset -1..+1 = where on the paddle
{ type:'wall-hit',   side:'left'|'right'|'top' }
{ type:'ball-lost' }
{ type:'level-clear' }
{ type:'game-over' }
{ type:'launch' }

destroyed is the flag that splits crack from break — most of the perceived quality lives in that distinction.

Collision: swept, not discrete — tunnelling is solved

Engineer's core uses continuous circle-vs-AABB (Minkowski-expanded box + slab raycast along the ball's path). Speed-independent by construction: a 700 px/s ball cannot skip a 20 px brick. This is why the speed curve is safe to push. Don't re-litigate it, and don't spend jam-time hunting a bug that can't happen.

Entry point

// src/main.js  — Engineer owns this. It is all index.html asks for.
export function boot(canvas) { /* start the loop */ }

index.html mounts the 960×640 canvas and calls boot(canvas). Until src/main.js lands, the import fails and a holding screen renders instead — a missing core must never look like a broken page in front of guests.

Live at jam.frankenbit.de/breakout/, deployed on every push to main.

Bricks

src/levels.js cells are numeric:

cell type hp colour
0 empty
1 wall 1 #8892A6 cold stone
2 reinforced 2 #5A6478 darker stone, visibly cracked at hp 1

The seam, so nobody duplicates work: buildBrickGrid(level) returns logical cells only —

{ row, col, value, type, hits }        // 'wall' | 'reinforced'

Engineer derives the geometry (x, y, w, h) from col/row using the constants above, maps hits → hp, and adds alive. He owns state, so he owns positioning. Carpenter stays pure data; Engineer places it. No rework needed on either side.

Import levels.js. Don't build your own grid.

Palette — gold-on-navy. No image assets. No fonts to load.

It has to render on a projector from a cold cache.

bg          #0B1020   deep navy (near-black)
wall        #8892A6   cold stone        (hp1)
reinforced  #5A6478   darker stone      (hp2)
ball        #FFD447   searchlight gold + glow
paddle      #FFD447   the beam
HUD text    #E8ECF4
danger      #FF5C5C   last life

Type: system monospace, uppercase, letter-spaced. ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace — zero load, and fixed-width digits for free (canvas won't hand you tabular-nums, so the face has to). Reads stencilled/institutional, which is the register anyway.

Feel: CRISP — with exactly one exception, and the exception is the idea

Hard edges. Instant feedback. No easing on anything the player acts on.

Then: the ball is the only soft thing on screen. It's the searchlight — the one light source in a dark yard. Glow it (shadowBlur ~12, gold) and give it a short motion trail. Everything else is cold, hard stone.

The player's eye tracks one warm moving light across a dead grey wall. That contrast is the look. If the bricks glow too, the idea dies. Spend the entire glow budget on the ball.

The one UX call that decides whether this is any good

Ball angle depends on WHERE it hits the paddle. Centre → steep. Edge → shallow. This is the entire difference between Breakout that feels alive and Breakout that feels like a screensaver. P0, not polish.

const hit   = (ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2);  // -1 .. +1
const angle = hit * (Math.PI / 3);                                    // ±60°
ball.vx =  speed * Math.sin(angle);
ball.vy = -speed * Math.cos(angle);        // always up. never sideways-stuck.

Preserve speed, set direction. Never just flip vy on a paddle hit.

Speed: start 300 px/s, +5% per row-tier first breached, hard cap 700.

Launch: the ball starts stuck to the paddle. Space/click launches. Never auto-launch into a death.

⚠️ Tunnelling — the bug that will bite us

At the 700 px/s cap that's ~12 px/frame against 20 px bricks. A fast ball skips a brick between frames — the classic Breakout bug. From the back of a room it reads as the game being broken.

Sub-step or sweep the collision. Lookout is watching for this specifically.

Scope fence — 2 hours. A finished small game beats a broken big one.

P0 — ships or we failed (playable by ~14:15): paddle (mouse + arrows) · ball · brick grid · collision · paddle-angle control · 3 lives · score · level-clear · game-over · restart. ONE level is a complete game.

P1 — only once P0 is solid: reinforced hp2 + crack → audio → more levels → title/game-over cards.

CUT — do not build: steel bricks · power-ups · shatter particles. (src/high-scores.js already exists and is tested — leave it. Wire it only if there's time at the end. Sunk cost; deleting it costs more than ignoring it.)

🔒 FEATURE FREEZE 14:45. Then: polish, deploy, verify, play it.

Lanes

chamber owns
Engineer core: loop, physics, collision, state machine. Owns state.
Shipwright render(ctx, state), HUD, the glow
Carpenter src/levels.js — level data + buildBrickGrid()
Lookout tunnelling watch (#1), then WebAudio (src/audio.js)
QM deploy, CI, the live URL
Herald creative calls. Blocked? Ping. Don't guess, don't stall.

Rules of the build

  • No build step, no bundler, no dependencies, no image assets. index.html + ES modules. npm test runs node --test, and that's the whole toolchain.
  • Small PRs. Land them. Jam repo = author-self-merge after Surveyor's pass.
  • Set your git identity before your first commit — a fresh clone here inherits the operator's, and the first two feature commits landed as "Alexander Saratow": git config user.name "<Chamber>" && git config user.email "<chamber>@frankenbit.de"
  • Carry gaps as data. Never fill them with plausible-but-wrong.