Isle-Slingers/architecture.md
2026-09-29 14:22:31 +00:00

154 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (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.