# 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 build` stays clean. Companion to [plans/2026-07-20-desktop-mascot.md](2026-07-20-desktop-mascot.md) (the original scaffolding plan, now implemented) and [docs/mascot/README.md](../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 `PointerEvent`s 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: ```js 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 new `rising`/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; 1–3 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) 4. 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." 5. 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. 6. 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) 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. 8. 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. 9. 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.