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>
85 lines
3.1 KiB
JavaScript
85 lines
3.1 KiB
JavaScript
import { makeWorld, tick, sim, check, section, report, CFG, Input } from './harness.mjs';
|
|
const { Arena, ARENAS } = await import('../src/arena.js');
|
|
|
|
section('CASCADE — the arena');
|
|
{
|
|
const w = makeWorld(99);
|
|
const arena = new Arena();
|
|
const def = arena.room(0);
|
|
check('arena builds a field', def.particles.length > 20, `${def.particles.length} atoms`);
|
|
|
|
// minimum separation respected (field must not explode on frame 1)
|
|
let tooClose = 0;
|
|
for (let i = 0; i < def.particles.length; i++)
|
|
for (let j = i + 1; j < def.particles.length; j++) {
|
|
const a = def.particles[i], b = def.particles[j];
|
|
if (Math.hypot(a.x - b.x, a.y - b.y) < 50) tooClose++;
|
|
}
|
|
check('field starts at natural packing', tooClose === 0, `${tooClose} overlaps`);
|
|
|
|
w.load(def);
|
|
arena.enter(w, 0);
|
|
sim(w, 3);
|
|
check('arena ignites quickly (no dead air)', w.radicals.count > 0, `n=${w.radicals.count} at 3s`);
|
|
|
|
// survivability: does an unattended arena reach a plateau, not a wipe?
|
|
const trace = [];
|
|
for (let s = 0; s < 8; s++) { sim(w, 5); trace.push(`${(s+1)*5}s:${w.radicals.count}`); }
|
|
console.log(' unattended 40s -> ' + trace.join(' '));
|
|
check('unattended arena does not fill the screen',
|
|
w.radicals.count < 60, `n=${w.radicals.count}`);
|
|
check('waves are finite (a fight you can finish)', arena.wave <= arena.spec().waves,
|
|
`${arena.wave}/${arena.spec().waves} waves`);
|
|
}
|
|
|
|
section('CASCADE — difficulty actually ramps');
|
|
{
|
|
const counts = ARENAS.map((s, i) => {
|
|
const w = makeWorld(7);
|
|
const a = new Arena();
|
|
const def = a.room(i);
|
|
w.load(def); a.enter(w, i);
|
|
sim(w, 20);
|
|
return { i, atoms: def.particles.length, n: w.radicals.count };
|
|
});
|
|
console.log(' ' + counts.map(c => `a${c.i}:${c.atoms}atoms/${c.n}rad`).join(' '));
|
|
check('later arenas are denser', counts[7].atoms > counts[0].atoms,
|
|
`${counts[0].atoms} -> ${counts[7].atoms}`);
|
|
check('later arenas are hotter at 20s', counts[7].n >= counts[0].n,
|
|
`${counts[0].n} -> ${counts[7].n}`);
|
|
}
|
|
|
|
|
|
section('CASCADE — win and lose conditions');
|
|
{
|
|
// LOSE: a hyper-dense arena left unattended must melt down
|
|
const w = makeWorld(3);
|
|
const a = new Arena();
|
|
const def = a.room(7); // the densest
|
|
w.load(def); a.enter(w, 7);
|
|
let melted = null;
|
|
for (let i = 0; i < 120 * 90 && melted === null; i++) { tick(w); if (a.meltdown) melted = i / 120; }
|
|
check('unattended late arena MELTS DOWN (a real lose state)', melted !== null,
|
|
melted ? `at ${melted.toFixed(1)}s` : 'never melted');
|
|
|
|
// WIN: quench everything by hand and the arena clears
|
|
const w2 = makeWorld(11);
|
|
const a2 = new Arena();
|
|
const d2 = a2.room(0);
|
|
w2.load(d2); a2.enter(w2, 0);
|
|
let cleared = null;
|
|
for (let i = 0; i < 120 * 120 && cleared === null; i++) {
|
|
tick(w2);
|
|
// stand-in for a competent player: quench one radical per half second
|
|
if (i % 60 === 0) {
|
|
const r = w2.particles.find(p => p.radical);
|
|
if (r) { r.radical = false; }
|
|
}
|
|
if (a2.cleared) cleared = i / 120;
|
|
}
|
|
check('arena CLEARS when the player keeps up', cleared !== null,
|
|
cleared ? `at ${cleared.toFixed(1)}s` : 'never cleared');
|
|
}
|
|
|
|
process.exit(report() ? 0 : 1);
|