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>
This commit is contained in:
m3ultra 2026-07-28 21:19:48 +10:00
parent a5bf60cc43
commit 3fd58110f4
9 changed files with 372 additions and 4 deletions

View File

@ -57,6 +57,14 @@ slip through a membrane.
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
@ -73,7 +81,8 @@ streak that lapses after three quiet seconds. No label, no praise, no record.
```
src/config.js ALL feel constants. Tune the game here.
src/physics.js storm, charge, the activation barrier, zones, membrane
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
@ -91,6 +100,7 @@ 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

View File

@ -66,6 +66,16 @@ export const CFG = {
BOND_SPRING: 560,
BOND_DAMP: 16,
BOND_BREAK_DIST: 74,
// VSEPR angular springs — bond ANGLE, where bond length is BOND_SPRING.
// Deliberately weak (~15% of BOND_SPRING): geometry must lose to the storm,
// to a blast and to Molly's hands. A rigid molecule that ignores being hit
// reads as fake instantly; a molecule that relaxes back into shape reads
// as chemistry.
ANGLE_SPRING: 85,
// Damps the TANGENTIAL relative velocity. Without it the spring is undamped
// and the storm pumps it — the mean angle comes out right while the molecule
// visibly flaps, which is worse than no geometry at all.
ANGLE_DAMP: 26,
// ---- snap payoff ----
SNAP_TRAUMA: 0.62,
@ -173,6 +183,14 @@ export const CFG = {
MAX_ROT: 2 * Math.PI / 180,
TRAUMA_DECAY: 1.5,
// ---- the solvent ----
// Non-interactive motes advected by the field. Not particles: no charge,
// no valence, no collisions, nothing can touch them. That is what makes
// 600 affordable when 116 real atoms already cost ~5ms.
SOLVENT_N: 600,
SOLVENT_REACH: 260,
SOLVENT_PUSH: 5200, // 1/r, not 1/r^2 — see solvent.js
// ---- palette ----
COL_POS: '#FF2D9B',
COL_NEG: '#2DB8FF',

View File

@ -8,14 +8,23 @@
// THE LAW: element owns the FILL. charge owns the EDGE and the LIGHT.
// ============================================================
// `bend` = the ideal angle between two bonds on this atom, in DEGREES.
// Only consulted when the atom has exactly two bonds; with three or more the
// geometry is just even angular spacing (3 -> 120 trigonal, 4 -> 90 caltrop),
// which lands benzene and methane on the right silhouettes for free.
//
// Two bonds is where the lone pairs matter and where the shapes are famous:
// water is visibly BENT at 104.5 and CO2 is a straight bar, and those two
// reading differently at a glance is the whole point. Carbon has no lone
// pairs, so it defaults to linear.
export const ELEMENTS = {
H: { col: '#EAF4FF', slots: 1, r: 7, mass: 0.4, name: 'hydrogen' },
C: { col: '#9B8CFF', slots: 4, r: 13, mass: 1.2, name: 'carbon' },
N: { col: '#4D7BFF', slots: 3, r: 11, mass: 1.1, name: 'nitrogen' },
O: { col: '#2BE86B', slots: 2, r: 11, mass: 1.3, name: 'oxygen' },
N: { col: '#4D7BFF', slots: 3, r: 11, mass: 1.1, name: 'nitrogen', bend: 107 },
O: { col: '#2BE86B', slots: 2, r: 11, mass: 1.3, name: 'oxygen', bend: 104.5 },
Na: { col: '#FF9500', slots: 1, r: 14, mass: 1.8, name: 'sodium' },
Cl: { col: '#D8FF3B', slots: 1, r: 13, mass: 2.0, name: 'chlorine' },
S: { col: '#FFE23D', slots: 2, r: 13, mass: 2.0, name: 'sulfur' },
S: { col: '#FFE23D', slots: 2, r: 13, mass: 2.0, name: 'sulfur', bend: 92 },
Mg: { col: '#D9DEE6', slots: 2, r: 13, mass: 1.6, name: 'magnesium' },
Fe: { col: '#9FB4C4', slots: 3, r: 15, mass: 3.0, name: 'iron' },
Cu: { col: '#23D9C0', slots: 2, r: 14, mass: 2.8, name: 'copper' },

View File

@ -30,6 +30,8 @@ export class Particle {
this.mass = o.mass ?? (E ? E.mass : 1);
this.spin = random() * Math.PI * 2;
this.flash = 0;
// ideal angle between two bonds on this atom, radians. see elements.js
this.bend = E && E.bend ? (E.bend * Math.PI) / 180 : 0;
}
get freeSlots() { return Math.max(0, this.slots - this.bonds.length); }

View File

@ -194,6 +194,82 @@ function bondSprings(world, dt) {
}
}
// ---- 5b. VSEPR: molecules have REAL SHAPES ---------------------------
// Bond springs alone only fix bond LENGTH, so a molecule was a set of atoms
// at whatever angles the storm happened to leave them — three circles near
// each other, never a thing. Valence-shell repulsion says the bonds around
// an atom splay as far apart as they can, and that is what gives the famous
// silhouettes: water bent at 104.5, CO2 a straight bar, methane a caltrop,
// benzene a hexagon.
//
// Weak on purpose (ANGLE_SPRING is a fraction of BOND_SPRING). These springs
// have to lose to the storm, to a blast and to Molly's hands — geometry is a
// tendency the molecule relaxes into, not a rigid body. Crank it up and
// molecules stop reacting to being hit, which reads as fake immediately.
function idealAngle(c, n) {
// two bonds is where lone pairs matter and the shapes are recognisable
if (n === 2 && c.bend) return c.bend;
// otherwise splay evenly: 3 -> 120 (trigonal/benzene), 4 -> 90 (caltrop)
return (Math.PI * 2) / n;
}
function angleSprings(world, dt) {
const k = CFG.ANGLE_SPRING;
for (const c of world.particles) {
const n = c.bonds.length;
if (n < 2) continue;
const ideal = idealAngle(c, n);
// order the neighbours by bearing so "adjacent" means adjacent. Applying
// the ideal to EVERY pair is wrong past three bonds: with four arms the
// adjacent pairs want 90 but the opposite pairs sit at 180, and forcing
// 90 on those folds the caltrop in on itself.
const nb = c.bonds
.map((b) => ({ b, a: Math.atan2(b.y - c.y, b.x - c.x) }))
.sort((p, q) => p.a - q.a);
// with two bonds the "adjacent pairs" are the same pair twice
const pairs = n === 2 ? 1 : n;
for (let i = 0; i < pairs; i++) {
const A = nb[i].b, B = nb[(i + 1) % n].b;
const ax = A.x - c.x, ay = A.y - c.y;
const bx = B.x - c.x, by = B.y - c.y;
const la = Math.hypot(ax, ay) || 1e-4, lb = Math.hypot(bx, by) || 1e-4;
const cross = ax * by - ay * bx;
const dot = ax * bx + ay * by;
const theta = Math.abs(Math.atan2(cross, dot)); // 0..PI
const err = ideal - theta; // >0 => splay further
if (Math.abs(err) < 0.01) continue;
// push each neighbour along its own tangent, in the direction that
// opens the angle; the centre takes the reaction so momentum holds.
const s = cross >= 0 ? 1 : -1;
const f = k * err;
// unit tangents — the direction each neighbour travels to open the angle
const tax = (ay / la) * s, tay = (-ax / la) * s;
const tbx = (-by / lb) * s, tby = (bx / lb) * s;
// ANGULAR DAMPING. Without this the spring is undamped and the Brownian
// storm pumps it: the mean angle is correct but water FLAPS between 63
// and 132 degrees, which reads as a broken hinge rather than a bent
// molecule. bondSprings damps its radial velocity for exactly this
// reason; the angular spring needs the tangential equivalent.
const dva = (A.vx - c.vx) * tax + (A.vy - c.vy) * tay;
const dvb = (B.vx - c.vx) * tbx + (B.vy - c.vy) * tby;
const damp = CFG.ANGLE_DAMP;
const fax = tax * f - tax * dva * damp, fay = tay * f - tay * dva * damp;
const fbx = tbx * f - tbx * dvb * damp, fby = tby * f - tby * dvb * damp;
A.addForce(fax, fay, dt);
B.addForce(fbx, fby, dt);
c.addForce(-(fax + fbx), -(fay + fby), dt);
}
}
}
// ---- 6. collisions ---------------------------------------------------
function collide(world) {
const ps = world.particles;
@ -263,6 +339,7 @@ export function step(world, dt) {
world.bondsSystem.update(world, dt); // hands + barrier + snap
membranes(world, dt);
bondSprings(world, dt);
angleSprings(world, dt);
collide(world);
integrate(world, dt);
}

View File

@ -435,6 +435,9 @@ export function render(ctx, world, camera) {
// ---- the ground is a MEDIUM, not a void: the field grid ----
drawField(ctx, world);
// ---- and so is the air. Under everything, so it reads as depth. ----
world.solvent.draw(ctx);
// ---- zone washes ----
for (const z of world.zones) {
ctx.fillStyle = z.type === 'sour' ? CFG.TINT_SOUR

107
src/solvent.js Normal file
View File

@ -0,0 +1,107 @@
import { CFG } from './config.js';
import { rand, random, rgba } from './util.js';
// ============================================================
// THE SOLVENT.
//
// The room used to be a vacuum with objects in it. Real matter at this
// scale is not sparse — it is crowded, and everything that happens
// happens THROUGH a medium. The field grid made the ground a medium;
// this makes the air one.
//
// These motes are deliberately NOT particles. They have no charge, no
// valence, no collisions, no bonds, and nothing can interact with them —
// they are advected by the Coulomb field the sim already computes, and
// that is all. That is what makes 600 of them affordable when 116 real
// atoms already cost ~5ms: no O(n^2) anything, one pass, one path, one
// fill per layer.
//
// They earn their place by carrying information the atoms cannot: you can
// SEE the field move before anything charged does, a detonation shockwave
// becomes visible as it crosses the room, and a dense pocket of matter is
// legible as a region where the medium is being dragged around.
// ============================================================
const MAX_SPEED = 130;
export class Solvent {
constructor() { this.motes = []; }
reset(bounds, n = CFG.SOLVENT_N) {
this.motes.length = 0;
for (let i = 0; i < n; i++) {
this.motes.push({
x: bounds.x + random() * bounds.w,
y: bounds.y + random() * bounds.h,
vx: 0, vy: 0,
// three depth bands: far motes are smaller, dimmer and lag the
// field, which reads as parallax without a second camera
z: rand(0.35, 1),
tw: random() * Math.PI * 2,
});
}
}
update(world, dt) {
const B = world.bounds;
// gather charged bodies ONCE — this is the only thing that costs, and
// it is linear in atoms, not in motes
const src = [];
for (const p of world.particles) if (p.charge) src.push(p);
if (world.molly.charge) src.push(world.molly);
const R2 = CFG.SOLVENT_REACH * CFG.SOLVENT_REACH;
const damp = Math.pow(0.04, dt);
for (const m of this.motes) {
let ax = 0, ay = 0;
for (const p of src) {
const dx = m.x - p.x, dy = m.y - p.y;
const d2 = dx * dx + dy * dy;
if (d2 > R2 || d2 < 4) continue;
const d = Math.sqrt(d2);
// 1/r falloff, not 1/r^2 — the motes are here to SHOW the field's
// shape at a distance, and an inverse-square drops off so fast that
// only the motes already touching an atom would ever move.
const f = (CFG.SOLVENT_PUSH * p.charge) / d;
ax += (dx / d) * f;
ay += (dy / d) * f;
}
m.vx = (m.vx + ax * dt) * damp;
m.vy = (m.vy + ay * dt) * damp;
const sp = Math.hypot(m.vx, m.vy);
if (sp > MAX_SPEED) { const k = MAX_SPEED / sp; m.vx *= k; m.vy *= k; }
m.x += m.vx * dt * m.z;
m.y += m.vy * dt * m.z;
m.tw += dt * 1.7;
// wrap, so the medium is continuous and never depletes at the edges
if (m.x < B.x) m.x += B.w; else if (m.x > B.x + B.w) m.x -= B.w;
if (m.y < B.y) m.y += B.h; else if (m.y > B.y + B.h) m.y -= B.h;
}
}
// Drawn UNDER everything, dim, in two batched passes. Never additive:
// 600 additive motes stack into a grey wash that eats the element hues
// and the radicals' white, which is the one thing the colour law will
// not tolerate.
draw(ctx) {
for (const [zmin, alpha, size] of [[0, 0.16, 0.9], [0.7, 0.30, 1.4]]) {
ctx.beginPath();
let any = false;
for (const m of this.motes) {
if (m.z < zmin || (zmin === 0 && m.z >= 0.7)) continue;
// a slow twinkle so the medium never looks like dead static
const r = size * (0.75 + Math.sin(m.tw) * 0.25);
ctx.moveTo(m.x + r, m.y);
ctx.arc(m.x, m.y, r, 0, Math.PI * 2);
any = true;
}
if (!any) continue;
ctx.fillStyle = rgba('#8FA8C8', alpha);
ctx.fill();
}
}
}

View File

@ -6,6 +6,7 @@ import { Bolts } from './bolts.js';
import { Juice } from './juice.js';
import { Radicals } from './radicals.js';
import { Overload } from './overload.js';
import { Solvent } from './solvent.js';
import { Particle, unbond } from './particle.js';
import { FX } from './fx.js';
import { Audio } from './audio.js';
@ -23,6 +24,7 @@ export class World {
this.bolts = new Bolts();
this.radicals = new Radicals();
this.overload = new Overload();
this.solvent = new Solvent();
this.exit = null;
this.exitOpen = false;
this.onSnap = null;
@ -55,6 +57,7 @@ export class World {
this.bolts.reset();
this.radicals.reset();
this.overload.reset();
this.solvent.reset(this.bounds);
for (const p of this.particles) { p.load = 0; p.fuse = 0; }
this.molly.revive();
this.kills = 0;
@ -189,6 +192,7 @@ export class World {
this.bolts.update(this, dt);
this.radicals.update(this, dt);
this.overload.update(this, dt);
this.solvent.update(this, dt);
// ---- radicals eat Molly ----
const m = this.molly;

138
test/geometry.test.mjs Normal file
View File

@ -0,0 +1,138 @@
import { makeWorld, sim, tick, check, section, report, CFG } from './harness.mjs';
const { bond } = await import('../src/particle.js');
// ============================================================
// VSEPR — molecules have real shapes.
//
// Bond springs only fix bond LENGTH, so before this a molecule was atoms at
// whatever angles the storm left them: three circles near each other, never
// a thing. These tests guard the silhouettes that make it read as chemistry
// — water bent, CO2 a bar, methane a caltrop, benzene a hexagon.
// ============================================================
const deg = (c, a, b) => {
const ax = a.x - c.x, ay = a.y - c.y, bx = b.x - c.x, by = b.y - c.y;
return Math.abs(Math.atan2(ax * by - ay * bx, ax * bx + ay * by)) * 180 / Math.PI;
};
// Build a centre with n arms, started OFF the ideal so we prove the springs
// do the work — but never at exactly 180 for a 2-arm case, which is a
// degenerate equilibrium (the cross product vanishes and it cannot pick a
// side to fold toward).
function molecule(centreEl, armEls, seed = 42) {
const w = makeWorld(seed);
w.load({ id: 'lab', molly: { x: -9999, y: -9999 }, particles: [] });
const c = w.spawn({ x: 640, y: 360, el: centreEl });
const arms = armEls.map((el, i) => {
const a = 0.6 + (i / armEls.length) * Math.PI * 1.4; // deliberately wrong
return w.spawn({ x: 640 + Math.cos(a) * 30, y: 360 + Math.sin(a) * 30, el });
});
arms.forEach((a) => bond(c, a));
return { w, c, arms };
}
// mean + spread of the ADJACENT bond angles over a stretch of live sim
function measure(w, c, arms, seconds = 3) {
sim(w, 4);
const xs = [];
const n = Math.round(seconds / CFG.DT);
for (let i = 0; i < n; i++) {
tick(w);
const bs = arms.map((b) => ({ b, a: Math.atan2(b.y - c.y, b.x - c.x) }))
.sort((p, q) => p.a - q.a);
const pairs = arms.length === 2 ? 1 : arms.length;
for (let j = 0; j < pairs; j++) xs.push(deg(c, bs[j].b, bs[(j + 1) % arms.length].b));
}
const mean = xs.reduce((a, b) => a + b, 0) / xs.length;
const sd = Math.sqrt(xs.reduce((a, b) => a + (b - mean) ** 2, 0) / xs.length);
return { mean, sd };
}
section('VSEPR — the famous shapes');
{
const cases = [
{ name: 'water is BENT', c: 'O', arms: ['H', 'H'], want: 104.5, tol: 8 },
{ name: 'CO2 is a straight bar', c: 'C', arms: ['O', 'O'], want: 180, tol: 12 },
{ name: 'ammonia', c: 'N', arms: ['H', 'H'], want: 107, tol: 8 },
{ name: 'H2S is tighter than water', c: 'S', arms: ['H', 'H'], want: 92, tol: 10 },
{ name: 'methane is a caltrop', c: 'C', arms: ['H', 'H', 'H', 'H'], want: 90, tol: 6 },
{ name: 'trigonal splays to 120', c: 'C', arms: ['H', 'H', 'H'], want: 120, tol: 6 },
];
for (const t of cases) {
const { w, c, arms } = molecule(t.c, t.arms);
const { mean } = measure(w, c, arms);
check(t.name, Math.abs(mean - t.want) < t.tol,
`${mean.toFixed(1)}deg (want ${t.want} +-${t.tol})`);
}
}
section('VSEPR — water and CO2 must not look the same');
{
// the whole point: two bonds on carbon reads as a BAR, two on oxygen as a
// BEND. If these ever converge the geometry is decorative.
const a = molecule('O', ['H', 'H']);
const b = molecule('C', ['O', 'O']);
const wa = measure(a.w, a.c, a.arms).mean;
const wb = measure(b.w, b.c, b.arms).mean;
check('bent and linear are far apart', wb - wa > 50,
`water ${wa.toFixed(0)}deg vs CO2 ${wb.toFixed(0)}deg`);
}
section('VSEPR — geometry HOLDS, it does not flap');
{
// Undamped, the mean angle came out right while water swung through a 61
// degree range — a broken hinge, not a molecule. ANGLE_DAMP fixed that and
// this is the regression guard.
const { w, c, arms } = molecule('O', ['H', 'H']);
const { sd } = measure(w, c, arms, 5);
check('water holds a steady bend under the storm', sd < 8, `sd=${sd.toFixed(1)}deg`);
}
section('VSEPR — geometry stays SOFT');
{
// It must lose to a blast, or molecules read as rigid props. A detonation
// beside a molecule should visibly deform it.
const { w, c, arms } = molecule('C', ['H', 'H', 'H', 'H']);
sim(w, 5);
const before = deg(c, arms[0], arms[1]);
const bomb = w.spawn({ x: c.x + 34, y: c.y, el: 'C' });
w.overload.detonate(w, bomb);
tick(w);
const after = deg(c, arms[0], arms[1]);
check('a blast deforms the molecule', Math.abs(after - before) > 1,
`${before.toFixed(0)}deg -> ${after.toFixed(0)}deg`);
}
section('SOLVENT — a medium, not more particles');
{
const w = makeWorld(7);
w.load({ id: 'lab', molly: { x: 640, y: 360 }, particles: [] });
check('motes are spawned', w.solvent.motes.length === CFG.SOLVENT_N,
`${w.solvent.motes.length}`);
check('motes are NOT particles (nothing can interact with them)',
w.particles.length === 0, `${w.particles.length} particles`);
// a charged atom must visibly move the medium around it
const p = w.spawn({ x: 640, y: 360, el: 'O' });
p.charge = 1;
const near = w.solvent.motes
.map((m) => ({ m, d: Math.hypot(m.x - 640, m.y - 360) }))
.filter((o) => o.d < 120 && o.d > 20)
.sort((a, b) => a.d - b.d)[0];
if (near) {
const d0 = near.d;
sim(w, 1.5);
const d1 = Math.hypot(near.m.x - p.x, near.m.y - p.y);
check('a charge pushes the medium away from it', d1 > d0,
`${d0.toFixed(0)}px -> ${d1.toFixed(0)}px`);
} else check('a charge pushes the medium away from it', false, 'no mote in range');
// and the medium must never leak out of the room
sim(w, 4);
const B = w.bounds;
const escaped = w.solvent.motes.filter(
(m) => m.x < B.x - 1 || m.x > B.x + B.w + 1 || m.y < B.y - 1 || m.y > B.y + B.h + 1).length;
check('motes wrap and never leak out of bounds', escaped === 0, `${escaped} escaped`);
}
process.exit(report() ? 0 : 1);