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>
71 lines
2.6 KiB
JavaScript
71 lines
2.6 KiB
JavaScript
import { makeWorld, tick, sim, aimAt, check, section, report, CFG, Input, ROOMS } from './harness.mjs';
|
|
const { Arena } = await import('../src/arena.js');
|
|
|
|
section('OVERLOAD — build a bomb out of charge');
|
|
{
|
|
const w = makeWorld(5);
|
|
w.load(ROOMS[1]);
|
|
w.particles.length = 0;
|
|
const p = w.spawn({ x: 640, y: 360, el: 'C' });
|
|
p.fixed = true;
|
|
w.molly.x = 300; w.molly.y = 360;
|
|
aimAt(w, 640, 360);
|
|
|
|
check('starts unloaded', !p.fuse, `load=${p.load||0}`);
|
|
for (let k = 0; k < CFG.OVERLOAD_MAX; k++) {
|
|
w.molly.cool = 0; w.bolts.fire(w, 1);
|
|
for (let i = 0; i < 200 && w.bolts.list.length; i++) tick(w);
|
|
}
|
|
check('3 proton bolts light a fuse', p.fuse > 0, `fuse=${(p.fuse||0).toFixed(2)}s`);
|
|
|
|
let boomT = null;
|
|
for (let i = 0; i < 400 && boomT === null; i++) { tick(w); if (!p.fuse) boomT = i / 120; }
|
|
check('fuse detonates', boomT !== null, boomT ? `after ${boomT.toFixed(2)}s` : 'never');
|
|
check('detonation counted', w.overload.detonations === 1, `n=${w.overload.detonations}`);
|
|
}
|
|
|
|
section('OVERLOAD — the cascade');
|
|
{
|
|
const w = makeWorld(5);
|
|
w.load(ROOMS[1]);
|
|
w.particles.length = 0;
|
|
// a tight cluster: one blast should tip the neighbours over too
|
|
for (let i = 0; i < 10; i++)
|
|
w.spawn({ x: 560 + (i % 5) * 46, y: 320 + Math.floor(i / 5) * 46, el: 'H' });
|
|
w.molly.x = 200; w.molly.y = 100;
|
|
const seed = w.particles[0];
|
|
seed.load = CFG.OVERLOAD_MAX; seed.fuse = 0.1; seed.fuseMax = 0.1;
|
|
|
|
for (let i = 0; i < 600; i++) tick(w);
|
|
check('one blast triggers others (CASCADE)', w.overload.detonations > 1,
|
|
`${w.overload.detonations} detonations from 1 seed`);
|
|
check('blast makes radicals (your mess now)', w.radicals.chain >= 0,
|
|
`radicals spawned=${w.particles.filter(p=>p.radical).length}`);
|
|
}
|
|
|
|
section('OVERLOAD — the juice budget holds');
|
|
{
|
|
const { Juice } = await import('../src/juice.js');
|
|
Juice.reset();
|
|
let granted = 0;
|
|
for (let i = 0; i < 30; i++) granted += Juice.hitstop(CFG.BLAST_HITSTOP);
|
|
check('30 simultaneous blasts cannot freeze the screen', granted <= CFG.JUICE_HITSTOP_CAP,
|
|
`${granted}ms granted of ${30*CFG.BLAST_HITSTOP}ms requested`);
|
|
}
|
|
|
|
section('PERF — the densest arena');
|
|
{
|
|
const w = makeWorld(2);
|
|
const a = new Arena();
|
|
w.load(a.room(7)); a.enter(w, 7);
|
|
sim(w, 6);
|
|
const t0 = process.hrtime.bigint();
|
|
for (let i = 0; i < 600; i++) tick(w); // 5 sim-seconds
|
|
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
|
const perStep = ms / 600;
|
|
check('sim step is fast enough for 60fps', perStep < 4,
|
|
`${perStep.toFixed(2)}ms/step, ${w.particles.length} atoms, ${w.radicals.count} radicals`);
|
|
}
|
|
|
|
process.exit(report() ? 0 : 1);
|