Files
oikos/plans/2026-07-20-mascot-physics-audit.md
dtoro d82095213a
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
fix(web): mascot physics, drag reliability, and speech-bubble polish
Audits and fixes ground-teleport/flat-fall/toss-momentum physics bugs,
fixes drag getting stuck via missing pointercancel handling, replaces
sprite-based speech bubbles with real HTML text/emoji bubbles, adds
drag-onto-icon "investigate" reactions and idle chatter, merges the
name badge and reaction bubble into one floating element, and caps the
bubble to one line with a teleprompter-style auto-scroll instead of
ellipsizing overflow text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 23:23:30 +02:00

16 KiB
Raw Blame History

2026-07-20 — Mascot physics/window-interaction audit + improvement plan

Status: P0P2 implemented. P3 ("cool stuff") ideas remain open/undecided.

Verification of the fixes (re-ran this document's own instrumented tests against the fixed code):

  • Case A (window closes under the mascot): position now falls smoothly over ~2.53s with visible x-drift (e.g. bottom went 84→87→104→125→...→912 across ~3.3s), instead of jumping straight to the floor in one tick.
  • Case B (window opens over a grounded mascot with a gap beneath it): the mascot stayed pinned to the floor (bottom: 950) for 2.6s straight while standing under an open window whose top was far above it — no snap-up at all.
  • Toss momentum: a fast upward-and-sideways release made the sprite keep rising for several frames after pointerup before gravity won, then fall with visible deceleration bumps roughly every ~550ms (the flap cycle) instead of a flat monotonic increase.
  • No new console errors; npm run build stays clean.

Companion to plans/2026-07-20-desktop-mascot.md (the original scaffolding plan, now implemented) and docs/mascot/README.md (the MBSE model). This document is a post-implementation review: static code read of every file under web/src/lib/mascot/, plus live testing in the browser (dragging, opening/closing/moving windows under the mascot, the radial menu, hatching), including two tests instrumented with synthetic pointer events + high-frequency position polling to get hard timing data rather than guessing from a laggy screenshot loop.

Summary

The scaffolding (registries, FSM shape, persistence, stimulus bus) is sound and matches the original plan's architecture. The actual physics is where it falls short of feeling alive, for one root cause plus a few smaller gaps:

The mascot doesn't actually fall in the cases that matter most — it teleports. The only code path where a real, animated fall happens is "user drags it into the air and lets go." Every other ground-change case (a window closes or moves out from under it, a window opens or moves under it, it walks off a window's edge) snaps its position instantly, with zero animation, because of one specific piece of logic in Mascot.svelte. This was proven with instrumented timing data, not just read from the source — see Finding 1.

How this was tested

  • Read every file in web/src/lib/mascot/ (behavior.ts, Mascot.svelte, MascotLayer.svelte, state.svelte.ts, stimuli.ts, sprites.ts, render.ts, actions.ts, RadialMenu.svelte, NameDialog.svelte, types.ts).
  • Ran the app (npm run dev), hatched a chick, and interactively tested: drag-and-release at various heights, opening/closing/dragging a window under the mascot, the right-click menu (including nested Feed), plain-click pet, and the hatch dialog.
  • For the two timing-sensitive claims below, screenshot-based verification was too slow/laggy to distinguish "instant teleport" from "fast but real fall" — so both were re-verified with a single javascript_exec call that dispatches synthetic PointerEvents to drag the mascot precisely onto a window, then clicks that window's close button and polls canvas.getBoundingClientRect() every ~65ms for 2+ seconds, all inside one script (no inter-call latency to contaminate the result).
  • npm run build passes with no new warnings.

Findings

Finding 1 (Critical) — Ground changes teleport the mascot instead of animating a fall or rise

Root cause, web/src/lib/mascot/Mascot.svelte tick() (~lines 111-131): every tick, computeGroundAt(runtime.x) recomputes the ground line, and if the mascot is "grounded" (not already falling/dragged) and the ground changed at all, this runs unconditionally:

if (runtime.behavior !== 'falling' && runtime.behavior !== 'dragged' &&
    runtime.y >= prevGroundY - 1 && newGround !== prevGroundY) {
  runtime.y += newGround - prevGroundY   // instant, any magnitude, either direction
}

This was meant to make the mascot "ride along" smoothly while a window it's standing on is being dragged (and it does do that correctly — verified, see below). But it fires for any ground change, not just a smooth drag, and it runs before stepMascot()/behavior.ts gets a chance to notice "I'm now floating" and start a real falling behavior — so the FSM's own fall-detection in the wander/idle cases (if (rt.y < groundY(rt) - 1) forceBehavior(rt, 'falling')) never actually fires; by the time it runs, runtime.y has already been silently snapped to match.

