feat(core): game engine — swept physics, paddle english, state machine #1

Merged
engineer merged 5 commits from i/core-engine into main 2026-07-13 13:33:14 +02:00
Owner

Game core for Breakout. Headless — never touches a canvas, an audio context, or the DOM. Emits the state object the renderer reads and the audio layer drains.

Herald's two non-negotiables, both done and test-proven

1. Paddle-angle control. Outgoing angle is set by where the ball lands on the paddle (±60°), preserving speed rather than flipping vy. Test pins direction-from-impact AND that an edge hit is genuinely wider than a centre hit — if the mechanic were cosmetic, the test fails.

2. No tunnelling at the 700px/s cap. The core sweeps the ball's whole path against each box (Minkowski-expanded AABB + slab raycast) instead of sampling position once per step. Speed-independent by construction, which is what makes the speed curve safe to push. A test fires a ball at the cap straight at a brick and proves it cannot skip.

Also in scope

  • Fixed-timestep loop (120Hz sim) + interpolation alpha. Variable dt makes collision response monitor-dependent and lets one long frame teleport the ball through a wall.
  • State machine ready -> playing -> levelclear / gameover. Pause is a flag, not a phase, so the renderer's switch (state.phase) stays total over Herald's four.
  • Speed progression: +5% per row-tier first breached, capped at 700.
  • Reflection = incidence on walls/bricks; no gravity/mass/spin (operator's arcade-realistic direction).

Three bugs the tests caught that a code-read would not

1. brick-hit was silently mislabelled. The event literal carried both type: 'brick-hit' and type: b.type — the later key wins in JS, so every brick-hit arrived as 'wall'. Anything filtering for brick-hit (Shipwright's particles, Lookout's crack) would have matched nothing, and it would have looked like their bug. Brick kind now travels as brickType; regression test pins it.

2. Events would have been destroyed under load. A fixed-timestep loop runs several sub-steps per frame; clearing events inside step() wipes everything raised in any sub-step but the last. Bricks would break silently and intermittently — the worst kind of failure. Events now accumulate across the frame and are cleared once, by the loop, after onRender.

3. Carpenter's levels would have silently loaded as the wrong game. His LEVELS entries are bare 8x12 numeric arrays, not objects with a .grid key. Looking only for the key found undefined and fell back to my placeholder grid — no error, no console output, just the wrong level. The integration test loads his real exported module and asserts the bricks match his own buildBrickGrid descriptors.

Seams

CONTRACT.md documents module system, file ownership, units, state shape, events, and level-data format. I own src/{engine,collision,constants,loop,input}.js and the single requestAnimationFrame; index.html is Shipwright's and untouched here.

The loader accepts both level alphabets (0/1/2 and ./N/H) and both ballSpeed conventions (px/s or multiplier) — Carpenter's format and Herald's brief crossed on the wire mid-jam, and reading a 1.0 multiplier as px/s yields a ball moving one pixel per second, which presents as a hung game.

Not in scope

Rendering (Shipwright), audio (Lookout), level content (Carpenter), deploy (QM). Steel is cut per Herald — the loader still reads 3 harmlessly, but nothing emits it.

26/26 tests green — this PR's 14, plus Carpenter's, Lookout's audio, and high-scores, all passing together.

Game core for Breakout. Headless — never touches a canvas, an audio context, or the DOM. Emits the state object the renderer reads and the audio layer drains. ## Herald's two non-negotiables, both done and test-proven **1. Paddle-angle control.** Outgoing angle is set by *where* the ball lands on the paddle (±60°), preserving speed rather than flipping `vy`. Test pins direction-from-impact AND that an edge hit is genuinely wider than a centre hit — if the mechanic were cosmetic, the test fails. **2. No tunnelling at the 700px/s cap.** The core sweeps the ball's whole path against each box (Minkowski-expanded AABB + slab raycast) instead of sampling position once per step. Speed-independent *by construction*, which is what makes the speed curve safe to push. A test fires a ball at the cap straight at a brick and proves it cannot skip. ## Also in scope - Fixed-timestep loop (120Hz sim) + interpolation `alpha`. Variable `dt` makes collision response monitor-dependent and lets one long frame teleport the ball through a wall. - State machine `ready -> playing -> levelclear / gameover`. **Pause is a flag, not a phase**, so the renderer's `switch (state.phase)` stays total over Herald's four. - Speed progression: +5% per row-tier first breached, capped at 700. - Reflection = incidence on walls/bricks; no gravity/mass/spin (operator's arcade-realistic direction). ## Three bugs the tests caught that a code-read would not **1. `brick-hit` was silently mislabelled.** The event literal carried both `type: 'brick-hit'` and `type: b.type` — the later key wins in JS, so *every* brick-hit arrived as `'wall'`. Anything filtering for `brick-hit` (Shipwright's particles, Lookout's crack) would have matched **nothing**, and it would have looked like *their* bug. Brick kind now travels as `brickType`; regression test pins it. **2. Events would have been destroyed under load.** A fixed-timestep loop runs several sub-steps per frame; clearing `events` inside `step()` wipes everything raised in any sub-step but the last. Bricks would break silently and *intermittently* — the worst kind of failure. Events now accumulate across the frame and are cleared once, by the loop, after `onRender`. **3. Carpenter's levels would have silently loaded as the wrong game.** His `LEVELS` entries are *bare* 8x12 numeric arrays, not objects with a `.grid` key. Looking only for the key found `undefined` and fell back to my placeholder grid — no error, no console output, just the wrong level. The integration test loads his **real** exported module and asserts the bricks match his own `buildBrickGrid` descriptors. ## Seams `CONTRACT.md` documents module system, file ownership, units, state shape, events, and level-data format. I own `src/{engine,collision,constants,loop,input}.js` and the single `requestAnimationFrame`; `index.html` is Shipwright's and untouched here. The loader accepts both level alphabets (`0/1/2` and `./N/H`) and both `ballSpeed` conventions (px/s *or* multiplier) — Carpenter's format and Herald's brief crossed on the wire mid-jam, and reading a `1.0` multiplier as px/s yields a ball moving one pixel per second, which presents as a hung game. ## Not in scope Rendering (Shipwright), audio (Lookout), level content (Carpenter), deploy (QM). Steel is cut per Herald — the loader still reads `3` harmlessly, but nothing emits it. **26/26 tests green** — this PR's 14, plus Carpenter's, Lookout's audio, and high-scores, all passing together.
Headless game core for Breakout. Emits the state object the renderer reads and
the audio layer drains; never touches a canvas.

- swept (continuous) circle-vs-AABB collision — Minkowski-expanded box + slab
  raycast along the ball's path. Speed-independent by construction, so the ball
  cannot tunnel through a brick at the 700px/s cap. This is what makes the speed
  curve safe to push.
- paddle english: outgoing angle is set by WHERE the ball lands on the paddle
  (+/-60 deg), preserving speed rather than flipping vy.
- fixed-timestep loop (120Hz sim) with interpolation alpha for the renderer.
  Variable dt would make collision response monitor-dependent.
- state machine: ready -> playing -> levelclear / gameover; pause as a flag so
  the renderer's switch over phase stays total.
- speed progression: +5% per row-tier first breached, capped at 700.
- events array, accumulated across a frame's sub-steps and cleared once by the
  loop — clearing per step would silently destroy events raised in any sub-step
  but the last.

11 headless tests, incl. a tunneling proof at max speed and a regression pin on
the brick-hit event name (a duplicate 'type' key had been relabelling every
brick-hit as 'wall', so nothing filtering for brick-hit would have fired).

CONTRACT.md documents the seams: module system, file ownership, units, state
shape, events, level-data format.
Carpenter's LEVELS entries are bare 8x12 numeric arrays, not objects with a
.grid key. Looking only for the key found undefined and silently fell back to
the placeholder grid — the game would run, throw nothing, and render the WRONG
level.
Loads Carpenter's actual exported LEVELS through the engine and asserts the
bricks match his own buildBrickGrid descriptors (cells, kinds, hits -> hp), that
geometry is derived here (x = 48+col*72, y = 80+row*24), and that the level is
winnable. A bare-array level would otherwise fall through to the placeholder
grid silently.
engineer force-pushed i/core-engine from 3baafdd116 to fd24126ee4 2026-07-13 13:31:05 +02:00 Compare
feat(core): expose window.__state for Herald's playtest harness
Some checks failed
deploy / deploy (push) Failing after 0s
ba5201ed02
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/breakout!1
No description provided.