Removes internal process references so the repo is presentable to an outside audience: dropped the internal build-retrospective doc (process log with internal terms, not useful to an outside reader), and reworded remaining internal references in code comments and module docs (agents.md cards, architecture.md, MODULES.md, index.html, package.json) to generic descriptions of what changed and when, with no behavior change -- full 47/47 suite re-run green after the scrub, every edit verified comment/doc-only via diff. 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 the physics + modularization update (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 physics-pass 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` (47/47) and re-check the golden differential
|
||
against the pre-modularization core when touching anything that predates
|
||
this modularization/physics pass.
|
||
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.
|