Isle-Slingers/architecture.md

155 lines
8.4 KiB
Markdown
Raw Normal View History

# 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`.
2026-09-29 09:22:31 -05:00
## 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.