Proven case A — window closes underneath the mascot (should fall): dragged the mascot onto an open window's title bar via synthetic pointer events (landed cleanly: sprite bottom = 96px = window top = 96px), then clicked the window's close button and polled position every 65ms:

t (ms) sprite bottom (px)
0 (before close) 96
67 950 (floor)
132 2000 950 (unchanged)

The coded physics (gravity 1400 px/s², capped at 320 px/s) would take ~2.8 seconds to fall 854px. It happened in under 67ms — an instant snap, not a fall. No falling/land animation plays.

Proven case B — window opens/overlaps underneath a grounded mascot (should NOT rise, or should climb visibly): with the mascot standing on the empty desktop floor (bottom = 950px), opened the Tasks window (which renders top=71px, bottom=751px at that position — its underside never reaches the floor, leaving a ~200px gap). Within 150ms, the mascot's sprite bottom was already 71px — snapped straight up onto the new window's title bar, 880px in under 150ms, despite the window's bottom edge (751px) never actually touching the mascot's original position. computeGroundAt() has no check that the candidate window is anywhere near the mascot's current position — it just returns the topmost window overlapping the mascot's x column, full stop, so any window opening/moving/resizing anywhere in that column instantly relocates the mascot to its top edge, no matter the vertical distance.

What does work correctly: dragging an already-mascot-bearing window smoothly (title-bar drag, not open/close) — the ride-along correctly translates the mascot's y by the same delta as the window moves, so it visually "stands" on the window through the drag. Also, manual drag-and-drop of the mascot itself (pick it up, release above ground) does enter a real, animated fallinglandidle sequence, because that path is driven entirely by releaseFromDrag() from the pointer handler, which isn't touched by the tick-level snap.

Fix direction: computeGroundAt needs to return the highest surface at or below the mascot's current y* (a downward raycast from the current position), not the global topmost window in the column. Separately, the tick-level "ride along" needs to distinguish *small, continuous* deltas (the window carrying the mascot while being dragged — legitimate instant translation) from *large or discontinuous* ones (a window appearing, disappearing, or the mascot walking off an edge — should hand off to forceBehavior(rt, 'falling')for a downward change, and a short newrising`/hop transition for an upward one, not a silent teleport in either direction).

Finding 2 (Critical) — the one real fall is a flat, straight drop; this is the user's specific complaint

Even in the one path that does animate (manual drag-release), the fall itself has no attempt at flight:

  • falling.enter() in behavior.ts hard-sets rt.vx = 0 — zero horizontal drift, ever.
  • fall-flutter's animation is just jump.png on a loop (sprites.ts) — the name says flutter, the physics is a monotonic vy = min(TERMINAL_VY, vy + GRAVITY*dt) capped fall, no oscillation, no upward impulses.
  • There's an already-defined, already-loaded flap animation (jump.png again, distinct AnimName) that no behavior ever references — it's dead weight in the registry right now.

This is exactly the "not always fall directly, try to fly a little" ask.

Finding 3 (Critical) — no toss/throw momentum on release

The original plan called for tracking recent pointer deltas during a drag and using them to give the release a real velocity (a toss/arc). The shipped onPointerUp/onPointerMove in Mascot.svelte track no pointer history at all — releasing while moving fast imparts nothing; the mascot just drops straight down from wherever the pointer let go, same as a slow release.

Finding 4 (Moderate) — sprite/name/bubble clip off-screen near the top edge

The mascot's canvas is 20×28 logical px (extra headroom above the sprite for the name label and reaction bubble), positioned bottom-anchored at runtime.y. When the ground is near the top of the viewport (e.g. standing on a window whose title bar sits close to y=0, which is common for a freshly-opened window), the canvas — and the name label, positioned even further above it — render partially or fully off-screen. Reproduced directly: standing on a window with top=32px clipped the sprite from y=-52 to y=32, more than half invisible above the browser viewport.

Finding 5 (Minor) — interruptsSleep is defined but never read

