Gummy Bears: How the argon POC Was Built
Gummy Bears: How the argon POC Was Built
Section titled “Gummy Bears: How the argon POC Was Built”~/internal/poc/argon is a proof of concept that started as a dare and ended as
a 4 119-line TypeScript project with a hand-written soft-body solver, 64 green
tests, and eleven rounds of vision-model critique. It renders translucent gummy
bears that you can grab, stretch, flick and pour, at 60 fps on a laptop CPU
before the GPU draws anything.
The write-up below used to stop at the founding night of 15 Oct 2026. That was the half where an agent built something; the interesting half happened afterwards, when the brief became a spec and the spec found three bugs the eye had missed. See After the Founding Night.
The live demo is at Gummy Bears POC. The source lives in a private repository on the owner’s Forgejo; the public artefacts are this page and the embedded build.
The Fact Sheet
Section titled “The Fact Sheet”| Repo | ~/internal/poc/argon, private Forgejo repository |
| Built | 14 Oct 2026 19:37 to 15 Oct 2026 05:50, in one continuous agent session; work continued through 22 Oct |
| Stack | Vite 8, TypeScript 7 strict, vitest 5, three@0.186, pnpm |
| Size | 4 119 lines of TypeScript, 392 lines of CSS, 1 919 lines of tests |
| Physics | Custom XPBD-style shape matching. No physics middleware |
| Tests | 64 green across 4 files, plain node, no canvas, no three (except the skinning guard) |
| Commits | 38, one agent per session, MR-only history |
| Cost | 1.05 ms/frame for one bear, 5.3 ms for a bag of four, 15.4 ms at the 12-bear cap |
| Bundle | JS 588 KB (gzip 151 KB), CSS 4.8 KB (gzip 1.7 KB), no CDN, no web fonts, no runtime fetches |
| Single file | pnpm build:single produces one 581 kB .html with three.js inlined |
| Spec conformance | 19 functional requirements: 17 met, 2 deviated, zero open |
| Knowledge ledger | 22 invariants in the repo’s AGENTS.md, each one bought by a bug |
Every number in this table was re-measured on the upstream main at 97c0558 on
2026-10-28 (pnpm test -> 64 passed, 4 files; find src -name '*.ts' | xargs wc -l
-> 4 119; wc -l src/ui.css -> 392; git rev-list --count HEAD -> 38). It replaces
the 15 Oct figures, which described the founding-night state.
Where It Came From
Section titled “Where It Came From”The seed was a screen recording (ref.mp4) of two competing gummy-bear demos
stacked in one vertical page. The agent had no eyes, so the video was cut into
frames, the frames were OCR’d, and a local vision model described each one. The
two panels were labelled by price:
| Panel | Label the model read off the frame | Look |
|---|---|---|
| Top | “Opus 5.5”, $7.2 | Smooth, translucent, glossy bears with soft coloured contact shadows |
| Bottom | “Gemini Argon 4”, $22 | Low-poly, faceted, matte bears, correct shadows, weak anatomy |
The point of the POC: reproduce the better panel from scratch, with no paid
model in the loop and no physics engine dependency, and see how far a
local-model agent gets on its own. The repo was named argon after the weaker
panel, as a running joke about price not being quality.
Research Before Code
Section titled “Research Before Code”Before the first solver line was written, the agent pulled the literature and the open-source implementations it could find, into a scratch research folder:
| Source | What it gave the design |
|---|---|
| Müller et al., A Robust Method to Extract the Rotational Part of Deformations | The one-sided Jacobi polar decomposition used in sim/math.ts |
| Macklin & Müller, A Constraint-based Formulation of Stable Neo-Hookean Materials | The plasticity model: deviate the target, normalise by volume |
| EG2015 PBD: Position-Based Simulation of Compliant Constrained Dynamics | Substepping and the per-frame-to-per-substep damping rule |
| XPBD (10-softBodies, 12-softBodySkinning, 14-cloth, 15-selfCollision) | Compliance, skinning, contact handling |
| Meshless Deformations Based on Shape Matching (SIGGRAPH 2005) | Cluster shape matching: particles plus clusters, no tetrahedra |
Flex engine sources (flex_softbody.h, flex_ext_soft.cpp) |
Cluster/partition layout, bearPartAt regions |
Rapier’s Rust elastic and plasticity modules, jelly3.c, plastic3.c |
Comparison points for stiffness and plastic creep |
bench.js, cmu.pdf, diglib.pdf |
Frame budget sanity checks |
The rule that came out of it: do not invent a constitutive model, pick one that is already known to be stable and implement it minimally.
The One-Shape-Definition Architecture
Section titled “The One-Shape-Definition Architecture”Everything in the project hangs off a single decision. The bear is defined
once, as a smooth-union signed distance field in src/geo/sdf.ts. That one
definition produces:
sdf.ts -> mesher.ts (dual contouring: the visible surface) -> bear.ts (particles: the simulated volume) -> bear.ts (clusters: the limb groups for shape matching) -> bear.ts (skin weights: the particle-to-vertex bridge)Every other geometry file is downstream. Duplicating the shape anywhere else desynchronises the skin from the physics, which is the first bug the project ever had.
| Path | Owns | May import |
|---|---|---|
src/geo/sdf.ts |
Bear shape, part regions, skeleton, bounds | nothing |
src/geo/mesher.ts |
Dual contouring, hand-written, 1 vertex per cell, Newton surface projection | sdf.ts |
src/sim/math.ts |
Mat3, one-sided-Jacobi polar decomposition, det3, mulberry32 |
nothing |
src/sim/body.ts |
Cluster shape matching, plastic target, volume projection | math.ts |
src/sim/world.ts |
Substepping, grab and drop, floor plus hash-grid contacts, damping | body.ts |
src/sim/bear.ts |
Bear factory: SDF to particles, clusters, cached mesh, skin weights | geo/*, sim/* |
src/sim/pour.ts |
Pour lattice, pure function, the “never spawn inside another bear” contract | nothing |
src/view/skin.ts |
Per-frame CPU skinning, delta-field fairing, face-accumulated normals | three, types |
src/view/stage.ts |
Renderer, candy material, lights, coloured shadows, picking, camera | three |
src/app.ts |
Loop, pointer, DOM controls, parameter mapping | everything |
src/sim and src/geo are DOM-free and three-free by contract. That is what
makes 64 physics tests runnable in plain node in fourteen seconds, with no canvas.
Nothing in src/view or src/app reaches into solver internals; they only see
the World API.
The Headless Verification Loop
Section titled “The Headless Verification Loop”The agent that built this has no eyes. It cannot tell whether a bear looks like a bear. So the project grew a perception rig, and that rig is the real story of how the POC was created.
pnpm build && pnpm preview --port 4178 --strictPort &export PLAYWRIGHT_CLI_SESSION=argonplaywright-cli --s=argon open "http://127.0.0.1:4178/?bears=4" --browser chromium# wait on rendered frames, never on a sleepplaywright-cli --s=argon run-code "async (page) => page.waitForFunction( () => window.gummy && window.gummy.frames() > 150, null, { timeout: 300000 })"playwright-cli --s=argon screenshot --filename=/tmp/opencode/argon/shot.pngpython3 "$SATWARE_HARNESS/skills/computervision/scripts/vision-analyze.py" \ /tmp/opencode/argon/shot.png --prompt-file critique.txtThree instruments, each compensating for a failure of the previous one:
| Instrument | What it does | Why it exists |
|---|---|---|
window.gummy |
Exposes world, stage, pour, reset, params, frames() |
Proves frames actually rendered, instead of trusting a sleep |
vision-analyze.py |
Local vision model answers a fixed critique prompt | Finds what to look at |
candy-probe.py |
Measures candy pixel share, mean candy luminance, dark-core share, saturation, shadow share | Decides whether a change helped |
The scratch directory holds 76 screenshots, 11 critique transcripts, and the
mutant copies of world.ts used for A/B measurement (world.base.ts,
world.cushion-good.ts, world.pre-stick.ts, world.stick.ts, world.final.ts).
Eleven Rounds of Being Told It Looked Bad
Section titled “Eleven Rounds of Being Told It Looked Bad”The critique prompt asked the same seven questions every round: anatomy, material, shadows, geometry artefacts, UI, composition, and a 1-10 score against “a premium commercial candy demo”. Scores from the transcripts:
| Round | Verdict | Score | What it forced |
|---|---|---|---|
| 1 | “elongated, melted blobs of taffy”, “crumpled foil” | 3/10 | Surface fairing, grounding |
| 2 | Base mesh 9/10, physics layer 2/10 | 9 / 2 | Isolated the fault: the solver, not the mesher |
| 3 | Still melting | 2 / 6 | Stiffness and plasticity model |
| 4 | Better form, shadows wrong | 3 / 6 | Shadow pass rewrite |
| 5 | “floating in mid-air”, “long streaky artifacts” | 3/10 | Light elevation, shadow attachment |
| 6 | Panel B reads as sugar gel | 6 / 8 | Material direction confirmed |
| 8 | Physics and shadow artefacts ruin it | 4/10 | Contact cushion |
| 9 | Panel C “a heap of colorful slime” | 3 / 4 | Bear-to-bear separation |
| 11 | “10-12 bears distinguishable”, shadows “under” | 6 / 5 | Converged, residual claims refuted by measurement |
Round 10 never produced a verdict: the call died on a missing prompt file
(FileNotFoundError: critique-10.txt). A critique round is only as good as its
prompt file, and a crashed call looks exactly like a missing feature if you only
grep the output for scores.
Two calibration facts came out of this loop and are now written into the repo’s AGENTS.md:
- The vision model’s absolute score swings about 3 points between near-identical images. It called the same content 7/10 and 4/10 in consecutive rounds. Use it to find what to look at, never to decide whether a change helped.
- At 1280x720 a bear is roughly 60 pixels in a full-frame shot. The model kept reporting “long detached streaks / motion trails” on shadows that a projection probe placed 1-2 % of the frame in x and 4-9 % below the bear that cast them. Grounding claims are settled by centroid offsets, not by the model.
Fifteen Invariants, Each One Cost a Bug
Section titled “Fifteen Invariants, Each One Cost a Bug”This is the part the POC is actually for. Every rule below is the scar of a specific failure, and each is guarded by a test or a measurement. The list here is the founding-night set; the upstream ledger now carries 22, and the entries the post-spec work added are named in After the Founding Night.
- One shape definition. Change the bear in
sdf.tsonly. Mesh, particles, clusters and weights all derive from it. - Skinning is a delta blend in one coordinate system.
vertex = worldRestSurface + Σ w·(pos - rest). Blending a bear-local surface against world deltas parks every vertex below the floor, and the floor clamp then renders a pancake. - Position-based fixes become velocity through
(x - prev)/h. Ath = 1/480a 10 mm correction is 4.8 m/s. Three defences, all required: the pour lattice never spawns interpenetrating bears,SEPARATION_SPEEDbudgets the shift per particle per substep, andclampContactEnergycaps each contact’s separation at what its own approach earned. - Parameters are clamped in one place (
clampParamsinworld.ts), used by both the constructor andWorld.configure. A slider that bypasses it pushes the solver out of its stable regime. - Stiffness and restitution are authored per frame, softened per substep as
1 - (1-k)^(1/substeps). Raw per-substep values compound toe^substeps. - Plasticity is a constitutive model, not a hack: target
= R·P·q0 + base,Pnormalised by1/cbrt(det P)and capped at‖P-I‖_F <= 0.6. Volume is handled only by its own projection. Folding a trace dilation into the shape target is not volume-conserving and the body never recovers. - Shape metrics in tests must be rotation-invariant. A free bear tumbles, so
an AABB aspect is meaningless; use principal extents from covariance
eigenvalues (
eigSym3). - Collisions run after shape matching, and the skinned surface is clamped at
y = max(y, groundY): particle centres rest one radius up, so the interpolated skin would otherwise sink through the table. - Fairing happens in delta space. The solver is grainy and a sparse blend renders that grain as crumpled foil, so the surface is faired with Taubin lambda/mu Laplacian pairs. Fairing positions ironed out 5.7 mm of the untouched rest surface, a third of an ear. Fairing the displacement field is a no-op when nothing is deformed. Never replace it with a plain Laplacian: that shrinks the candy to a marble.
- Coloured shadows use plain alpha blending, and are depth-tested. The
tempting multiply trick (
blendDst: OneMinusSrcColorFactor) is an exact no-op wherever the floor is white, so a whole shadow pass can render, be counted correctly, and still be invisible. The opposite trap isdepthTest: false: measured 10 % of the frame was shadow laid over candy that should have hidden it, and the picture reads as “muddy, floating, streaky”. - Beer-Lambert needs
attenuationDistance>= about 1.2xthickness. Absorption isexp(-thickness/distance): at thickness 0.75 with distance 0.09 the core of a 0.28 m bear is extinguished to 0.0002 and every candy grows a muddy brown-grey centre. Shipped: thickness 0.30, distances 0.19-0.30. Raisingtransmissionis not the lever for a glowing interior: 0.76 to 0.84 darkened the close bear (luminance 119 to 111, dark core 5.4 % to 11.9 %), because what transmission shows through a bear is its own shaded far side. - Neighbours settle on a skin cushion, applied gently. Pair contacts resolve
to
PAIR_REACH(1.25) times the touching distance, because the skinned surface reaches 30-75 mm past the nearest particle and bare touching left bears visibly fused. The extra push runs atCUSHION_GAIN(0.1): a full-strength cushion separated a stress pair by 1.23 m where the bound is 1.0 (0.56 m as shipped). Four poured bears never touch, so only a 12-bear bag exercises this, which is why the “crowds without fusing” test uses twelve. - Two lights, one of them shadowless. The key sits at about 78 degrees
elevation, because a shadow lands
h / tan(elev)from its caster: at the old 52 degrees a 0.2 m bear threw its shadow 0.23 m away and six vision rounds read that as “detached streaks, so the bears float”. A top-only key darkens the camera-facing side (dark candy pixels 12 % to 19 %), so a shadowless fill sits near the camera axis: +5-10 luminance, and it cannot put the streaks back. - The highlight must be wide. Roughness 0.34 with a rough clearcoat (0.25 / 0.42). A smooth clearcoat over a smooth base re-sharpens the specular into the “sharp small white hotspot on the belly” that every vision round called wet plastic or polished rubber.
- Candy has to be able to stop. Floor friction was purely viscous
(
vx *= frictionK), which has no rest state: a “settled” bag drifted at 1.5-4 cm/s forever and the bears visibly crawled across the table.STICK_SPEED(0.045 m/s) holds a contacting particle: creep down to 0.3 cm/s, residual speed 0.060 to 0.008 m/s, while a 1.4 m/s throw still travels 0.19 m before stopping.
Testing Discipline
Section titled “Testing Discipline”64 tests, four files, and a set of rules about how to test a solver you cannot see:
| File | Covers |
|---|---|
test/sim.test.ts |
The physics contract: stability, separation, plasticity, energy clamps. No canvas, no three |
test/geo.test.ts |
Polar decomposition, watertight mesh, Euler number, isosurface distance, normals |
test/skin.test.ts |
The only file that imports three: finite output, exact rest surface, floor contact, smoothness under deformation, unit normals |
test/flavors.test.ts |
The candy palette: every preset stays inside the agreed hue and luminance band, added when flavour presets became a spec requirement |
Three of these tests are the interesting ones, because each documents a way a test can lie:
- Negative control is mandatory for a bug fix. Revert the fix, watch the test go red, restore it. One pour test passed against a deliberately broken solver until it asserted the discriminating metric (pair separation 0.67 m fixed versus 1.55 m broken) instead of a decorative one.
- Restore mutants from a copy you verified, not one you named. A backup taken before the edit in the same command silently reverted the change, and the next two “A/B” measurements compared a mutant against itself and printed identical numbers. After any mutant sweep, grep the constant back.
- Never assert object identity on a
BearShape. A failedtoBe(shape)makes the reporter deep-diff 3 100 vertices of typed arrays and hangs for minutes with no output. It looks like a hung suite, not a failed test.
Operational traps that cost time: vitest 5 swallows console.log and has no
basic reporter, so diagnostics write to /tmp/opencode/argon/diag.txt via
node:fs; a scratch test/_diag.test.ts breaks pnpm typecheck because
@types/node is absent, so it must always be deleted; and pkill -f vitest
matches the agent’s own shell command line.
Scale and Frame Budget
Section titled “Scale and Frame Budget”Measured per frame at steady state, CPU only, no GPU in the loop:
| Bears | Total | 60 fps budget |
|---|---|---|
| 1 | 1.05 ms | fits |
| 4 | 5.3 ms | fits |
| 12 | 15.4 ms | fits, at the cap the UI enforces |
Re-measured after the rigid-frame skinning fix (see After the Founding Night). The founding-night breakdown (1.06 / 4.60 / 8.54 / 12.74 ms for 1 / 4 / 8 / 12 bears and 27.00 ms for 24) was dropped rather than kept half-updated: a correct sim/skin split for the 8- and 24-bear cases has not been measured since that fix, and the bag is capped at 12 anyway.
Hence MAX_BEAR = 16: 24 bears was 30 fps before the GPU drew anything.
Skinning is the hot path, which is why the loops keep Math.sqrt instead of
Math.hypot (about 10x slower here) and why the mesh resolution is 30 rather
than 34 (60 % more vertices for facets nobody sees).
| Quantity | Value |
|---|---|
| Particles per bear | 95, in 5 clusters (95 torso / 43 head / 8 / 6) |
| Skin influences per surface vertex | 14 |
| Surface at resolution 30 | 3 102 vertices, 6 200 triangles (about 25 k for a bag of four) |
| Bear size | 0.28 m wide, 0.5 m tall standing |
| Pour lattice cell | 0.34 m, so a fresh bear can never land inside a settled one |
| Bag / cap | 4 poured, 16 maximum |
| Settled creep | 0.3 cm/s |
What the Headless Loop Taught
Section titled “What the Headless Loop Taught”Headless Chromium rasterises with SwiftShader at about 4 fps with transmission and shadows enabled. Real GPU numbers do not transfer, and performance must never be tuned from the headless browser. An open page renders forever: SwiftShader took about 10 cores and the vitest fork pool then stalled for minutes with almost no CPU, which reads exactly like a broken test suite. Close the session when you are done.
Two query flags exist only for this loop: ?still=1 freezes the solver so the
picture is the rest surface (isolates a mesher artefact from a deformation one),
and ?flat=1 drops transmission and dispersion (isolates a shading artefact from
a refraction one). ?quality=low drops shadow maps and the transmission buffer
for a fast anatomy shot, ?bears=N and ?wobble=0..100 preset the bag.
The probe is only comparable for the same settled arrangement: a tighter pile moved candy share from 8.5 % to 4.7 %, which is more than any lighting change produced. For material A/B, freeze one pile and toggle the light inside the page.
After the Founding Night: The Brief Became a Spec
Section titled “After the Founding Night: The Brief Became a Spec”The founding night produced a bear that looked roughly right and a page describing
it. Then the Reddit brief that started the whole thing was written down as
spec.md with per-requirement acceptance criteria, and the conformance table
started finding things the eye had accepted.
Timeline
Section titled “Timeline”| When | What |
|---|---|
| 14 Oct 19:37 - 15 Oct 05:50 | Founding session: solver, mesher, skinning, look; 43 tests, eleven vision rounds. This is what the rest of this page describes |
| 15 Oct 19:02 | First sync-gummy-bear.sh into this site (embedded build index-BDeVwDnm.js) |
| 17 Oct | The brief adopted as spec.md with a conformance table: 18 FRs, deltas listed honestly |
| 18 Oct | Spec closed on eyes, palette, part clusters, limb thinning, idle hop, page identity, grab cursor, single-file artefact; grab cannonball fixed |
| 18 Oct | Face molded hard enough for the camera, sugar grain introduced, folded UV, back light measured inert and removed, the lying-bear collapse fixed |
| 19 Oct | Arc-length sugar UV plus clearcoat grain |
| 22 Oct | Spec status words, DOX framework, IPADP entry declaring this site as deploy upstream, embedded build re-synced (index-Dbx9PBaN.js) |
Three bugs the spec found and the eye did not
Section titled “Three bugs the spec found and the eye did not”| Problem, measured | Fix |
|---|---|
| A bear lying on the table rendered at 52.6 % of its own volume. Linear blend skinning approximates a rotation by averaging chords: 93.6 / 76.3 / 52.6 / 5.3 % of rest volume at 30 / 60 / 90 / 180 degrees | Skin through the body’s rigid frame (poseRotation plus elastic deltas): 100.0 % at 90 degrees, and the cost went down, 4.91 ms to 4.77 ms |
129 of 6 268 triangles read the whole sugar tile backwards where the cylindrical UV crossed the atan2 cut, so the grain smeared across the flank |
Fold u to ` |
| One texture, two grain sizes: 3.01 mm of candy per tile at the waist against 0.8 mm on a limb (2.1x anisotropy), and the clearcoat ignored the grain entirely | Arc-length `u = r |
The clearcoat scale was the one call a probe could not make. A repeated blind four-panel vision sweep chose it: 0.18 read “smeared gloss”, 0.10 won twice. That is the honest split this project runs on - the vision model decides what to look at, the probe decides whether it got better, because cross-call vision scores on identical content measured 9/10 then 4/10.
What the conformance table says
Section titled “What the conformance table says”19 functional requirements: 17 met, 2 deviated, zero open. deviated means
measured and adjudicated, not unfinished:
- FR-017 (back light): implemented, then measured inert. Candy p95 168.2 with the light against 168.3 without; rim share 0.53 % against 0.53 %. Removed, with the measurement kept in the spec so nobody re-adds it.
- FR-018 (CDN): replaced by inlining three.js, so the single-file artefact works offline. The requirement’s intent beat its letter.
Seven invariants joined the ledger after the founding night, each one the same kind of scar as the fifteen above: a hand positions and never throws; an idle hop is a shape exchange, not an impulse; the ragdoll read is limb lag and only visible in the torso frame; a feature exists where the camera can see it, not where the field defines it (the eyes were in the SDF, the mesh and the tests, and invisible in every round); sugar grain is a shading feature and shading needs coordinates; a delta blend of translations is not a rotation; and the freeze-one-pile rule for material A/B.
Status
Section titled “Status”Shipped: 64 tests green, tsc --noEmit clean, 5.3 ms of CPU per frame for a bag of
four. Look metrics on the pinned recipe from spec section 5 (cherry flavour, one
bear, camera 0.8 m, hops off, solver frozen): candy 17.5 % of frame, luminance 134.9,
dark core 5.7 %, saturation 0.48, sugar texture energy 6.21 against 1.58 untextured -
3.93x. Absolute look numbers drift between sessions, so the sugar clause is
adjudicated by that within-session ratio on one frozen pose, never across runs.
Visual direction is converged per the model. The residual disagreements are documented above so they are not re-litigated in a later session.
Try It
Section titled “Try It”cd ~/internal/poc/argonpnpm installpnpm test # 64 tests, about 14 spnpm dev # http://127.0.0.1:5178To refresh the copy embedded on this site:
./scripts/automation/sync-gummy-bear.sh