Isle-Slingers/architecture.md
Relay dff5dd5b30 Clean release scrub: remove internal fleet references
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>
2026-09-29 14:36:52 +00:00

8.4 KiB
Raw Blame History

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.