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>
20 KiB
Molly Cool — Vertical Slice Build Plan
For the executing agent (Opus 4.8). Read this whole file before writing code.
You are building a playable 2D browser prototype of the first ten minutes of Molly Cool. This is a feel-first vertical slice: the entire point is to prove that the core mechanics are fun to hold a controller for. If the moment-to-moment feel isn't satisfying, nothing else matters. Prioritize game feel over completeness at every fork.
This is not a science app. There is no real chemistry validation, no accuracy requirement, no molecule database, no pedagogy. "Protons," "charge," "bonds," "acidity" are flavor for mechanics — nothing more. Do not add educational text, quizzes, or explanations. The game teaches entirely through play, with zero words on screen.
1. The design in one screen
Molly is a nanoscale character in a permanently shaking fluid world. She has two hands:
- Give — push a proton into a thing → it goes positive (hot pink).
- Take — rip a proton out → it goes negative (cyan).
- Leave it alone → neutral (dim gray-white), and it drifts free.
From those two verbs everything follows:
- Opposites attract, likes repel. Charged things lunge at their opposite and stick.
- Bonds aren't built with a button — you drag two atoms together, wrestle them through the resistance, and let go. They snap. Big juice.
- The membrane wall blocks anything charged and ignores anything neutral. No guard, no switch — you get through by becoming neutral.
- Zones (sour / soapy / neutral) re-tune your hands: a sour zone keeps shoving things positive, so neutral won't stay neutral; soapy drains charge away.
- Heat shakes everything in a radius (crowbar). Light flips one thing (scalpel). Some setups react to them in opposite directions.
The heist: you're too small to open doors, so you change what things are. Strip a thing neutral → it drifts through the wall → the sour room on the far side charges it back up → now it's trapped. You never opened the vault. You made the loot un-leavable.
2. Non-negotiable pillars
- The snap must feel incredible. The bond hold-and-release (§8) is the heartbeat of the game. Spend disproportionate effort here. If you only get one thing feeling perfect, make it this.
- Zero on-screen text during play. No tutorials, no labels, no tooltips. Every lesson is a thing that happens to the player. (A single title card at the start and an end card are fine.)
- Two buttons carry the whole game. Give + Take, plus Grab, plus two openers (Heat, Light). Resist adding mechanics. Depth comes from combining these, not from more verbs.
- The world is always alive. Everything jitters constantly (Brownian shimmer). Nothing sits perfectly still, including Molly at rest. No coasting — there's drag on everything.
- Readable at a glance. Charge state is legible instantly by both color AND shape (see §6), so it reads even for colorblind players and even in motion.
3. Explicit non-goals (do not build these)
- No 3D, no WebGL, no molecular rendering libraries, no Babylon/Three.
- No real chemistry engine, no force fields, no molecule data files, no PubChem.
- No accuracy claims, no correct bond angles, no periodic table. Charge is an integer; that's the whole model.
- No build framework (no React, no bundler required). No npm dependencies. See §4.
- No menus beyond a start screen, no settings, no save system, no networking, no analytics.
- No art assets, no audio files. Everything is drawn with Canvas primitives and synthesized with WebAudio.
4. Tech stack
- Vanilla JavaScript, ES modules. No dependencies, no bundler, no build step.
- HTML5 Canvas 2D for all rendering.
- WebAudio API for all sound (synthesized at runtime — no files).
- Gamepad API for controller + haptic rumble (the hold-resistance tell).
- Runs by opening
index.htmlthrough any static file server (e.g.python3 -m http.server). Must work in current Chrome/Edge/Safari. Must hold 60 fps on modest hardware.
Logical resolution 1280×720, letterboxed/scaled to fit the window (integer or fractional scale, preserve aspect). All coordinates in logical pixels.
5. Project structure (suggested — adapt if you have a better idea)
index.html # canvas + module entry, title card, start button
src/
main.js # boot, game loop, state machine (title → play → end)
config.js # ALL tunable feel constants live here. One file.
input.js # keyboard + mouse + gamepad → unified InputState, rumble
camera.js # follow + screenshake (trauma model) + world↔screen
audio.js # WebAudio synth: pop, hum, snap, ambience, bounce
world.js # holds particles, membranes, zones; steps the sim
particle.js # Particle entity + charge/jitter/bond state
molly.js # player: movement, hands, grab, self-charge
physics.js # forces: jitter, charge interaction, zones, membrane, bonds
bonds.js # the hold-and-release mechanic + bond constraints
render.js # draw world, particles, glow/shape by charge, effects
fx.js # particles/sparks, hitstop, flashes, trauma helpers
rooms.js # room definitions (data) + RoomManager
rooms.data.js # the ten scripted beats as data
Keep config.js authoritative for every feel number. Feel is tuned by editing that one file; don't scatter magic numbers through the code.
6. Core data model
Particle (a mote / atom):
{
pos: {x, y}, vel: {x, y},
charge: -1 | 0 | +1, // slice uses three states; allow ±2 later
displayCharge: float, // lerps toward `charge` for smooth color/shape
radius: ~10,
slots: 1..4, // "valence" dots drawn around it (visual only in slice)
bonds: [particleRef, ...], // spring-linked partners
zoneTimer: float, // accumulates while in a re-tuning zone
grabbedBy: null | 'L' | 'R', // which hand is holding it
fixed: bool, // scenery anchors (raft targets, etc.)
tag: string // e.g. 'key', 'debris', for room scripting
}
Molly (player):
{ pos, vel, charge: -1|0|+1, displayCharge, aim:{x,y}, radius:~14,
handL: {holding: particleRef|null}, handR: {holding: particleRef|null} }
Membrane: a line segment {a:{x,y}, b:{x,y}, thickness} that repels charged things, ignores neutral ones.
Zone: {shape:'rect'|'circle', ...bounds, type:'sour'|'soapy'|'neutral', tint}.
Room: { id, molly:{x,y}, particles:[...], membranes:[...], zones:[...], exit:{rect}, ambient:{hum, tint}, onEnter?:fn, onExit?:fn }.
Charge → appearance (both color and shape, always redundant):
| Charge | Color | Shape signature | Halo |
|---|---|---|---|
| +1 positive | hot pink #FF2D9B |
spiky outline (outward points) | tight bright rim |
| 0 neutral | dim gray-white #C8D0D8 |
smooth circle, flat | none, slightly translucent |
| −1 negative | cyan #2DB8FF |
smooth circle, soft pulsing halo | wide gentle glow |
Positive = aggressive/spiky, negative = smooth/glowing, neutral = quiet/flat. This makes charge readable without color, and just reads better in motion. displayCharge lerps over ~150 ms so transitions feel physical, not instant.
7. The simulation (physics.js) — update order per frame
Run at a fixed timestep (e.g. accumulate real time, step at dt = 1/120 s, render interpolated). Order each step:
- Jitter (the storm). Every non-grabbed particle gets a random impulse:
vel += randUnit() * JITTER_ACCEL * dt. Molly gets a much smaller idle jitter so she never sits dead still. TuneJITTER_ACCEL(start ~400 px/s²) so the world shimmers — alive, not chaotic. - Charge interaction. For each pair of charged particles within
CHARGE_RANGE(~180 px): Coulomb-ish forceF = K_CHARGE * q1 * q2 / max(d, D_MIN)², attractive for opposite signs, repulsive for like. Cap magnitude to avoid explosions. Skip neutral–neutral pairs (cheap). StartK_CHARGEso two opposites within ~120 px visibly lunge together over ~0.3 s. - Zone re-tuning. For each particle inside a zone:
- sour: if
charge < +1, accruezoneTimer += dt; when it exceedsZONE_FLIP_TIME(~1.2 s), bumpcharge += 1and reset. (So neutral won't stay neutral here — the sour air keeps shoving it positive. This IS the "your take hand has to fight the current" feel.) - soapy: if
charge != 0, accrue timer; on threshold movechargeone step toward 0. - neutral zone / outside: decay
zoneTimertoward 0. - Reset a particle's
zoneTimerwhenever the player just acted on it, so player action feels responsive and the zone "reclaims" it afterward.
- sour: if
- Membrane repulsion. For each charged particle (and Molly if charged) near a membrane within
thickness: apply a strong force along the segment normal, pushing it back to its current side. Neutral particles get zero force — they pass freely. Tune so charged things clearly bounce and cannot muscle through; neutral things ignore it entirely. - Bond springs. For each bonded pair: spring toward
BOND_LENGTH(~24 px), stiff, damped, so molecules hold shape but wobble slightly in the storm. - Grab springs. Grabbed particles are pulled toward their hand anchor (§8).
- Integrate.
vel *= damping(drag — start so speed halves ~every 0.1 s at rest),pos += vel * dt. Clamp to world bounds.
Keep it cheap: a few dozen particles per room max. No spatial hashing needed at this scale.
8. THE SIGNATURE MECHANIC: bond hold-and-release (bonds.js)
This is the game. Get it feeling physical and earned. The challenge is the wrestle, not the click.
Grab. Hold the Grab input near a particle → it tethers to a hand anchor that tracks the aim reticle via a stiff spring. Molly can carry it. Grab again → second particle on the other hand (hold up to two). The two anchors sit slightly apart so the player positions each.
The activation barrier (this is what makes it fun). When two grabbed opposite-charge particles approach:
- Beyond ~60 px: mild attraction, easy.
- Between ~25–45 px: a repulsive bump they must be shoved through — plus the constant jitter knocking them off line. This is the resistance. The player has to force them together and hold against the push-back.
- Below
BOND_LOCK_DIST(~22 px): they snap — bond forms, both auto-release from the hands, and the snap event fires (§9).
Feedback while wrestling:
- Rumble ramps with effort — low as they approach, peaking at the barrier. (Gamepad haptics; on keyboard, substitute a rising audio hum + a subtle screen tremor.)
- A rising hum (audio.js) climbs in pitch/gain as they near the barrier.
- The particles visibly shove back against the player's drag at the barrier.
Release / fail. Let go of Grab while still fighting → the particles fly apart on the jitter, no bond, no punishment. Just try again. Failure is instant and cheap.
The payoff — snap event:
- Hitstop ~50 ms (freeze the sim, keep rendering).
- Screenshake: add ~0.6 trauma (§9).
- Bass thunk + noise burst (audio.js).
- Spark burst at the bond point, colored by the two charges.
- Molly knockback: shove her velocity away from the bond point.
- The new bond glows at the seam for ~0.5 s.
Tune the whole sequence until you'd do it fifty times just because it feels good. That's the bar.
9. Juice spec (fx.js, camera.js)
Feel lives here. Starting values — tune freely.
- Screenshake (trauma model, à la "The Art of Screenshake"). Keep a
traumafloat 0..1. Offset =MAX_SHAKE * trauma² * noise(), plus small rotationMAX_ROT * trauma² * noise().MAX_SHAKE~20 px,MAX_ROT~2°. Decaytrauma -= 1.5 * dt. Snap adds ~0.6; membrane bounce ~0.15; charge pop ~0.05. - Hitstop. On big events set
freeze = 0.05 s; during freezedt = 0for the sim but rendering + effects continue. Sells impact. - Particles/sparks. Cheap circles/lines with velocity + fade. Bursts on snap, charge change, membrane bounce, heat pulse.
- Charge transitions.
displayChargelerps ~150 ms; color and shape morph with it. A quick bright flash on the moment of change. - Trails. Fast-moving motes leave a short fading trail (a few ghost positions or additive alpha). Sells speed and the fluid.
- Ambient bloom. Charged particles have an additive glow. Use
globalCompositeOperation = 'lighter'for glows/sparks over a dark ground.
Audio (all synthesized, no files):
- Charge pop: short sine blip; pitch up for Give, down for Take.
- Hold hum: oscillator whose gain + pitch ramp with bond proximity; kill on release/snap.
- Snap: sine sweeping 80→50 Hz over ~120 ms + a filtered white-noise burst + short decay tail. The money sound — make it satisfying.
- Membrane bounce: dull filtered thud.
- Zone ambience: low drone / filtered noise, timbre tinted per zone; crossfade on zone change.
10. Zones & membrane recap (behavior already specced in §7)
- Zones apply a subtle full-screen tint (sour = yellow-green, soapy = blue-violet, neutral = none) and swap the ambient hum. The gameplay effect is the recharge/discharge timer in §7.3. Keep the visual subtle — a wash, not a filter that hurts readability.
- Membrane renders as a shimmering oily band. When a charged thing bounces, ripple + bounce thud. When a neutral thing passes, a soft "phase-through" shimmer. Molly passes only while neutral — desaturate the screen and drop the hum while she's neutral to sell "gone quiet."
11. Controls
Keyboard + mouse (must be fully playable):
- Move: WASD / arrows. Aim: mouse reticle.
- Give (right hand): Left Mouse. Take (left hand): Right Mouse.
- Grab (hold): Space (or hold Left Mouse variant — pick what feels best).
- Heat: Q. Light: E.
- Self-charge: if the reticle isn't over a particle, Give/Take act on Molly herself (this is how she strips neutral to cross the wall). Clean and discoverable.
Gamepad (support + rumble):
- Move: left stick. Aim: right stick (reticle offset from Molly).
- Give: RT. Take: LT. Grab: A/× (hold). Heat: LB. Light: RB.
- Rumble ramps during the bond wrestle (the core use of haptics).
Unify both into a single InputState each frame so game code never checks device type.
12. Heat & Light (openers)
- Heat: area impulse at the reticle, radius ~150 px — kicks all particles outward + spikes their jitter briefly; can break a bond if it stretches past
BOND_BREAK_DIST. Loud, indiscriminate. Big trauma, big noise. - Light: single target under the reticle — flips its "mood." In the slice, Light toggles a target's charge sign (or triggers a scripted state on a special object). Silent, precise, one thing.
- The opposite-directions hook: include at least one scripted "lock" object that moves its state one way under Heat and the other way under Light. It can be a stretch beat (Milestone G) — wire the hook even if only one puzzle uses it.
13. The scripted sequence (rooms.data.js)
Ten beats. Each is a small hand-authored room (data). Camera follows Molly; reaching a room's exit rect loads the next. Some beats can merge if it plays better — use judgment. No text in any of them.
| # | Room | What the player learns | How it's taught (no words) |
|---|---|---|---|
| 0 | Wake | Give/Take = push/pull | Dark, one drifting mote. Poking it charges it and it pushes away / pulls back. Fade in from black. |
| 1 | First toy | Opposites grab | A cluster; charge one +, one −, they lunge together and stick. A gap to reach behind them. |
| 2 | The raft | Bonds = hold-and-release | Two raft-halves drift apart, jittering. Wrestle them together → SNAP → ride the raft across a gap. |
| 3 | The wall | The membrane reads charge | Throw charged debris → it bounces, no reaction. Drift a neutral bit → it passes through. |
| 4 | Go quiet | Neutral = sneak | Strip Molly neutral (Take on herself) → screen desaturates → drift through the membrane. |
| 5 | Sour | Zones re-tune your hands | World tints yellow-green. A mote you neutralize keeps snapping back to +. |
| 6 | The vault | (setup) | A second membrane with the exit behind it, and a "key" object. Sour air present. |
| 7 | The heist | Combine everything | Key is charged → bounces. Strip it neutral → it drifts through → sour air on the far side charges it → trapped. Exit opens. |
| 8 | Scale | (tease) | Zoom/parallax reveal: this was one droplet; thousands more; something huge pulsing far off. Fade to end card. |
Give each room optional onEnter/onExit hooks for the few scripted moments (Room 0 fade-in, Room 8 reveal). Don't build a scripting engine — just callbacks.
14. Build order — milestones (each independently runnable & testable)
Build in this order. After each milestone, stop and verify the stated test before moving on. Do not build later milestones until the current one feels right.
-
A — Grey box & life. Canvas, fixed-timestep loop, letterbox scaling. Molly moves with drag + idle jitter; camera follows. A dozen particles drift with the storm jitter. No charge yet. Test: does moving around and watching the shimmer feel alive and fluid, not floaty or dead?
-
B — Give/Take + charge + opposites-grab. The core toy. Reticle targeting, Give/Take change charge, color+shape by charge (§6), charge interaction force (§7.2), charge pop audio. Test (the kill-test): poke a mote and it charges; make two opposites lunge together and stick. Is just this fun to mess with for a minute?
-
C — Bond hold-and-release + full snap juice. Grab/anchor springs, the activation barrier, rumble/hum ramp, hitstop, screenshake, sparks, knockback, snap audio (§8, §9). Spend the most time here. Test: would you trigger the snap fifty times for fun? If not, keep tuning before proceeding.
-
D — Membrane. Charged repulsion, neutral pass-through, Molly self-charge to cross, bounce/phase FX. Test: charged things bounce, neutral things pass, and going neutral to sneak through feels sneaky.
-
E — Zones. Sour/soapy/neutral recharge fields, tint wash, ambient hum crossfade. Test: in a sour zone a neutralized mote visibly refuses to stay neutral.
-
F — Rooms & the sequence. RoomManager,
rooms.data.js, exit triggers, room transitions, wire beats 0–7. The heist must be solvable using only mechanics the earlier rooms taught. Test: play start→heist with no instructions and no text. Does the heist click as "my own idea"? -
G — Heat & Light + the opposite-directions lock. The two openers and at least one puzzle using the heat-vs-light hook. Test: heat feels like a crowbar, light like a scalpel; the opposite-reaction lock reads.
-
H — Bookends & polish. Title card + start, Room 8 scale reveal, end card, audio mix pass, one full feel-tuning pass across
config.js. Test: the whole ten minutes plays clean, start to finish, 60 fps.
If time is short, A–F is the real slice (it contains the whole thesis). G and H are enhancement.
15. Definition of done
- All beats 0–7 are playable start to finish, with zero text on screen during play.
- The bond snap feels genuinely good (the fifty-times bar).
- Charge state is readable instantly by color and shape, even in motion.
- Keyboard+mouse fully playable; gamepad supported with rumble during the wrestle.
- Holds 60 fps in Chrome on modest hardware.
- All feel constants live in
config.jsand can be tuned without touching logic. - Runs from a plain static server with no build step and no dependencies.
16. Tuning notes for whoever plays it next
The feel lives in config.js. The three knobs that matter most, in order:
JITTER_ACCEL— the life of the world. Too low = dead; too high = unplayable soup.- The bond barrier (
BOND_ATTEMPT_RANGE, barrier strength,BOND_LOCK_DIST) — the wrestle. This is the whole game's feel in three numbers. - Snap juice (
MAX_SHAKE, hitstop duration, snap audio envelope) — the payoff.
Tune those first, by feel, before touching anything else.
This is a prototype to answer one question: is the core loop fun? Build the smallest thing that answers it honestly. Everything else waits.