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