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>
182 lines
6.5 KiB
JavaScript
182 lines
6.5 KiB
JavaScript
// ============================================================
|
|
// ALL FEEL LIVES HERE. Tune the game by editing this file only.
|
|
//
|
|
// The three knobs that matter most, in order:
|
|
// 1. JITTER_STRENGTH — the life of the world
|
|
// 2. the bond barrier — BARRIER_*, BOND_LOCK_DIST — the wrestle
|
|
// 3. snap juice — SNAP_TRAUMA, HITSTOP, SNAP_SHOCKWAVE
|
|
// ============================================================
|
|
|
|
export const CFG = {
|
|
// ---- world ----
|
|
W: 1280,
|
|
H: 720,
|
|
DT: 1 / 120, // fixed sim step
|
|
MAX_FRAME: 0.25, // clamp huge tab-switch deltas
|
|
|
|
// ---- molly ----
|
|
MOLLY_RADIUS: 14,
|
|
MOLLY_ACCEL: 3000,
|
|
MOLLY_MAX_SPEED: 260,
|
|
MOLLY_DAMP: 0.004, // fraction of velocity left after 1s
|
|
MOLLY_JITTER: 26, // she never sits perfectly still
|
|
|
|
// ---- particles / the storm ----
|
|
P_RADIUS: 11,
|
|
JITTER_STRENGTH: 90, // px/s per sqrt(s) — Brownian, framerate-independent
|
|
P_DAMP: 0.02, // fraction of velocity left after 1s
|
|
COLLIDE_PUSH: 0.5, // overlap resolution strength
|
|
|
|
// ---- charge interaction ----
|
|
CHARGE_RANGE: 190,
|
|
K_CHARGE: 1.2e7, // F = K*q1*q2/d^2
|
|
D_MIN: 26, // clamp denominator
|
|
MAX_CHARGE_FORCE: 3000,
|
|
DISPLAY_LERP: 9, // charge color/shape morph speed (per second)
|
|
|
|
// ---- hands / grab ----
|
|
GRAB_RANGE: 130,
|
|
AIM_DIST: 92, // reticle offset from molly (gamepad)
|
|
HAND_SPRING: 500,
|
|
HAND_DAMP: 30,
|
|
CHARGE_COOLDOWN: 0.16,
|
|
|
|
// ---- THE BOND WRESTLE (the heart of the game) ----
|
|
SQUEEZE_RATE: 42, // px/s the hands close while you HOLD. the verb.
|
|
// Radical pairs recombine barrierlessly, so the SQUEEZE has to be fast too —
|
|
// dropping the barrier alone doesn't help when the hands are the bottleneck.
|
|
// 1.8s is a wonderful puzzle payoff and a fatal combat move.
|
|
RAD_SQUEEZE_MUL: 3.4,
|
|
BOND_ATTEMPT_RANGE: 62, // where mutual attraction kicks in while held
|
|
BOND_PULL: 900, // attraction while wrestling
|
|
BARRIER_OUTER: 46, // barrier band starts here...
|
|
// ...and peaks halfway to BOND_LOCK_DIST.
|
|
// MUST stay > MAX_CHARGE_FORCE, or opposites bond themselves and the
|
|
// wrestle — the whole game — gets skipped. The barrier is why Molly matters.
|
|
BARRIER_FORCE: 3400,
|
|
// Bond distances SCALE WITH ATOM SIZE. A flat lock distance is a trap:
|
|
// two carbons (r13) can never get closer than 26px because collision
|
|
// stops them, so a flat 22px lock means carbon can never bond at all.
|
|
// Big atoms bond further out — which is also just true.
|
|
LOCK_MUL: 0.92, // lock distance = (ra + rb) * this
|
|
BOND_LEN_MUL: 0.95, // resting bond length = (ra + rb) * this
|
|
BARRIER_BAND: 24, // barrier runs from lock -> lock + this
|
|
BOND_LOCK_DIST: 22, // fallback only
|
|
BOND_LENGTH: 25, // fallback only
|
|
BOND_SPRING: 560,
|
|
BOND_DAMP: 16,
|
|
BOND_BREAK_DIST: 74,
|
|
|
|
// ---- snap payoff ----
|
|
SNAP_TRAUMA: 0.62,
|
|
HITSTOP: 0.05,
|
|
SNAP_KNOCKBACK: 340, // shove on molly
|
|
SNAP_SHOCKWAVE: 620, // radial impulse on nearby particles (this is a TOOL)
|
|
SNAP_SHOCK_RADIUS: 170,
|
|
SNAP_SPARKS: 26,
|
|
|
|
// ---- zones ----
|
|
ZONE_FLIP_TIME: 1.2, // seconds of sour air before a neutral thing goes +
|
|
ZONE_GRACE: 0.35, // timer reset after player acts on a particle
|
|
|
|
// ---- membrane ----
|
|
MEM_THICK: 24,
|
|
MEM_FORCE: 6000,
|
|
MEM_BOUNCE_TRAUMA: 0.14,
|
|
|
|
// ---- LASERHANDS ----
|
|
// Tap = bolt. Travel time, never hitscan — travel is what reads as "pyew".
|
|
BOLT_SPEED: 940,
|
|
BOLT_R: 4,
|
|
BOLT_LIFE: 1.8,
|
|
BOLT_COOLDOWN: 0.16,
|
|
BOLT_HITSTOP: 45, // ms, requested through the juice budget
|
|
BOLT_TRAUMA: 0.15,
|
|
BOLT_RECOIL: 40, // molly shoved backward by give
|
|
BOLT_PULL: 30, // molly tugged forward by take
|
|
BOLT_CURVE: 0.30, // bolts are charged bodies -> they BEND round atoms
|
|
BOLT_SPARKS: 8,
|
|
HAND_KICK: 6, // px the hand sprite kicks back
|
|
MUZZLE_LIFE: 0.09,
|
|
|
|
// give SPENDS, take EARNS. you physically cannot spam one hand.
|
|
AMMO_MAX: 8,
|
|
AMMO_RING_R: 18,
|
|
AMMO_SPIN: 1.2,
|
|
|
|
// ---- OVERCHARGE / DETONATION ----
|
|
// Charge clamps at +-1 for handling, but you can OVERLOAD past it.
|
|
// Cram 3 into one atom and it goes critical: a fuse, then a blast that
|
|
// charges everything nearby — which can push THOSE past 3 too. Cascade.
|
|
OVERLOAD_MAX: 3,
|
|
FUSE_TIME: 0.9,
|
|
BLAST_R: 130,
|
|
BLAST_IMPULSE: 700,
|
|
BLAST_HITSTOP: 160,
|
|
BLAST_TRAUMA: 0.62,
|
|
CHAIN_FUSE_MUL: 0.5, // a fuse lit by a blast burns faster
|
|
|
|
// ---- THE RADICAL ----
|
|
RAD_TICK: 0.32, // deterministic hop interval. NEVER per-frame random.
|
|
RAD_REACH: 70, // how far it can grab
|
|
// 2nd victim this close -> it takes BOTH, and branch factor crosses 1.
|
|
// MUST exceed the field's natural packing distance. The barrier repels every
|
|
// bondable pair out to (lock + BARRIER_BAND) ~= 48px, so atoms self-space
|
|
// around there; a split radius below that can never fire and the outbreak
|
|
// would only ever crawl instead of blooming.
|
|
RAD_SPLIT_R: 58,
|
|
RAD_TELEGRAPH: 0.2,
|
|
RAD_CAP: 120,
|
|
RAD_KILL_HITSTOP: 130,
|
|
RAD_KILL_TRAUMA: 0.7,
|
|
RAD_QUENCH_R: 150, // annihilation puts out everything in this radius
|
|
RAD_BARRIER_MUL: 0.15, // radical-radical is barrierless -> fast wrestle
|
|
|
|
// ---- molly's life ----
|
|
MOLLY_ELECTRONS: 3,
|
|
MOLLY_IFRAME: 1.1,
|
|
MOLLY_DEATH: 0.4,
|
|
|
|
// ---- juice budget ----
|
|
JUICE_WINDOW: 0.5, // seconds
|
|
JUICE_HITSTOP_CAP: 200, // ms of hitstop allowed per window
|
|
|
|
// ---- openers ----
|
|
HEAT_RADIUS: 155,
|
|
HEAT_IMPULSE: 540,
|
|
HEAT_JITTER_SPIKE: 2.4,
|
|
HEAT_TRAUMA: 0.34,
|
|
HEAT_COOLDOWN: 0.5,
|
|
LIGHT_COOLDOWN: 0.35,
|
|
|
|
// ---- camera / juice ----
|
|
CAM_LERP: 7,
|
|
MAX_SHAKE: 20,
|
|
MAX_ROT: 2 * Math.PI / 180,
|
|
TRAUMA_DECAY: 1.5,
|
|
|
|
// ---- palette ----
|
|
COL_POS: '#FF2D9B',
|
|
COL_NEG: '#2DB8FF',
|
|
COL_NEU: '#C8D0D8',
|
|
// near-black reads as a VOID. ink-indigo reads as a MEDIUM.
|
|
COL_BG: '#0B0716',
|
|
COL_GRID: '#1A1030',
|
|
GRID_STEP: 32,
|
|
COL_MOLLY: '#FFE9A3',
|
|
COL_MEM: '#7FE3C4',
|
|
|
|
// zone washes (subtle — never hurt readability)
|
|
TINT_SOUR: 'rgba(190, 210, 40, 0.055)',
|
|
TINT_SOAPY: 'rgba(130, 110, 255, 0.055)',
|
|
|
|
// ---- audio ----
|
|
MASTER_GAIN: 0.35,
|
|
};
|
|
|
|
// charge state -> visual identity. Color AND shape, always redundant.
|
|
// +1 positive : hot pink, SPIKY outline
|
|
// 0 neutral : dim gray-white, flat & quiet
|
|
// -1 negative : cyan, smooth with a soft pulsing halo
|
|
export const CHARGE_COL = { '-1': CFG.COL_NEG, '0': CFG.COL_NEU, '1': CFG.COL_POS };
|