Go to file
m3ultra 3fd58110f4 VSEPR geometry and a solvent: molecules become things
"It needs more detail" was not a pixel problem. A molecule was a set of
atoms at whatever angles the storm left them — three circles near each
other, never a thing — and the room was a vacuum with objects in it.

VSEPR. Bond springs only ever fixed bond LENGTH. Angular springs now splay
the bonds around each atom as far apart as they can go, which is what
produces the famous silhouettes: water bent at 104.5, CO2 a straight bar,
methane a caltrop, a closed carbon ring a hexagon. Two bonds is where lone
pairs matter, so O/N/S carry an explicit `bend`; three or more is just even
angular spacing, which lands trigonal at 120 and the caltrop at 90 for free.

The springs are deliberately weak (ANGLE_SPRING is ~15% of BOND_SPRING) so
geometry loses to the storm, to a blast and to Molly's hands. A rigid
molecule that ignores being hit reads as fake instantly.

They also needed DAMPING, which was the real bug. Undamped, the mean angle
measured correct while water actually swung through a 61 degree range —
a broken hinge, not a molecule. Damping the tangential relative velocity
(as bondSprings already does radially) cuts the spread from sd 16.5 to 1.3.

Two measurement traps worth recording, since both nearly sent me the wrong
way: a single-frame angle reading is meaningless in a Brownian storm and
showed water at 58 degrees when its mean was 105; and starting a two-arm
molecule at exactly 180 degrees is a degenerate equilibrium where the cross
product vanishes and it cannot pick a side to fold toward, which looked
exactly like a systematic bias.

THE SOLVENT. 600 motes advected by the Coulomb field the sim already
computes. Deliberately NOT particles: no charge, no valence, no collisions,
nothing can touch them, so 600 cost 0.16ms where 116 real atoms cost ~5ms.
They carry what the atoms cannot — you see the field move before anything
charged does. Drawn under everything and never additive, because 600
additive motes would stack into a wash that eats the element hues and the
radicals' white.

13 new tests in test/geometry.test.mjs; 81 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 21:19:48 +10:00
.claude Molly Cool: arcade science-action vertical slice 2026-07-19 15:36:54 +10:00
deploy deploy: parse the CF zone id with json, not a greedy sed 2026-07-25 23:27:50 +10:00
src VSEPR geometry and a solvent: molecules become things 2026-07-28 21:19:48 +10:00
test VSEPR geometry and a solvent: molecules become things 2026-07-28 21:19:48 +10:00
.gitignore deploy: purge cloudflare, document the two silent-failure traps 2026-07-25 23:17:14 +10:00
BUILD_PLAN.md Molly Cool: arcade science-action vertical slice 2026-07-19 15:36:54 +10:00
index.html Saturation is the firebreak: make bonding matter 2026-07-25 23:13:08 +10:00
NEXT_LEVEL.md Molly Cool: arcade science-action vertical slice 2026-07-19 15:36:54 +10:00
README.md VSEPR geometry and a solvent: molecules become things 2026-07-28 21:19:48 +10:00
serve.py Molly Cool: arcade science-action vertical slice 2026-07-19 15:36:54 +10:00

MOLLY COOL

You are fourteen angstroms tall with a proton in one hand and a vacuum in the other. Shoot charge across the room, wrestle atoms together until they snap, and try to put out a chain reaction before it eats the field — and you.

Play: python3 serve.pyhttp://localhost:5178

No dependencies. No build step. Vanilla JS + Canvas 2D.


Controls

WASD move
mouse aim
left click proton bolt — pushes charge in (+)
right click vacuum bolt — rips charge out ()
space (hold) grab two things and squeeze them together
Q heat — area shove, breaks every bond in radius (40% go homolytic)
R retry
F frame-time readout (off by default)
shift+A jump straight to CASCADE

Aim at your own feet to charge yourself — that's how you go neutral and slip through a membrane.

