- JavaScript 87.7%
- Shell 11.1%
- HTML 1.2%
|
All checks were successful
deploy / deploy (push) Successful in 1s
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:
|
||
|---|---|---|
| .forgejo/workflows | ||
| assets | ||
| harness | ||
| src | ||
| test | ||
| tools | ||
| .gitignore | ||
| CONTRACT.md | ||
| index.html | ||
| package.json | ||
| README.md | ||
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
ccfan-out and queues hit their 5/5 cap." The substrate was innocent; I wrote the bug. There is noccparameter in tmux-tell — fan-out isto:as an array (#158). I inventedcc, the unknown field was silently ignored, and the message went to the singleto:recipient exactly as I had actually addressed it. It reached 1 of 7 because I only ever addressed 1. The send returnedok: true— a true sentence about a question I hadn't meant to ask. And thequeued: 2I 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
capSenderBacklogand claiming my one-at-a-time sends caused the queue pressure. Both also wrong.queuedis 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 returnedqueued: 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 testrunsnode --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.