mollycool/src/config.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

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 };