The rules, none of which the game will tell you

  • Opposites attract, likes repel. Bolts are charged too, so they curve through the field. Bank shots are real.
  • Give spends, take earns. Eight protons orbit your hand. Fire them all and you're dry — the left hand always works, for free.
  • Bonds can't form on their own. Every pair has an activation barrier that parks them at arm's length. Your hands are the only way over it.
  • Saturation is the firebreak. A radical takes an atom by grabbing an open valence slot. Close every slot on something and the fire can't cross it. A bonded pair of hydrogens is the cheapest wall in the game; a lone carbon has four open sockets and is the best kindling. Wall a radical in with nothing left to take and it goes out on its own in a few seconds — which is the only way to kill the last one, since terminating needs a pair.
  • Nothing you build is permanent. A detonation snaps a bond and both halves come away radical. Q breaks every bond it touches and 40% of those splits go homolytic — two new radicals. The button that unsticks a jam is the button that opens a hole in your own wall.
  • The membrane blocks anything charged and ignores anything neutral. It has no guard and no keyhole. You get through by becoming the right kind of thing.
  • Sour air keeps shoving protons onto everything. Including you.
  • Keep feeding one atom and it goes critical. A fuse, then a blast that charges everything nearby — which can tip those over. In a dense field one seed can take the whole screen.
  • Radicals are the only pure white thing in the game. They have no mind: every 0.32s a radical grabs its nearest neighbour, completes itself, and hands the wound on. You can't shoot one dead. You terminate it by wrestling two together — radical pairs recombine barrierlessly, so it's fast.
  • Two radicals that drift into each other quench on their own. Herding is a legitimate tactic.
  • Molecules have real shapes. Bonds splay as far apart as they can, so water sits visibly bent at 104.5°, CO₂ is a straight bar, methane is a caltrop and a closed carbon ring is a hexagon. The geometry is a tendency, not a rigid body — a blast deforms it and it relaxes back.
  • The room is a medium, not a vacuum. 600 solvent motes drift on the same Coulomb field the atoms feel. They can't be touched and nothing interacts with them; they're there so you can see the field move before anything charged does.

Structure

Seven wordless tutorial rooms (the cold open), then CASCADE — eight arenas of escalating density. Each arena spends a fixed number of ignition waves; put the fire out and it clears. Let it burn and the field melts down.

Density is the difficulty dial. Everything else follows from it.

The one number on screen is terminations, and back-to-back kills stack a streak that lapses after three quiet seconds. No label, no praise, no record.

Layout

src/config.js      ALL feel constants. Tune the game here.
src/physics.js     storm, charge, the activation barrier, VSEPR, zones, membrane
src/solvent.js     600 non-interactive motes advected by the field
src/bonds.js       the wrestle — grab, squeeze, SNAP
src/bolts.js       laserhands
src/radicals.js    the enemy
src/overload.js    fuses, detonations, the cascade
src/arena.js       CASCADE — waves, meltdown, clear
src/elements.js    twelve elements: colour, valence, radius, mass
src/juice.js       hitstop/trauma budget (a cascade must never freeze the screen)
test/*.test.mjs    headless sim tests — `node test/sim.test.mjs`

Tests

node test/sim.test.mjs        core physics, radicals, molly, regression
node test/arena.test.mjs      arena waves, win/lose, difficulty ramp
node test/overload.test.mjs   fuses, cascade, juice budget, perf
node test/firebreak.test.mjs  saturation, homolysis, the score, arena both-ends
node test/geometry.test.mjs   VSEPR shapes, angular damping, the solvent

The sim is seedable (seedRandom), so runs are reproducible. Tests drive the real physics headlessly in Node — no browser, no canvas.

Tuning

The three knobs that matter, in order:

  1. JITTER_STRENGTH — the life of the world.
  2. SQUEEZE_RATE / BARRIER_FORCE — the wrestle. The whole game's feel in two numbers.
  3. SNAP_TRAUMA / HITSTOP — the payoff.

Some constants have non-obvious constraints, documented where they live: BARRIER_FORCE must stay above MAX_CHARGE_FORCE (or atoms bond themselves and the wrestle is skipped); RAD_SPLIT_R must stay above the field's natural packing distance (or outbreaks never bloom); and RAD_STARVE_TIME / RAD_DRIFT_TIME must stay far apart — a radical walled in by matter you built should die fast, but one merely adrift in a sparse early arena must not, or the outbreak evaporates unattended and the meltdown lose state disappears.

Deploy

./deploy/deploy.sh

Live at https://monsterrobot.games/mollycool/. Rsyncs index.html + src/ to the games VPS at /home/humanjing/monsterrobot.games/games/mollycool and symlinks mollycool -> games/mollycool at the lander root, which is the convention not-tonight already uses — no nginx change needed.

Two traps it handles for you, both of which produce a silent dead title screen rather than an error:

  • forum-nginx mounts the lander read-only, so docker cp fails quietly. Always write the host path. (Same trap as beyondmorp V2.)
  • The dev server's /v<timestamp>/ prefix is absolute. In production it falls through nginx's try_files to the lander's index.html and gets served as text/html, which the browser refuses to execute as a module. index.html only uses the prefix on localhost and ships a relative specifier everywhere else. The script verifies the module's content-type, not just the page's status code.

Cloudflare serves the modules with max-age=14400, so the script purges the deployed paths. Put CF_API_TOKEN in deploy/.env (gitignored) or you'll be running four-hour-old code after a redeploy until you hard-reload.

Dev note

serve.py strips a /v<timestamp>/ prefix off request paths, and index.html imports the entry under a fresh one each load. Browsers cache the parsed ES module by URL, so without this you can edit a file, hard-reload, and still be running yesterday's code.