Zum Inhalt springen

Gummy Bears: How the argon POC Was Built

Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.

~/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.

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.

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.

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.

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 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.

Terminal window
pnpm build && pnpm preview --port 4178 --strictPort &
export PLAYWRIGHT_CLI_SESSION=argon
playwright-cli --s=argon open "http://127.0.0.1:4178/?bears=4" --browser chromium
# wait on rendered frames, never on a sleep
playwright-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.png
python3 "$SATWARE_HARNESS/skills/computervision/scripts/vision-analyze.py" \
/tmp/opencode/argon/shot.png --prompt-file critique.txt

Three 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).

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.

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.

  1. One shape definition. Change the bear in sdf.ts only. Mesh, particles, clusters and weights all derive from it.
  2. 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.
  3. Position-based fixes become velocity through (x - prev)/h. At h = 1/480 a 10 mm correction is 4.8 m/s. Three defences, all required: the pour lattice never spawns interpenetrating bears, SEPARATION_SPEED budgets the shift per particle per substep, and clampContactEnergy caps each contact’s separation at what its own approach earned.
  4. Parameters are clamped in one place (clampParams in world.ts), used by both the constructor and World.configure. A slider that bypasses it pushes the solver out of its stable regime.
  5. Stiffness and restitution are authored per frame, softened per substep as 1 - (1-k)^(1/substeps). Raw per-substep values compound to e^substeps.
  6. Plasticity is a constitutive model, not a hack: target = R·P·q0 + base, P normalised by 1/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.
  7. 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).
  8. 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.
  9. 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.
  10. 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 is depthTest: false: measured 10 % of the frame was shadow laid over candy that should have hidden it, and the picture reads as “muddy, floating, streaky”.
  11. Beer-Lambert needs attenuationDistance >= about 1.2x thickness. Absorption is exp(-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. Raising transmission is 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.
  12. 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 at CUSHION_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.
  13. 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.
  14. 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.
  15. 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.

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 failed toBe(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.

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

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.

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.

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.

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.

Terminal window
cd ~/internal/poc/argon
pnpm install
pnpm test # 64 tests, about 14 s
pnpm dev # http://127.0.0.1:5178

To refresh the copy embedded on this site:

Terminal window
./scripts/automation/sync-gummy-bear.sh