16 KiB
2026-07-20 — Mascot physics/window-interaction audit + improvement plan
Status: P0–P2 implemented. P3 partially implemented: the physics-feel
round shipped (panic-flap cycle with speed scaling + jitter, soft
terminal-velocity drag, one-bounce impact restitution, landing skid, wall
ricochet, squash-and-stretch impact spring, air/drag tilt, walk bob,
impact feather-poof particles, and a new idle-selectable hop behavior —
all in behavior.ts + Mascot.svelte's render layer, no new assets).
The remaining P3 feature ideas (investigate badges, startle-and-flee, a
"home" spot, round radial menu v2, distinct adult art) stay open.
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.5–3s 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 buildstays 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_execcall that dispatches syntheticPointerEvents to drag the mascot precisely onto a window, then clicks that window's close button and pollscanvas.getBoundingClientRect()every ~65ms for 2+ seconds, all inside one script (no inter-call latency to contaminate the result). npm run buildpasses 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 falling → land → idle 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()inbehavior.tshard-setsrt.vx = 0— zero horizontal drift, ever.fall-flutter's animation is justjump.pngon a loop (sprites.ts) — the name says flutter, the physics is a monotonicvy = min(TERMINAL_VY, vy + GRAVITY*dt)capped fall, no oscillation, no upward impulses.- There's an already-defined, already-loaded
flapanimation (jump.pngagain, distinctAnimName) 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; 1–3 directly address the user's stated complaints.
P0 — Fix the ground-detection/teleport bug (Finding 1)
- Change
computeGroundAt(x)to only consider a window a ground candidate if its top edge is at or below the mascot's currenty(plus a small tolerance for the "about to land on it" case) — i.e. the nearest surface underneath, not the global topmost overlapping window. - 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 throughforceBehavior(rt, 'falling')(ground dropped) or a new shortrisingbehavior (ground rose — a quick hop/flutter-up, not a snap). - This also fixes the FSM's existing (currently unreachable)
wander/idlefall-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)
- Wire the unused
flapanimation intofalling: instead of one continuousfall-flutterloop, alternate shortflapbursts (each burst applies a brief small negativevyimpulse — a wing-beat that measurably slows the descent for a few frames) withfall-flutterglide 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." - 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. - Track a short rolling history of pointer positions during
dragged(last ~100ms ofonPointerMovesamples is enough) and derive a release velocity from it inonPointerUp; feed that intofalling's initialvx/vyinstead of hard-zeroing them, so a fast toss actually arcs.
P2 — Cosmetic/correctness cleanups (Findings 4, 5, 7)
- 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. - Either wire
interruptsSleep/a drag-guard into the reaction dispatch path inMascotLayer.svelte(skip forcingreactwhileruntime.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. - 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-alarmframe, have thealarmedreaction actually scurry the mascot a short distance (reusewander-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
flapframes 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 thedraggedanimation, not the reaction, until released (if Finding 5 is fixed by enforcing the guard). npm run buildstays clean.