Delivered by Hermies Qwen, gated and deployed by Relay. - Board tip/topple: full continuous rotation when a board's center of mass passes its support edge (Q1: smooth pivot swing, no discrete snap). Falling boards deal impact damage and can kill enemies in their fall path. - Falling-object impact damage: blocks and birds can now kill enemies from vertical drops and side plows (velocity-aware crush gate), chain momentum transfers correctly through stacks (mass-weighted contact), repeated impacts finish tougher enemies. - Landed-bird physics: a bird resting on a board remains a real physical body under continuous gravity (Q2: no lifetime timer). If its platform is destroyed it falls, damages what it lands on (chain reaction), and can kill enemies. - Repo modularized: folders = modules, subfolders = submodules, architecture.md + MODULES.md at root, an agents.md card in every module and submodule (17 total). - Test suite grown from 27 to 47 (20 new RED-watched tests for the above behaviors), all green. Golden differential against the original core confirmed the only behavioral changes are the intended ones. Live at http://10.244.136.121:8093/, independently re-verified by Relay (full suite re-run, module structure check, headless Chromium smoke) before this push. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
154 lines
8.4 KiB
Markdown
154 lines
8.4 KiB
Markdown
# Isle Slingers — Architecture
|
||
|
||
Original architecture for a fully offline, canvas-rendered slingshot game.
|
||
No frameworks, no build step, no external assets. As of RC-375D84 (S-2) the
|
||
JavaScript is a **modularized tree**: folders are modules, subfolders are
|
||
submodules, and every module/submodule carries its own `agents.md`.
|
||
|
||
## Runtime model (plain `<script>` load, single page)
|
||
|
||
There is no bundler and no import/export in the browser. Modules are ordinary
|
||
UMD-style files loaded in a strict order by `index.html`; the test harness
|
||
(`test/page.test.mjs`) replays that exact order in a DOM stub, and the
|
||
headless suites (`test/sim.test.mjs`, `test/balance.test.mjs`) load the same
|
||
modules through Node's CommonJS `require` branch of each UMD wrapper.
|
||
|
||
Two kinds of module:
|
||
|
||
1. **Shared-scope modules** — a group of submodules that must share one
|
||
lexical scope (the way a single file used to). Each submodule exports an
|
||
*installer function* `P -> void`; the aggregator builds one shared object
|
||
(`P` for physics, `L` for levels) and calls every installer against it.
|
||
Submodules therefore reference each other's units as `P.<unit>` /
|
||
`L.<unit>` — never as free names. This preserves the monolith's
|
||
single-closure semantics exactly (proven by the golden-step hash: identical
|
||
scripted playthroughs produce byte-identical state hashes).
|
||
In the browser, submodules push their installer onto a global part list
|
||
(`ISLE_PHYSICS_PARTS` / `ISLE_LEVEL_PARTS`); the aggregator (the last
|
||
`<script>` of the group) consumes the list in order.
|
||
2. **Self-contained modules** — `audio`, `render`, `app`. One file, one
|
||
global (`ISLE_AUDIO`, `ISLE_RENDERER`, plus the page state object on
|
||
`window`), no shared scope.
|
||
|
||
## Load order (contract — do not reorder)
|
||
|
||
```
|
||
physics submodules : constants, state, aim, abilities, damage, step,
|
||
collision, topple, rules, camera (shared scope P)
|
||
physics/index.js : aggregator -> window.ISLE_CORE (the physics API)
|
||
levels/helpers : constructors + SLING anchor (shared scope L)
|
||
levels/data/* : one level definition each (shared scope L)
|
||
levels/index.js : aggregator -> window.ISLE_LEVELS {LEVELS, SLING}
|
||
audio/index.js : window.ISLE_AUDIO {AudioEngine}
|
||
render/index.js : window.ISLE_RENDERER {Renderer, VIEW_W, VIEW_H}
|
||
app/index.js : the page — input, screens, main loop
|
||
```
|
||
|
||
`app` (the former `main.js`) is the only module that touches DOM wiring;
|
||
`render` draws from the state object returned by `ISLE_CORE.createGame`;
|
||
`audio` plays the events the core appends to `state.events`.
|
||
|
||
## Physics model (RC-375D84, landed 2026-09-29)
|
||
|
||
The simulation is a deterministic impulse-ish engine: bodies integrate under
|
||
gravity at fixed 1/120 s, contacts resolve positionally, and damage routes
|
||
through two funnels (`damageBlock`, `damageEnemy`). What each RC-375D84 step
|
||
added, and where it lives:
|
||
|
||
- **Boards tip and topple (S-3, Q1 = full continuous rotation).** Blocks carry
|
||
a rotational DOF: `ang`, `angVel`, `pivoting`. A resting block whose centre
|
||
of mass lies outside the x-span of its support (ground, or the top face of a
|
||
block below — `topple.blockSupportSpan`) pivots about the support-edge
|
||
corner under gravity (point-mass COM, `alpha = G·(comX−pivotX)/r²`), a
|
||
smooth continuous swing — no discrete snap. Two endings: a far corner
|
||
reaches the ground line → it lands, impact speed `|angVel|·r` deals
|
||
self-damage and footprint damage (`topplePathDamage`: blocks take impact,
|
||
enemies in the footprint can be killed); or the pivot is too high for any
|
||
corner to reach the ground → the board is a pendulum and swings back,
|
||
settling flat on its support (`settleBack`, with anti-livelock: it
|
||
re-tips only on a real knock ≥ 6 px or a damaging hit).
|
||
- **Falling objects kill (S-4).** Crush is velocity-direction-aware: the
|
||
moving box must be the body plowing into the enemy (`boxAlong > 30`), so
|
||
vertical drops and side plows both register and a merely resting box never
|
||
does. One landing = one crush instance (per-enemy 0.12 s cooldown). Stacked
|
||
block contact is mass-weighted inelastic, so a heavy block landing on a
|
||
light plank transfers real momentum through the stack (chain-crush).
|
||
- **Landed birds stay physical (S-5, Q2 = real continuous gravity, no
|
||
lifetime timer).** A bird that settles while ANOTHER bird is in play
|
||
(queued, or flying/aiming/landed) becomes `landed`: it keeps integrating
|
||
under gravity, falls when its support breaks, chain-damages the boards it
|
||
lands on, and can kill enemies — for as long as the level runs. The LAST
|
||
bird still spends exactly as before, so win/lose resolution is unchanged;
|
||
`rules` must not declare `lost` while a landed bird is in the world, and
|
||
level end clears landed bodies.
|
||
|
||
## Data flow
|
||
|
||
```
|
||
input (drag/keys) -> app -> ISLE_CORE.launch / ISLE_CORE.triggerAbility
|
||
|
|
||
main loop (rAF, fixed 1/120s accumulator) -> ISLE_CORE.step(state, dt)
|
||
|
|
||
state = { birds, blocks, enemies, events, score, camera, status, ... }
|
||
|
|
||
Renderer.draw(ctx, state) + AudioEngine.play(event)
|
||
```
|
||
|
||
The core is **deterministic and DOM-free**: `createGame(levelDef)` returns a
|
||
plain state object; `step(state, dt)` mutates it and is safe to call from a
|
||
headless loop (that is how all physics tests run).
|
||
|
||
## Invariants (the load-bearing contracts)
|
||
|
||
- `ISLE_CORE` public API is key-for-key stable (see `js/physics/index.js`):
|
||
consumers use `ISLE_CORE.WORLD`, `MATERIALS`, `BIRD_TYPES`, `ENEMY_TYPES`,
|
||
`createGame`, `activeBird`, `aimVector`, `trajectory`, `launch`,
|
||
`triggerAbility`, `step`, `starsFor`, `birdsRemaining`, `LAUNCH_POWER`,
|
||
`MAX_PULL`, `MIN_PULL`, `BIRD_BONUS`, `AIR_DRAG`, `VIEW`.
|
||
- `ISLE_LEVELS` public API: `{ LEVELS, SLING }`. Level geometry uses block
|
||
**centers**; enemies sit with their bottom at `y + r` resting on ground
|
||
(y=900) or a flat block top — the level-integrity test enforces this.
|
||
- Physics timestep: 1/120 s in tests and the page accumulator; collision
|
||
response, crush damage and settle logic all assume `dt` at that scale.
|
||
- One global per module, attached to `window` in the browser and exported
|
||
via `module.exports` in Node. Shared-scope part lists
|
||
(`ISLE_PHYSICS_PARTS`, `ISLE_LEVEL_PARTS`) are **browser-only** transport;
|
||
Node resolves parts via `require`.
|
||
|
||
## Test surface
|
||
|
||
- `test/sim.test.mjs` (15) — headless: parses every file under `js/`, DOM-id
|
||
wiring, level integrity, determinism, stability, trajectory, abilities,
|
||
win/lose, all 5 levels clearable, fort rest, aim framing.
|
||
- `test/page.test.mjs` (7) — DOM stub: boots the real page in browser order,
|
||
click-through the UI, a full 8 s launch + sim, pause/mute/keys, launch
|
||
direction, sling-band release.
|
||
- `test/balance.test.mjs` (5) — the balance spec: per-material break budgets
|
||
and enemy kill budgets under full-power direct hits.
|
||
- `test/topple.test.mjs` (5) — S-3: overhanging board tips (COM past support
|
||
edge), rotation is continuous, a fully-supported board never tips (negative
|
||
control), tip impact damages a block in the fall path, falling board kills
|
||
an enemy in the fall path.
|
||
- `test/knifeedge.test.mjs` (6) — S-3 regression: a knife-edge overhang (COM a
|
||
hair past the edge, pivot too high to reach the ground) tips once and
|
||
settles back to rest — no livelock; a real knock re-arms it.
|
||
- `test/fallingkill.test.mjs` (5) — S-4: chain-kill through a knocked plank,
|
||
direct one-hit kill, repeated-impact kill (grum), one landing = one crush
|
||
instance, and a resting block never crushes.
|
||
- `test/landedbird.test.mjs` (4) — S-5: perched bird falls when its platform
|
||
breaks (Q2 real gravity), no drift on an intact board, chain-damages the
|
||
board it lands on, kills an enemy in its fall path.
|
||
|
||
Total: 47/47. New physics behavior must ship with RED-watched tests in this
|
||
same surface (every new spec was watched RED on the pre-change tree first).
|
||
|
||
## Conventions for agents working in this tree
|
||
|
||
1. Change a physics rule -> edit the one submodule that owns it (see each
|
||
`agents.md`); re-run `npm test` (27/27) and the golden-step hash
|
||
(`/opt/data/s2_golden.mjs` against `/opt/data/core.orig.js`) when touching
|
||
anything that predates RC-375D84 behavior.
|
||
2. Never re-introduce a flat file for a unit that has a submodule folder.
|
||
3. Keep `index.html` script order in lockstep with `test/page.test.mjs`'s
|
||
`FILES` list — both encode the load contract.
|
||
4. No new runtime dependencies; the game must stay fully offline.
|