# Port notes
Working notes for quack, the web port of celphase's duck-pond. The line-by-line
study of the Rust project lives in `duck-pond/exploration.md`, beside the
vendored clone (both kept out of this repo). This file is about *this* project:
what was ported, what changed, and the conventions the code relies on.
[`behavior-divergence.md`](behavior-divergence.md) is the balance sheet: what
came across from the Rust model, what has **not** been ported yet, what quack
invented, and where the behaviour differs because quack has flight and winter
and the Rust has neither.
## What came from where
| in quack | source | lives in |
| --- | --- | --- |
| Activity model, time budget, families, swimming speeds | duck-pond `scene.rs` | `src/pond.ts` |
| Calling model: intervals, sleeping suppression, hen lottery, social answers, brood recall | duck-pond `scene.rs` | `src/pond.ts`, `src/config.ts` (`CALLS`) |
| Duck rig: body, wings, bill, eyes, poses | our own bird world's `bird.js` | `src/duck.ts` |
| Flight: the glide, the climb-off beat and the landing flare, and the flap phase under them | our own bird world's `bird.js` air states | `src/duck.ts` |
| Season-keyframe idea (re-paced: 7 minutes a season) | our own bird world's `seasons.js` | `src/seasons.ts` |
| Bread, begging, the sight cone, flying to a far crust, the curious visitor, cursor gaze, the camera, pacing | new in quack | `src/pond.ts`, `src/main.ts`, `src/camera.ts` |
| The lead: a hole the freeze never closes, and the rules that keep a duck from being caught by the ice | our own bird world's `js/world.js`, `js/bird.js` | `src/world.ts`, `src/pond.ts` |
The research citations behind the upstream constants live in
`duck-pond/ATTRIBUTION.md`.
Ported in v2: the audio stack, unchanged from the Rust, driving the flock quack
already has. `Stage` in the vendored `duck-pond/src/scene.rs` (kept as
`audio/duck-pond-stage.patch`, built by `scripts/build-audio.mjs` into
`public/quack_audio.wasm`) adds a manual sound layer: quack supplies the ducks'
voices, their places in the listener's frame, and the sound kinds; the Rust
answers with the same synthesis, distance attenuation, speed-of-sound delay,
head shading and reverb as the live pond. It runs in a worker behind an
AudioWorklet, wired in `src/audio.ts`; `docs/audio.md` maps every sound to the
action that makes it.
The calling model itself was ported as behaviour in v1: the intervals, lottery,
one-hop answer chain and brood recall run in `src/pond.ts`, tuned by `CALLS`. A
call is still a head/bill gesture in the rig (and a `CallEvent` on
`Pond.onCall`), but the event now also plays, through `AudioCore.call`; a lost
duckling's "peep peep" and a content duckling's "pip-pip" are separate call
kinds, not a random chirp.
## The flock
Twelve mallards, built in `src/pond.ts`:
- Hens: **Mabel** (brood: Pip, Dot, Bean, Fig), **Clover**, **Hazel**
(brood: Nib, Tuft, Sprout).
- Drakes: **Rufus** (Clover's mate; trails her), **Bramble** (lone).
- Ducklings follow their mother; drakes follow their mate.
Activities: `swim | dabble | rest | preen | stray | feed | beg | fly`, each
running sub-actions (`idle`, `look`, `sleep`, `stretch`, `preen`, `peck`,
`drink`, `dabble`, `upend`, `chirp`, `beg`). `feed`, `beg` and `fly` are ours;
the rest carry over duck-pond's time budget (~40 % feeding, ~30 % resting,
~15 % preening). A duck on the wing is flown by the pond along a straight line
and posed by the rig; only an unencumbered duck ever flies — a hen with a brood,
or a duckling, keeps to the water.
Calls are scheduled beside the activities, not inside them: `CALLS.interval`
sets the next one by occasion (lost young, lost duckling, feeding, a drake's
mate out of reach, dabbling, everything else), a resting duck skips 80–90 % of
them, and a hen's series or decrescendo can set one hop of answers going.
Ducklings peep when lost and pip-pip otherwise; a hen gathering her brood or
answering lost young gives the `kwek-kwek` brood call and cues stray ducklings
back. Each is heard as the matching Rust call — see `docs/audio.md`.
## Pond geometry
| piece | value |
| --- | --- |
| pond ellipse (`WORLD.pond`: cx, cz, rx, rz) | 0, −22, 46, 64 |
| beach strip (`WORLD.bank`) | x ≤ 26, z 41.5–52.5 |
| thrower's stand (`WORLD.thrower`, what the flock reacts to) | (0, 47) |
| viewer's stand (`WORLD.viewStand`, the camera's eye) | (0, 48.3); the water in front of it is kept clear of reeds |
| depth bands (`WORLD.bands`, world z) | 22–38 / 0–22 / −38–4 |
| chase region for bread (`WORLD.chase`) | x ±120, z −120…43.5, and any water a duck can be in — open, shallow or frozen |
| walkable shore (`WORLD.shore`) | x ±130 on the waterline, 3 m (edge) to 21 m inland |
The near land is a **bank**, not a meadow that happens to be near the water: south
of `shorelineZ` the ground climbs clear of the water (0.7 m at the water's edge to
about 3 m, 90 m inland) and stays clear, so the camera always stands on dry ground
and the water's edge is a clean line rather than a patch of land afloat in the
shallows. That climb is steep — most of it inside 2.5 m — so the mesh has to
resolve it: at 6.94 m steps the corners straddle the bank's foot, the interpolated
surface floats above the ground, and the *drawn* water's edge lands 2.7 m out into
the water, stranding the reeds and any duck the field puts in the shallows. At
3.5 m steps the drawn edge lands within 0.1 m of the field's. `WORLD.shore` +
`shorePoint` / `shorePointAt` / `shoreNormal` / `onShore` describe the walkable
stretch of that bank, ready for later interaction.
## Tuned constants
Every dial lives in `src/config.ts`, one export per scope: `APP`, `INPUT`,
`LOADER`, `HINTS`, `CAMERA`, `SEASONS`, `WORLD`, `FLOCK`, `CALLS`, `SHORE`,
`WINTER`, `LAP`, `FLIGHT`, `BREAD`, `SIGHT`, `CURIOUS`, `SPACE`, `DUCK`,
`EFFECTS`. A module imports only the groups it spends, and each field carries the reasoning
that used to sit beside the code. Two things deliberately stay where they were
written: the season colour keys in `src/seasons.ts` and the species data in
`src/duck.ts` (read as whole pieces of art), and the rig's pose curves in
`src/duck.ts` (a shape, not a number).
The table gives the tuned value; `config.ts` is the source of truth. The last
column is the module that spends it.
| knob | value | spent in |
| --- | --- | --- |
| `SEASONS.seconds` | 420 (7 min; a year = 28 min) | `src/seasons.ts` |
| `APP.openSeason` | the year opens in summer | `src/main.ts` |
| winter ice | season key `ice` 1: the pond locks solid, but for the lead | `src/seasons.ts` |
| `WORLD.lead` | centre −3.5, 29; r0 8.5, r1 11.5 m; rim wobbled ±1.85 m | `src/world.ts` |
| `WORLD.ice.walk` | 0.5 — how thick the lid must be to carry a duck | `src/world.ts` |
| `WORLD.ice.still` | 0.3 — how frozen a spot must be for the swell under it to be pinned: the lid is perfectly still, and only the open pool still answers the clock (the water shader repeats it) | `src/world.ts` |
| `WORLD.water` | wade 0.05 / swim 0.3 / deep 0.8 m: the shallows are walked, deeper water paddled, and 0.8 m is enough to upend | `src/pond.ts` |
| winter goals | inside the lead (within 0.9 × r0); the odd swim bout (`WINTER.loaf` 0.045) bound for the lid's rim instead | `src/pond.ts` |
| a visit ashore | gated on a swim bout starting within `SHORE.gate` 26 m of the thrower: `SHORE.visit` 0.35, `SHORE.broodVisit` 0.5 for a hen with ducklings, both halved once the pond is iced over (`SHORE.winterVisit`) | `src/pond.ts` |
| a spell ashore | `SHORE.stay` 35–90 s on the bank, `SHORE.iceStay` 15–45 s on the lid; the water pulls at each bout end — `SHORE.returnToWater` 0.75, `SHORE.returnFromIce` 0.85 | `src/pond.ts` |
| the lap of the lake | `LAP.chance` 0.14 of the bout ends of an in-frame duck in the water — a wandering round drawn fresh through the bird's position each time: radius `LAP.radius` 4.5–9.5 m (`LAP.winterRadius` 3–5.5 m in the lead), a random way round, and a two-harmonic wobble (`LAP.wobble` 0.15, `LAP.wobble2` 0.1) bowing the path in and out by up to 25% of the radius; `LAP.turns` 0.55–1.5 turns a bout, `LAP.out` 0.3 of them swinging out of the picture; a duck out of the picture is sent back at `FLOCK.returnToView` 0.8, its pastime cut to `FLOCK.outViewBout` 50 s | `src/pond.ts` |
| the flight | `FLIGHT.chance` 0.18 of the bout ends of an unencumbered, in-frame duck (× `WINTER.bouts` 0.55 in the freeze), plus any far piece of bread (below); a low pass `FLIGHT.reach` 16–34 m, `FLIGHT.peak` 0.8–1.45 m × body scale up, `FLIGHT.time` 4.2–8.5 s, the landing inside `FLIGHT.far` 40 m of the thrower; `FLIGHT.cool` 20–45 s after each landing | `src/pond.ts` |
| hull speed, adult | `FLOCK.hullSpeed` 0.71 m/s | `src/pond.ts` |
| hull speed / burst, duckling | `FLOCK.ducklingHullSpeed` 0.43 / `FLOCK.ducklingBurst` 1.26 m/s | `src/pond.ts` |
| brood pace (hen with ducklings) | `FLOCK.broodPace` 0.3 m/s | `src/pond.ts` |
| cruise speeds (hen / drake / duckling) | 0.45 / 0.5 / 0.33 m/s (per kind, in the rig) | `src/duck.ts` |
| lost duckling | `FLOCK.lost` — stops beyond 4 m from mother | `src/pond.ts` |
| stray mean interval | `FLOCK.strayEvery` 900 s | `src/pond.ts` |
| calls | `CALLS.first` 0.5–8 s before the first; then `CALLS.interval.lostYoung` 1.5–3, `.lost` 0.2–1, `.ducklingFeed` 1.5–5, `.duckling` 4–15, `.feed` 2.5–6, `.drakeAway` 8–20, `.drake` 45–180, `.dabble` 15–60, `.other` 20–90 s, in that precedence; a resting duck skips `CALLS.sleepSkip` 0.9 (adult) / 0.8 (duckling) | `src/pond.ts` |
| answers | a social call reaches 40 m: mate p = `CALLS.answer.mate` 0.6, others `CALLS.answer.other` 0.3 × (1 − dist/40); answer due in `CALLS.answerDelay` 0.8–2.5 s, one hop | `src/pond.ts` |
| brood recall | a lost duckling cuts its mother's timer to `CALLS.motherDelay` 0.5–2.5 s; her brood call runs `CALLS.broodNotes.lost` 6–10 notes (gathering: 3–6) and cues stray ducklings | `src/pond.ts` |
| `INPUT.tapSlop` | 12 px | `src/main.ts` |
| cursor gaze limits | `DUCK.look.yawMax` 1.55, `DUCK.look.pitchMax` 0.72 rad | `src/duck.ts` |
| gaze shaping | `DUCK.look.sweep` 0.9, `DUCK.look.tilt` 1.1 | `src/pond.ts` |
| bread flight time | `BREAD.time`: clamp(0.45 + dist * 0.04, 0.6, 1.9) s | `src/pond.ts` |
| bites per piece | `BREAD.bites`: 2 or 3 (50/50) | `src/pond.ts` |
| bread lifetime | `BREAD.lifetime` 100 s, then it sinks | `src/pond.ts` |
| begging time | `BREAD.begTime` 25–40 s at the landing spot | `src/pond.ts` |
| seeing the bread | a duck head-up with the landing spot within `SIGHT.range` 55 m and inside `SIGHT.half` 1.15 rad of its bill starts for the splash at 25–70% of the piece's flight; everyone else only hears the splash and comes late | `src/pond.ts` |
| flying for bread | a free duck with a piece `FLIGHT.food.near` 15 m or more away, rising to `FLIGHT.food.max` 0.55 at `FLIGHT.food.far` 42 m; it comes down `FLIGHT.food.gap` 1.4–2.6 m off the piece on open water, one bird to a piece | `src/pond.ts` |
| the curious duck | `CURIOUS.chance` 0.08 of bout ends for a duck in the water within `CURIOUS.gate` 46 m of the stand with no bread down; one at a time, `CURIOUS.every` 150–420 s apart, `CURIOUS.time` 25–60 s of begging after the swim over | `src/pond.ts` |
| floating bread rings | `BREAD.ring.every` 1.4–4.2 s, `BREAD.ring.scale` 0.2–0.3 (2–3× the piece), in open water only — a piece out on the lid lies still | `src/pond.ts` |
| bread scatter | a tap within `BREAD.scatter.crowded` 0.55 m of floating bread is skewed `BREAD.scatter.near`–`BREAD.scatter.far` 0.24–0.6 m off the cursor, re-rolled until it clears every piece by `BREAD.scatter.gap` 0.24 m | `src/pond.ts` |
| personal space | hull `SPACE.hullLen` 0.31 m long × `SPACE.hullBeam` 0.18 m beam, per unit of body scale; gap `SPACE.familyGap` 0.03 m inside a family, `SPACE.strangerGap` 0.10 m between strangers | `src/pond.ts` |
| the waddle | one hinge per leg at the hip; a step covers `2·DUCK.legs.length·sin(DUCK.legs.swing)` ≈ 0.14 m × body scale over `DUCK.legs.stance` 0.62 of the cycle, so the cadence falls out of the speed; swing sole lifts `DUCK.legs.lift` 0.022, and the body rides ~0.015 m × body scale through a cycle | `src/duck.ts` |
| the sleeping sit | a duck asleep on solid ground folds its hips to `DUCK.sit.tuck` 1.55 rad and settles `DUCK.sit.drop` 0.04 rig units × body scale, until the belly is 7–9 mm × body scale *into* the shore or lid; eases at 2.6/s | `src/duck.ts` |
| breathing | `DUCK.breath.amount` 1.4% at `DUCK.breath.rate` 3.2 rad/s, on the torso mesh alone — head, bill, wings, tail and legs keep their size | `src/duck.ts` |
| avoidance field | a soft nudge from `SPACE.reach` 1.3× the hull distance out, up to `SPACE.push` 0.28 m/s | `src/pond.ts` |
| camera | first person: the eye planted at `WORLD.viewStand`, `CAMERA.eyeHeight` 2 m above the ground; `CAMERA.yawLimit` ±0.85, pitch `CAMERA.pitchMin`…`CAMERA.pitchMax` −1.0…0.45, resting at `CAMERA.pitchStart` −0.06 | `src/camera.ts` |
| camera zoom | `CAMERA.fov`: 10–78°, opens at 52°; wheel and pinch ask for equal *ratios* per pixel, and the ease runs in log space. The floor holds a slice of horizon rather than a number of degrees: 10° vertical on a 16:9 screen (`CAMERA.fov.refAspect`), opening up on narrower ones (`fovFloor(aspect)`) so a phone's portrait frame always shows the same width of pond at full lean-in | `src/camera.ts` |
| shore bank floor | `WORLD.bankFloor` 0.7 m at the edge → 3.0 m at 90 m inland | `src/world.ts` |
| walkable shore | `WORLD.shore`: ±130 m along the waterline; edge 3 m, depth 18 m | `src/world.ts` |
| terrain mesh | `WORLD.segments` 432 (3.5 m) over `WORLD.size` 1500 m; the slope is read off the grid, not re-sampled | `src/world.ts` |
| ripples | `EFFECTS.ripple`: lift 0.035, life 1.3–1.8 s, growth 0.4 + 2.4 t, alpha 0.55, 24 rings | `src/effects.ts` |
| particle pools | `EFFECTS.capacity`: 180 feathers, 320 droplets, 220 leaves, 300 snowflakes; `EFFECTS.weather` holds the shedding rates | `src/effects.ts` |
| tone mapping | ACES, `APP.exposure` 0.98 | `src/main.ts` |
| default seed | `APP.seed` 20261018 (`?seed=N`) | `src/main.ts` |
## Conventions
- Units are meters and seconds; y-up; a duck faces +z before posing.
- **Head pitch sign**: positive X tips a +z-facing part (the bill) down — for an
unyawed bill. In the sleep pose the head is yawed 2.85 rad (~163°), so the
sign flips and a *negative* pitch is what lowers the bill onto the back.
- **Sleep pose**: the head is drawn right around over a shoulder with the bill
trailed back along the flank. Tangency of the jaw's underside with the
torso/wing shell sits near −0.18 rad, but flush is the wrong answer — the tip
disappears into the body volume from most angles, the player's included. The
code holds the bill at **+0.06 rad**: still lying on the feathers, top edge
clear of the body line, so the tip reads from either flank. Checked on hen,
drake and duckling, close up and from the stand.
- **Sleeping is sitting**: a duck that dozes off on solid ground folds its legs
up under itself and settles onto its belly; dozing upright on straight legs
read as standing with the head tucked, which is a horse, not a duck. The
trigger is `action === 'sleep'` while on land or the lid and still; the hips
fold to `DUCK.sit.tuck` (1.55 rad, the swimming tuck) and the standing height
drops by `DUCK.sit.drop` (0.04 rig units × body scale), a little *more* than
it takes to bring the belly down to the surface — the body presses a feather's
depth into the shore or ice instead of touching it tangentially. Tangency
reads as hovering, and the ice would draw the contact as a hard line; sunk in,
the belly and the stern are cut by the ground plane and the bird sits in it.
The idle bob fades out with `sit` so the settled body does not breathe itself
clear of the ground, and the whole settle eases at 2.6/s, so lying down and
getting up are movements. Sleeping afloat is untouched: a loafing duck already
has its legs at 1.55 and rides the swell. (Measured at drake scale 1.46: the
body drops 5.8 cm, the belly's lowest point crosses the ice by ~7 mm, the
folded feet ride 13 cm up inside the belly and never break its silhouette.)
- **Breathing is the body's alone**: the idle breath is a scale on the torso
mesh, not on the root. Scaling the whole rig made the head, bill and feet
pulse along with the chest, and quietly changed the legs' length every breath
— so a planted sole crept a millimetre or two while the duck stood still.
Now the chest fills and empties and everything else holds its size; the neck
is short enough that the 1.4% seam stays buried in the plumage. Measured at
drake scale 1.456: torso world scale runs 1.436–1.477, while head, bill,
wings, tail and feet all stay at 1.456.
- **Wing stretch**: both wings come up and wave, one a little higher than the
other, and both *stay* up. A floating duck's shoulders sit barely above the
water, so a wing swinging down out of the stretch dips straight into it and the
pair reads as one broken diagonal; the asymmetric part is therefore added on
both sides rather than being a mirror of the flap.
- **Cursor gaze**: every duck pans and tilts for the pointer across the whole
viewport; the easing keeps the middle gentle while the corners reach the full
gaze limits. Driven by `pond.setCursor(x, y)` with NDC coordinates.
- **Tap vs drag**: no time limit — press as long as you like, release to throw.
Only a drift past `INPUT.tapSlop` turns a press into a camera drag. Right-button
presses and second fingers are ignored. A duck that never sees the throw reacts
only once the bread lands; one that does (below) is off while it is still in
the air.
- **Bread**: leaves the hand just below the camera (1.2 m along the throw
direction, at least 1 m above the water), arcs to the landing spot, then
splashes or kicks up dust. Landing spots are clamped into the chase region so
the flock can always reach them, on open water, in the shallows or out on the
ice. A piece left floating nudges the surface now
and then — a small ring, a couple of times the size of the piece — so it stays
findable without shouting like the splash it landed with.
- **Seeing the throw**: a duck with its head up has the whole pond in front of it,
but no reason to look up — unless something arcs over the water. A piece whose
landing spot lies within `SIGHT.range` (55 m) and inside `SIGHT.half`
(1.15 rad) of the bird's bill is *seen*: that duck sets off for the splash at
25–70 % of the piece's flight (`sees`, `maybeFlyToFood`), so it is already
moving — and sometimes already eating — by the time the ring spreads. The rest
only hear the splash, the near ones first (`T/12` per metre), so the flock
comes in waves, and the birds facing the other way or asleep always come late.
A duck on the wing keeps its invitation until it is down, so a flight for one
piece is never cut short by another. Measured (20 min, a throw every 110 s):
22 ducks set off for a piece before it touched down, against 101 that came
after the splash.
- **Rings ride the swell**: every ripple — splash, wake, nibble, bread nudge —
is pinned to `waterHeightAt(x, z, time)` for its whole life, never to the
height it was spawned at. The swell runs ±0.3 m here, so a ring left at a
fixed height hangs in the air beside its duck the moment the surface drops
away from it; a wake spawned at the duck's own waterline would otherwise start
6 cm *under* the surface. `Effects.update` stores the world and clock so
`ripple()` and the pool can re-seat every ring each frame.
- **Repeated taps**: a landing spot within `BREAD.scatter.crowded` (0.55 m) of
bread already there is skewed off the cursor — a random bearing,
`BREAD.scatter.near`–`BREAD.scatter.far` (0.24–0.6 m) out — and re-rolled
until it clears every piece by `BREAD.scatter.gap` (0.24 m, a piece being a
0.1 m pebble). A few taps on one spot then make a little scatter of crusts to
pick at, close together but never intersecting. The skew stands only if the
ducks can still reach the spot.
- **Winter, and the lead**: the cold comes in from every edge, but it never
closes one patch of water — `WORLD.lead` (centre −3.5, 29; rim 8.5 to
11.5 m out), set a little left of the middle of the gaze the camera opens on.
`iceAt(x, z)` is how frozen a spot is: 0 inside the lead, 1 far from it, ramped
across the rim, and the rim is wobbled by `leadWobble(angle)` in integer
harmonics so it closes on itself without a seam. The water shader repeats both
formulas from the same constants (passed as uniforms), so the sheet that is
drawn and the sheet the rules know about agree to the metre. Its swell is
pinned as soon as the sheet starts to skin over — `WORLD.ice.still` 0.3, well
short of the `WORLD.ice.walk` 0.5 that lets a duck stand on it — and the
fragment shader repeats the same pinning, so the whole lid is perfectly
still: on screen, and in `waterHeightAt`, which is what a duck standing on
it, and a ring that lands on it, both ride. Only the open pool still answers
the clock. Measured at full ice: all 78 sampled lid points had zero swell
amplitude across 24 s of clock, and the ice read 0 changed pixels in a
straight-down render two seconds later, while the pool moved 45 % of its
pixels in the same frame (and open water in summer 44 %, the control). Through
the winter the lid reaches further in, so the flock is pushed off the
shallows, gathers in the lead, and begins to stand about on the ice.
- **What a duck may be in**: `solidAt` — ice at `WORLD.ice.walk` (0.5) or thicker —
joins the media a duck may occupy. It swims open water, wades the shallows
(0.05–0.3 m: too shallow to paddle, shallow enough to walk through), and walks
the lid; those are the only rules that changed, and steering is the same in
all three. `Pond.stranded()` is the net under all of it: a duck standing where
nothing allows it any more — the freeze closed around it, the thaw took the lid
from under it, a shove left it high and dry — is walked out to the nearest spot
that is allowed, because steering cannot free a duck that has nowhere to steer,
and one that is not freed stands still for good and a hen waiting on her brood
waits for ever. Measured through a freeze (30 s of sim once the pond is locked):
no duck-frame had a duck standing where the rules forbade it, and none was left
stuck — five of the twelve paddling in the lead and seven about on the lid, and
a crust thrown on the lid is walked to and eaten.
- **Ice that reads as ice**: the sheet takes the lead's rim as a slush edge that
rides the freezing front, then a grained, patched lid — snow lying on it in
patches and the ice scoured bare between them a shade darker, so that even at
the grazing angle the camera opens on the sheet is a surface rather than a
mist. It turns properly solid just *past* `WORLD.ice.walk`, so nothing is ever
seen paddling on something that already reads as ice, and the open water
deepens and cools as the season turns, which is what makes the lead read as the one hole in
the lid. (borb's sheet was a single colour-and-alpha mix; the grain, the slush
and the patches are ours.)
- **Bread on ice, and in the shallows**: a crust may land on the lid or in the
shallows, and the flock walks out to it — nothing is ever re-landed on water
they cannot reach. A piece on the lid does not bob and does not ring (it is not
floating); it kicks up dust when it lands, like one on the bank.
- **Ashore is a visit, not a move**: the bank and the winter lid are places to
pass the time, never somewhere to go *to*. A duck leaves the water only on a
whim of the swim model — a swim bout starting within `SHORE.gate` (26 m of the
thrower) turns into a visit with `SHORE.visit` 0.35 (`SHORE.broodVisit` 0.5
for a hen with ducklings; `SHORE.winterVisit` halves both, because a spell
ashore then means a march over the frozen lid) — and once it is out, no bout
targets another spot out there: a pastime is all the footing model offers, and
each bout end walks the duck back to the water, `SHORE.returnToWater` 0.75 on
the bank and `SHORE.returnFromIce` 0.85 on the lid. The odd bout takes another
pastime instead, so a visit is one to three bouts — not instant, and not a
migration.
Measured in summer (45 min): four adult visits for the flock, 112–226 s each,
~12 % of duck-time out of the water, and most of that one hen's brood on the
bank; in winter (20 min × 3 seeds): 7–12 % of duck-time on the lid, over half
of it the walk across to the bank, the rest loafing in 15–45 s bouts. No duck
ever stood anywhere the rules forbade, and none was left out for long.
- **One year, one cast**: quack is a single year in a timeless place. The same
twelve birds are here in January as in June, and the ducklings never grow up;
the seasons turn around them. This is a deliberate simplification, not a
migration model, and it is the reason the winter cast can stay fixed.
- **The picture keeps the flock**: a duck that ends a bout outside the camera's
wedge is sent back to its band's water (`FLOCK.returnToView` 0.8), and a
pastime already running out there is cut to `FLOCK.outViewBout` 50 s. `dabble`
is offered only in water inside the wedge, which was the leak that let a
dabbler drift over the line and then hold position out there for a whole bout;
a duck already outside keeps its ground (and dabbles where it stands) until
the bout ends.
- **The lap of the lake**: a duck that would settle to the nearest pastime
sometimes goes off around the water instead — `LAP.chance` 0.14 of the bout
ends of an in-frame duck, 0.55 of that in the freeze (`WINTER.bouts`). The
round is drawn fresh every time, *through the duck's own position*, so the
whole bout is the round and never a swim out to some far-off circle: a random
radius (`LAP.radius` 4.5–9.5 m, `LAP.winterRadius` 3–5.5 m in the lead), a
random way round, and two radial wobbles (`LAP.wobble` 0.15, `LAP.wobble2`
0.1, incommensurate with the circle and with each other) that bow the path in
and out by up to a quarter of the radius, so no two laps read the same and
none closes into a stiff circle or ellipse. The pond walks the mark along the
arc a few metres ahead of the bird (`stepLap`),
and a bout swims `LAP.turns` 0.55–1.5 turns of it — part of the round, or all
the way about — before the bird gets its day back. A round that is asked
for out of the picture (`LAP.out` 0.3) skips the wedge test, so part of it
plays off the edge of the frame and back in. Measured (30 min summer): four
laps, radii 4.6–8.9 m, three one way round and one the other, 0.7–1.16 turns
each, the path holding the round to 0.91–0.96 of its radius, the net turn
matching the lap's own direction every time, all four inside the wedge.
- **The flight**: now and then a duck takes the short way across on the wing
instead of swimming it. `FLIGHT.chance` 0.18 of the bout ends of an
unencumbered duck that is inside the picture become a low pass over the water,
`FLIGHT.reach` 16–34 m long and `FLIGHT.time` 4.2–8.5 s in the air, riding
`FLIGHT.peak` 0.8–1.45 m × body scale over the surface at its peak; the
landing spot is re-rolled until it is clear of the ducks already there and
within `FLIGHT.far` (40 m of the thrower), where the flock still reads — a
duck out in the far water flies shorter and tends back towards it.
A bird that has just come down stays down for `FLIGHT.cool` 20–45 s, so flights
never chain into a shuttle. The pond flies the bird (a straight line, held off
and on the water, climb — glide — settle), and the rig does the wings, from
borb's air poses: a hard beat to get up, a long glide with barely a tip moving,
then the flare and backbeat that sets it down, with the bill lifted on the
climb and again on the flare and the wingbeat carrying the body up and down.
Splashes at both ends — a puff of snow if it sets down on the lid — legs
tucked, tail spread. Nothing on the water touches a bird on the wing — it is
listed for no hull, no ripple wake, no cursor and no unclaimed bread, and
steering leaves it alone — so it passes over the flock and over the ice with no
trouble. Measured (30 min summer, no bread): eight flights from the flock's two
eligible birds, 17.8–32.9 m, every one landing on open water, none chained; in
winter, three short hops over the lead in 25 min.
- **Flying for bread**: a crust that comes down across the pond is worth the
wing rather than the paddle. A duck that has decided to come for a piece
measures it, and if the bird is `FLIGHT.food.near` (15 m) or more away the
chance rises with the distance to `FLIGHT.food.max` 0.55 at `FLIGHT.food.far`
42 m (`maybeFlyToFood`); it comes down in the water `FLIGHT.food.gap` 1.4–2.6 m
off the piece on a random bearing and paddles the last of the way, so it never
lands on its own mouthful, and only one bird flies for a piece — the rest
swim, which is what keeps a far throw from emptying the pond
into the air. Only ducks with nothing to look after fly for it (a hen with a
brood stays on the water), and a piece out on the bank is left to the walkers. Measured: with a throw every 110 s, six food flights in 20 min at
25.7–51.4 m, every one onto water and five of six still going for the bread
at touchdown.
- **The curious duck**: now and then it is the duck that notices the visitor,
not the other way round. At a bout end, a duck in the water within
`CURIOUS.gate` (46 m of the stand), with no bread down, may take it into its
head to come over: `CURIOUS.chance` 0.08, and the flock keeps at least
`CURIOUS.every` 150–420 s between visits, so it reads as one bird's idea
rather than a habit. It crosses at a purpose (`hurry` until it is within 4 m
of the shallows in front of the stand), then stands about there for
`CURIOUS.time` 25–60 s, head up and the odd imploring bill-open, begging at
nothing — until a piece of bread lands anywhere and the flock's usual business
resumes. In a freeze the
bank is out of reach, so it comes to the nearest open water instead: the rim of
the lead. Measured (25 min winter): two such visits, 66–83 s each, each ending
9.3–9.7 m from the stand at the lead's near rim; in summer one bird crossed
43 m to beg 5.8 m from the stand.
- **Personal space**: every duck carries a hull — an ellipse half a body long
(`SPACE.hullLen`, 0.31 m) and a quarter wide (`SPACE.hullBeam`, 0.18 m), in
its own units of body scale, so a drake is a larger obstacle than a duckling —
plus a little water between hulls: `SPACE.familyGap` (0.03 m) for a mother and
her brood, a pair of mates or siblings, `SPACE.strangerGap` (0.10 m) between
strangers. It is a nudge first: steering eases every duck off the space its
neighbours hold, from `SPACE.reach` 1.3× the hull distance out (up to
`SPACE.push` 0.28 m/s), which is what keeps ordinary meetings from turning
into contact. Anything that does touch is settled after everyone has moved:
`Pond.separate()` measures every pair against
that hull distance and shares out any overlap, then takes the closing speed out
of the pair, so the next frame cannot drive them straight back in. A duck that
cannot give ground (a bank at its back) hands its share to the other; a
sleeper or an upended duck takes a quarter share and is given no new velocity
at all, so nothing can set it adrift or swing it about. Two ducks therefore
meet nose to nose further apart than they meet flank to flank, and a scrum on
one crust shoulders up like real dabbling ducks instead of intersecting.
- **Anchored poses**: a duck in `sleep` or `upend` takes no steering input at
all — no wander, no drift on the spot, and no slow turn as its mark shifts.
Only a neighbour's crowd-avoidance push can move it (a quarter of the parting
push above, and never any new velocity). A duck standing on its
bill is planted in the mud; if it kept paddling after a mark it would drag the
pose off the spot it chose.
- **The waddle**: a duck on land hinges each leg exactly once, at the hip, and
keeps the step on the ground. Each leg is a hip group (`hipL`/`hipR`), an
ankle group and a foot mesh, so the sole can be held flat while the shank
swings; both sides share one shank and one foot geometry, mirrored by the
hinge's own position. Through the stance (`p < DUCK.legs.stance`, 0.62 of a
cycle) the hinge angle is the arcsine of a linear ramp —
`asin(sin(DUCK.legs.swing)·(2p/DUCK.legs.stance − 1))` — which is exactly the
angle that holds a foot of length `DUCK.legs.length` (0.15) on the spot it
landed on while the body crosses the ground over it: the planted sole never
skates, and it lies flat the whole time, because the ankle cancels the hinge.
The swing carries the foot back to the front on a smooth ease, sole lifted
clear of the ground (`DUCK.legs.lift`, 0.022, the shank telescoping up into
the belly) and toe up. A step covers
`2·DUCK.legs.length·sin(DUCK.legs.swing)`, about 0.14 m per unit of body
scale, spread over `DUCK.legs.stance` of the cycle, so the cadence falls
straight out of the speed —
`speed·DUCK.legs.stance/(2·DUCK.legs.length·sin(DUCK.legs.swing)·scale)`,
near 1.9 cycles/s at a hen's cruise, quicker for a duckling's shorter legs
because the
step in metres shrinks with it. The body rides the leg that is on the
ground — the one hanging lowest of them — at exactly that leg's vertical
reach (`legReach`), so the planted sole holds its height as well as its
spot, and the body rides back up out of the lean, which presses the outside
foot into the ground by as much again; what is left over is the step's own
weight: high over the straight planted leg, low as the feet spread. Over
that it leans onto the planted foot (`DUCK.waddle.roll` 0.13 rad) and the
stern answers once a step (`DUCK.waddle.wag`). The hips answer the lean in
turn, so the legs hang plumb and the body rocks *over* the planted foot
rather than swinging the foot through an arc — which is what holds the sole
still
sideways as well as fore-and-aft. A duck without a step's worth of weight
in it — standing, floating, or swimming with the legs tucked — damps
`stride` to 0, so the legs ease back under the body and a stop never
freezes mid-stride. Measured on the ice with `scripts/gait.mjs` (a hen at
body scale 1.46, walking 0.45 m/s): the planted sole holds its spot — no
forward travel at all over a stance, and sideways under 2% of the ground the
body covers — and its height to within a centimetre, most of that the body's
own breathing. The swing sole clears by up to 4.7 cm, the lean runs ±5°, and
the body rides 1.4 cm per unit of body scale through a cycle. Cadence came
out at 1.32 cycles/s against 1.32 predicted, sweeping 100% of the ground it
crossed.
- **Bottom feeding (`upend`)**: the whole body tips forward about the waterline
pivot — the duck's own `root` origin, ~9 cm under the surface, roughly the
body's centre, which is where a real upender turns. `DUCK.upend.pitch` is
1.62 rad (a little past vertical) reached over a 0.8 s ramp, held with the feet
paddling and the stern wagging, then rocked back in 0.75 s. The `dabble`
activity offers it only to a duck that is afloat — never out on the lid — and
only where `depthAt > DUCK.upend.depth` (0.8 m), so the bill really is in the
mud; ducklings never upend. Rings and droplets fire at both ends of the move.
Measured: the head goes ~0.24 m under the surface (visible through the
transparent water) and the tail ~0.47 m above it.
- **Camera**: first person, planted on the shore. Drag turns yaw (right = turn
right) and pitch (down = the gaze drops); pinch and wheel change the field of
view. The eye never moves, so the pond keeps its place and only the gaze
swings across it. Reeds are kept clear of the water directly in front of the
stand (`WORLD.viewStand`), so no stem parks itself in the eye.
Zooming has no centre of its own — it narrows the frame around wherever the
gaze rests — so the gaze rests a touch below level (`CAMERA.pitchStart`,
−0.06 rad): that is the line a full lean-in keeps, and it frames the whole
flock from the near families to the far band.
## The loading card
The pond is held behind a card for its first moment: the view it opens on, shot
without a duck in it and thrown out of focus, with "quack ... quack" coming up a
letter at a time over it. When the world is up, the card fades away and the live
pond is left behind, framed the same way.
- **The face**: `public/pond-loader-portrait.jpg` and `-landscape.jpg`, shot by
`scripts/loader-shot.mjs` from the camera's opening pose (summer, default
seed, `app.paused`), with the flock and the particle pools hidden. Progressive
JPEG (mozjpeg, 4:2:0, q50) at 480 and 880 px wide — 3.9 and 5.4 kB, so the
card has its picture before the module is down.
- **The wash**: `filter: blur(17px)`, with the image stretched to the element
(`100% 100%`) rather than cover-cropped. Under that blur the stretch never
shows, and it keeps the far shore on the same line of the screen as the live
camera's at any aspect ratio — card 43.0–43.4 % down, live 44.0 %, measured on
a phone and a 4:3 tablet — so the handover has no jump to hide.
- **The word**: five spans — word, three dots, word — each easing out of a 6 px
blur and *fully in* before the next starts (word 0.38 s, dot 0.2 s, 0.05 s
between; 1.61 s end to end), so the line reads in five distinct steps rather
than as one smear.
- **The handover**: `hideLoadingSoon()` keeps the card to `LOADER.min` = 1950 ms
from navigation — enough for the last word even on a fast load, with a beat on
the finished line — then `.done` fades it over `LOADER.fade` 900 ms and removes
it `LOADER.removeDelay` 400 ms later. The class also drops `pointer-events`,
so the pond can be dragged while the fade is still going.
`prefers-reduced-motion` puts the line up at once and fades in 0.3 s.
## The title cards
Five lines the pond cannot show for itself come up low over the water after the
loading card has gone, one at a time, and leave again: the throw instruction,
the look instruction, the zoom instruction, the credit to celphase's duck-pond,
and the source card.
- **One line, one card**: `HINTS.tipHold` 3 s holds for the three tips,
`HINTS.creditHold` 7.5 s for the credit and `SOURCE.hold` 6.5 s for the
source, `HINTS.fade` 0.6 s fades (the CSS runs 0.55 s), `HINTS.gap` 0.5 s of
quiet water between. The first starts 1.9 s after the loading card begins to
fade — with a normal boot that is about four seconds after navigation, so the
pond is revealed first and the tips come second.
- **The source card**: `SOURCE.src_url` in `src/config.ts` is the public
repository. When it is a non-empty string the card opens that URL in a new
tab; when it is empty or null the card instead offers
`public/quack-source.zip`, a download of this working tree. The zip is built
by `scripts/source-zip.mjs` from `src/`, `public/`, `scripts/`, `docs/`,
`audio/` and the root project files, and leaves out `node_modules`, `dist`,
`temp`, `duck-pond`, `.wrangler` and every local credential file. The build
runs it before Vite, so the copy in `dist/` is current. It is generated, not
committed.
- **Source maps**: `vite.config.ts` sets `build.sourcemap: true`, so every
production build ships its `.js.map` beside the bundle. That keeps the live
site debuggable and lets anyone compare the deployed code with the source zip
module by module.
- **Device wording**: `(hover: none) and (pointer: coarse)` picks "tap to throw
bread", "drag to look around" and "pinch to zoom"; a fine pointer gets
"click", "drag" and "scroll to zoom". The wording follows the *primary* input,
so a touchscreen laptop still gets mouse words. The controls are described as
they are: drag turns the gaze, pinch or wheel changes the field of view.
- **Hardly there**: an 0.33-alpha panel with a 9 px backdrop blur, a 1 px border
and a 13 px radius — legible over bright water without turning into a HUD. The
container is `pointer-events: none`; only the links re-enable the pointer, so
taps and drags pass through the card to the pond everywhere but the link
itself.
- **Stays for the link**: on hover-capable devices the card does not leave while
the pointer is on its link, so the credit or source can be read or followed at
leisure. `prefers-reduced-motion` collapses the fades to 0.01 s; the cards
still come and go.
## Debugging
- `window.quack = { app, world, seasons, effects, pond, rig, camera, renderer, scene }`
is set once the pond is up; `app.paused`, `app.timeScale` and `seasons.hold(i)`
are the quick levers. `world.ice`, `world.iceAt(x, z)` and `world.solidAt(x, z)`
answer what the freeze is doing where.
- `app.step(raw = 1/60)` advances the world by exactly `raw` seconds whatever
the clock is doing — pause and `timeScale` are ignored — and renders, so a
probe can hold `app.paused = true` and walk the sim in fixed slices without
the frame rate colouring the answer. `scripts/gait.mjs` uses it to film and
measure the waddle; each duck carries its two feet at `d.feet`
(`[footL, footR]`, index 0 is the +x side) for a probe to watch, and
`d.stride`, `d.walkPhase`, `d.roll` and `d.vel` say what the gait is doing,
and `d.sit` how far a sleeping duck has settled onto its belly.
- Holding winter: `seasons.hold(3)` glides there in about a second. To catch the
freeze part way, hold it with `app.timeScale = 1` and poll `world.ice` on a
timer, pausing the app as it passes the value you want — 0.4 is a good
mid-freeze, lid advancing and the lead still wide. `world.waterHeightAt(0, 45, t)`
is the check that the shore has stopped moving: every `t` must give the same
number, or the lid at the edge of a frozen pond is still heaving; the same
check on screen is two renders of the lid two seconds of clock apart with every
mesh but the water hidden — 0 differing pixels, while the pool in the same
frame moves.
- `pond.force(name, activity, left)` pins a duck; `pond.throwBread(x, z, ...)`
lands a piece wherever you like; `pond.setCursor(nx, ny)` / `setCursor(null)`
drives the gaze (works headless).
- Forcing a flight: `d.setActivity('fly')` then
`pond.beginFlight(d, { x, z })` (the target needs open water) — the pond flies
it from where it stands while the rig poses it. `d.flying`, `d.flyH` (height
over the water), `d.flyPhase` (0–1 through the pass) and `d.flyPitch` say what
it is doing; `d.flight` is the plan itself. Walking `app.step` through a pass
at 0.05 s slices gives the glide and the landing for a shot. `d.flyCool` is
the seconds left before that bird will take the wing again.
- Forcing a lap: `pond.startLap(d, true)` (the flag is the wedge test) returns
whether it found a round, and `d.lap` holds it (`x`, `z`, `r`, `a`, `dir`,
`wob`, `left`). The path is measured from the plan, not the frame: sample
`hypot(d.pos.x - d.lap.x, d.pos.z - d.lap.z) / d.lap.r` each second (it holds
~0.9–1.1), and add up the signed heading change around `d.facing` — the net
turn must have the sign of `d.lap.dir` and about its size (turns × 2π).
- Bread in the air: `pond.sees(d, x, z)` is the sight cone (awake, within
`SIGHT.range`, inside `SIGHT.half` of the bill); `d.notice` is the seconds
until that duck sets off, so a probe can read the early reaction before the
splash. `pond.maybeFlyToFood(d)` makes the fly-or-swim roll for the nearest
unclaimed piece; `piece.claimed` says who is coming for it.
- Curiosity: `pond.curious` is the seconds until the flock may notice the visitor
again; `pond.begPoint(d)` is where that duck would come to, and 0 if there is
no water it can reach in front of the stand. `pond.force(name, 'beg', left)`
poses a visit for a shot.
- Overlap checks: `pond.contact(a, b, ux, uz)` is the centre distance at which
two hulls touch — reach it through a unit vector and compare with the real
gap, so a probe can report the worst clearance in a crowd rather than trusting
a screenshot. `pond.separate()` can be called by hand to settle a planted
crowd before a shot.
- Shore: `world.shore` is the walkable section; `world.shorePoint(x, across)`
returns a point on it (`across` 0 = water's edge, 1 = inland edge),
`world.shorePointAt(t)` marches it west to east, `world.onShore(x, z)` tests a
spot and `world.shoreNormal(x)` gives the inland direction.
- Screenshots: `node scripts/shot.mjs <url> <out> [w] [h] [waitMs] ["js"]` —
the optional JS string runs in the page first (to pose the camera or season).
Output goes under `temp/`, which is git-ignored.
- The loading card is plain HTML/CSS in `index.html`, up before any module is:
`document.getAnimations()` lists its five span animations, so a probe can
pause them and seek a frame (`a.currentTime = ms`). `scripts/loader-shot.mjs
<url> <out> [w] [h] [dpr] [outWidth] [q]` re-shoots its backdrop from the
pond's opening pose and writes a progressive JPEG (needs
`npm i --no-save sharp`; the raw PNG lands in `temp/`). Hide the flock
(`d.root.visible = false`), the particle pools and the ripples first — the
card is the pond with no bird in it.
- Reading a shot back as data: `node scripts/sample.mjs <png> [x0..1]` prints the
colour down that column, sampling every 2.5 % of the height. Colours on the way
down a column settle questions a glance cannot ("is that band the bank or the
shallow water?"): compare the shot against the same frame with
`world.water.visible = false`.
- Forcing a sleep pose: `m.setActivity('rest')`, `m.activityLeft = 600`, wait
~0.5 s, then `m.startAction('sleep', 600)`, wait ~2.6 s. `m._sleepSide = 1`
pins which way the head turns. A duckling needs the pair re-asserted every
~100 ms — family glue replaces the action otherwise.
- Freezing an action mid-pose: re-assert `d.startAction('upend', 4.5)` and
`d.actionTime = d.actionDuration - 1.6` every ~16 ms to hold it in the middle
of the move, and pin `pos`/`target`/`facing` in the same tick — `force()`
clears the action, and a drifting duck is a moving frame. `app.timeScale = 6`
fits a whole upend cycle into the shot window.
- Camera: the eye is planted, so pose it directly — `rig.look(yaw, pitch, fov)`
aims it at once with no easing, `rig.stand(x, z)` puts the stand down
somewhere else (it re-reads the ground), then `app.paused = true` freezes the
scene for the shot.
- TypeScript 7 quirk: `instanceof` does not narrow `unknown` values from
`import * as THREE`; use explicit casts.
## Open threads
- **Night**: the seasons already carry the sun across the year; a proper
day/night cycle within a season is the next step.
- **Audio**: synthesis, propagation and spatialization are wired (v2) — the
Rust `Stage` behind `src/audio.ts`. What is still ours to do is the finer
matching: a call's gesture and its sound are offset by the worker queue
(`AUDIO.queue`), the dabbling ripple rhythm is a single Rust patter rather
than visual ripples, and bread lands as a pea plop scaled up. An attention
model for the mix (who deserves the foreground) is the open question, not the
synth.