mollycool/src/juice.js
type-two 6263672134 Molly Cool: arcade science-action vertical slice
A nanoscale arcade game. Shoot charge across the room, wrestle atoms
together until they snap, and contain a chain reaction before it eats
the field. Vanilla JS + Canvas 2D, no dependencies, no build step.

Core systems:
- Charge: give/take as the only two verbs. Opposites attract, likes repel.
- Laserhands: ranged proton/vacuum bolts with real travel time. Bolts are
  charged bodies, so they curve through the field — bank shots are real.
  Give spends from an 8-proton ring, take earns them back.
- The wrestle: bonds can't form on their own. Every pair has an activation
  barrier that parks them at arm's length; Molly's hands are the only way
  over it. Hold, squeeze, release -> SNAP.
- Radicals: the enemy, with no mind. Every 0.32s one grabs its nearest
  neighbour, completes itself, and hands the wound on. Can't be shot dead —
  terminated by wrestling two together (barrierless, so 0.38s vs 1.56s).
  Two that drift into contact self-quench, which gives outbreaks a natural
  equilibrium instead of unbounded growth.
- Overload: keep feeding one atom and it goes critical. The blast tips its
  neighbours, so density decides whether you get one pop (34 atoms) or 84
  detonations from a single seed (116 atoms).
- CASCADE: eight arenas of escalating density with finite ignition waves.
  Clear the field to advance; sustained neglect melts it down.
- Membrane, sour/soapy zones, and the seven wordless tutorial rooms.

Non-obvious constraints, documented at each constant:
- BARRIER_FORCE must exceed MAX_CHARGE_FORCE, or atoms bond themselves and
  the wrestle (the whole game) gets skipped.
- Bond lock distance scales with atom radius. A flat value meant two carbons
  could never bond at all, since collision held them further apart than the
  lock — invisible because hydrogen worked fine.
- RAD_SPLIT_R must exceed the field's natural packing distance (set by the
  barrier), or outbreaks can only crawl and never bloom.

Testing: 39 headless sim tests run the real physics in Node — no browser,
no canvas. The sim draws all randomness from a seeded RNG so runs are
reproducible. Perf is 0.32ms/sim-step at 116 atoms.

Dev server strips a /v<timestamp>/ path prefix that index.html adds to its
entry import, because browsers cache the *parsed* ES module by URL and
no-cache headers alone don't touch it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:36:54 +10:00

43 lines
1.2 KiB
JavaScript

import { CFG } from './config.js';
import { FX } from './fx.js';
// ============================================================
// THE JUICE BUDGET.
// Written FIRST, before bolts/detonations/cascades can stack on it.
// Without a cap, a chain reaction reads as "the game crashed"
// rather than "the game is going off".
// ============================================================
const win = []; // [{t, ms}] hitstop spend inside the window
let clock = 0;
export const Juice = {
tick(dt) {
clock += dt;
while (win.length && clock - win[0].t > CFG.JUICE_WINDOW) win.shift();
},
spent() {
let s = 0;
for (const e of win) s += e.ms;
return s;
},
// request hitstop; returns what was actually granted
hitstop(ms) {
const room = Math.max(0, CFG.JUICE_HITSTOP_CAP - this.spent());
const grant = Math.min(ms, room);
if (grant > 0) {
win.push({ t: clock, ms: grant });
FX.freeze(grant / 1000);
}
return grant;
},
// trauma is self-limiting (clamped 0..1 + decay) but funnel it here
// so every source is auditable in one place
trauma(camera, v) { camera.addTrauma(v); },
reset() { win.length = 0; clock = 0; },
};