stimuli.ts's ReactionDef.interruptsSleep (set true only on alarmed) documents an intended rule ("sleep is broken only by reactions that opt in"), but nothing in MascotLayer.svelte's dispatch callback or behavior.ts ever reads it — every reaction unconditionally calls forceBehavior(runtime, 'react', ...) regardless of current behavior. Practically, this also means a reaction can visually interrupt an active drag (the sprite briefly shows a reaction animation mid-drag, though position tracking is unaffected since that's driven separately by the pointer handler) — the documented "dragged always wins" rule isn't enforced either.

Finding 6 (Cosmetic / scope gap) — radial menu isn't round

The implementing agent deviated from the original "round, Sims-style" requirement to a vertical rounded-button column (documented in the plan's deviation note — the polar ring layout hid labels). It works correctly, including nesting, but it's a direct miss against what was asked for. Worth a deliberate decision: keep the readable column, or revisit a true ring with icon-only buttons + a hover/center text readout.

Finding 7 (Minor) — first-hatch naming can be dismissed with no easy way back

NameDialog's Escape handler always calls onCancel, which just closes it — on the very first hatch prompt (no Cancel button is shown in hatch mode, but Escape still works via the window-level listener), a user who hits Escape is left with an unnamed, un-hatched egg and no obvious way to reopen the dialog short of reloading or finding the Debug → Force hatch menu action.

Improvement plan

Ordered by priority; 13 directly address the user's stated complaints.

P0 — Fix the ground-detection/teleport bug (Finding 1)

  1. Change computeGroundAt(x) to only consider a window a ground candidate if its top edge is at or below the mascot's current y (plus a small tolerance for the "about to land on it" case) — i.e. the nearest surface underneath, not the global topmost overlapping window.
  2. Replace the unconditional tick()-level position snap with a threshold check: deltas under ~4px/tick (a window being smoothly dragged with the mascot riding it) still translate instantly; anything larger routes through forceBehavior(rt, 'falling') (ground dropped) or a new short rising behavior (ground rose — a quick hop/flutter-up, not a snap).
  3. This also fixes the FSM's existing (currently unreachable) wander/idle fall-detection — once the snap isn't preempting it, that code path should work as originally intended.

P1 — Make falling actually look like an attempt at flight (Findings 2 & 3)

  1. Wire the unused flap animation into falling: instead of one continuous fall-flutter loop, alternate short flap bursts (each burst applies a brief small negative vy impulse — a wing-beat that measurably slows the descent for a few frames) with fall-flutter glide segments. Net effect: still descends, but in a scalloped, fluttering arc rather than a flat monotonic line — reads as "trying to fly, not quite making it" rather than "dropped like a rock."
  2. Add a small horizontal drift during falling (e.g. a slow sine wobble or a fraction of the pre-release pointer velocity — see next point) so the fall isn't perfectly vertical either.
  3. Track a short rolling history of pointer positions during dragged (last ~100ms of onPointerMove samples is enough) and derive a release velocity from it in onPointerUp; feed that into falling's initial vx/vy instead of hard-zeroing them, so a fast toss actually arcs.

P2 — Cosmetic/correctness cleanups (Findings 4, 5, 7)

  1. Clamp the sprite's screen-space draw position (or reserve top margin on the surface) so the canvas/name/bubble never render above y=0, independent of where the logical ground sits.
  2. Either wire interruptsSleep/a drag-guard into the reaction dispatch path in MascotLayer.svelte (skip forcing react while runtime.behavior === 'dragged', and gate sleep-interruption on the flag as documented), or remove the field if the current always-interrupts behavior is actually preferred — right now it's an unenforced contract, which is worse than either explicit choice.
  3. On first hatch, prevent the naming dialog from being fully dismissed without a name (or make it trivially reopenable — e.g. clicking the still-unnamed egg reopens it) rather than requiring a reload/debug menu to recover.

P3 — Ideas worth considering ("cool stuff")

Not committed, listed for discussion:

  • Investigate badges: have the mascot occasionally walk toward a desktop icon that currently has an unread badge (Signals, Operations) and peck at it curiously — a very literal, delightful expression of "aware of its environment" using icon positions already in stores/icons.ts.
  • Startle-and-flee on alarm: instead of a static react-alarm frame, have the alarmed reaction actually scurry the mascot a short distance (reuse wander-style motion) before settling, more visceral than a still reaction sprite.
  • A "home" spot: remember a preferred idle location (e.g. near its hatch point or a favorite window) and occasionally wander back to it, giving its roaming a sense of place rather than pure randomness.
  • True round radial menu v2: revisit Finding 6 with icon-only buttons on an actual ring and a text label in a tooltip/center readout on hover/focus — closer to the original ask while keeping labels legible (the problem the first attempt hit).
  • Distinct adult sprite (already flagged as deferred polish in the original plan's deviation note) — currently chick and adult share art.

Verification (once fixed)

  • Re-run this document's two instrumented tests (drag-onto-window-then-close; open-window-over-grounded-mascot) and confirm the position samples show a smooth multi-frame transition instead of a single-tick jump.
  • Manually: drag the mascot up and release with a fast flick — confirm it arcs/drifts rather than dropping straight down, and that flap frames visibly appear during the descent.
  • Stand the mascot on a window, drag that window so its title bar approaches y=0 — confirm the sprite/name/bubble stay on-screen.
  • Trigger a reaction (e.g. force an eureka) while mid-drag — confirm the sprite keeps showing the dragged animation, not the reaction, until released (if Finding 5 is fixed by enforcing the guard).
  • npm run build stays clean.