Kokonkun mascot upgrade — Codex technical spec

Generated 2026-05-04 via /codex MCP integration. Asked Codex (gpt-5.5) to produce technical specs for upgrading src/components/CoroneMascot.tsx to a more fluid Moonlighter-style 8-bit feel.

This page is the canonical reference for implementation. Code PRs should cite section numbers from here (e.g. “Implements §3 lookLeft keyframes per Codex spec”).


1. Animation Timing Best Practices For 8-Bit Web Characters

Frame counts

Pose TypeFramesFrame TimeNotes
idle / breathe4-6180-260msSlow, loopable, tiny deltas
blink overlay1-360-90msFast, independent overlay
reaction3-680-140msSnappy anticipation + hold
locomotion4-880-120msWalk slower, run faster
emotional pose4-7100-180msNeeds readable silhouette
transition pose3-580-130msWake, dizzy, settle, etc.

Good defaults

const TIMING = {
  idleFrameMs: 220,
  walkFrameMs: 120,
  runFrameMs: 80,
  reactionFrameMs: 100,
  blinkMs: 70,
  poseTransitionMs: 160,
  quickTransitionMs: 90,
  sleepWakeTransitionMs: 450,
};

8-bit motion rule

Use stepped keyframes for pixel-art readability, but tween the SVG group transforms underneath. The visual result should feel smooth while still landing on chunky readable poses.

Good pattern:

// visual keyframe changes every 100ms,
// transform tween updates every rAF frame

Linear vs eased

Use linear for:

  • walk/run cycles
  • looping bobbing
  • orbiting sparkles
  • repeated particle drift
  • frame-index advancement

Use ease-in-out for:

  • head turns
  • arm waves
  • sleepy transitions
  • breathing amplitude changes
  • cursor tracking

Use ease-out-back or cubic overshoot for:

  • cheer
  • celebrate
  • jump landing
  • confused head tilt
  • wake sit-up

Use ease-in for:

  • falling
  • getting tired
  • being pulled downward
  • pre-jump crouch

Recommended cubic-beziers:

const EASE = {
  linear: [0, 0, 1, 1],
  inOut: [0.42, 0, 0.58, 1],
  out: [0.16, 1, 0.3, 1],
  in: [0.7, 0, 0.84, 0],
  outBackLite: [0.34, 1.35, 0.64, 1],
  sleepy: [0.45, 0, 0.2, 1],
  snapCute: [0.2, 1.4, 0.35, 1],
};

Transition durations

const POSE_TRANSITIONS = {
  idleToLook: 120,
  lookToIdle: 150,
  idleToWave: 100,
  idleToWork: 180,
  tiredToSleepy: 450,
  sleepyToSleep: 700,
  sleepToWake: 600,
  wakeToIdle: 250,
  anyToError: 120,
  anyToCelebrate: 80,
  dragReleaseToDizzy: 100,
  dizzyToIdle: 400,
};

Avoid making every pose the same speed. Cute characters feel alive when ambient loops are slow, reactions are fast, and settling takes longer than impact.


2. Frame Interpolation Pseudocode

type PartName =
  | "helmet"
  | "face"
  | "head"
  | "eyes"
  | "mouth"
  | "heart"
  | "leftArm"
  | "rightArm"
  | "leftLeg"
  | "rightLeg"
  | "prop";
 
type Transform = {
  rot?: number;
  tx?: number;
  ty?: number;
  sx?: number;
  sy?: number;
  opacity?: number;
};
 
type PoseFrame = Partial<Record<PartName, Transform>> & {
  eyesState?: "open" | "blink" | "happy" | "dizzy";
  mouthState?: "neutral" | "smile" | "o" | "sleep";
};
 
type Easing = [number, number, number, number];
 
const DEFAULT_T: Required<Transform> = {
  rot: 0,
  tx: 0,
  ty: 0,
  sx: 1,
  sy: 1,
  opacity: 1,
};
 
function cubicBezier([x1, y1, x2, y2]: Easing) {
  // Newton-Raphson approximation, good enough for animation.
  function sampleCurveX(t: number) {
    return ((1 - 3 * x2 + 3 * x1) * t + (3 * x2 - 6 * x1)) * t * t + 3 * x1 * t;
  }
 
  function sampleCurveY(t: number) {
    return ((1 - 3 * y2 + 3 * y1) * t + (3 * y2 - 6 * y1)) * t * t + 3 * y1 * t;
  }
 
  function sampleDerivativeX(t: number) {
    return (3 * (1 - 3 * x2 + 3 * x1) * t + 2 * (3 * x2 - 6 * x1)) * t + 3 * x1;
  }
 
  return function solve(x: number) {
    let t = x;
 
    for (let i = 0; i < 5; i++) {
      const dx = sampleCurveX(t) - x;
      const d = sampleDerivativeX(t);
      if (Math.abs(dx) < 0.001 || Math.abs(d) < 0.001) break;
      t -= dx / d;
    }
 
    return sampleCurveY(Math.min(1, Math.max(0, t)));
  };
}
 
function lerp(a = 0, b = 0, t: number) {
  return a + (b - a) * t;
}
 
function normalizeTransform(t?: Transform): Required<Transform> {
  return { ...DEFAULT_T, ...t };
}
 
function interpolateTransform(a?: Transform, b?: Transform, t = 0): Required<Transform> {
  const from = normalizeTransform(a);
  const to = normalizeTransform(b);
 
  return {
    rot: lerp(from.rot, to.rot, t),
    tx: lerp(from.tx, to.tx, t),
    ty: lerp(from.ty, to.ty, t),
    sx: lerp(from.sx, to.sx, t),
    sy: lerp(from.sy, to.sy, t),
    opacity: lerp(from.opacity, to.opacity, t),
  };
}
 
function interpolateFrame(
  from: PoseFrame,
  to: PoseFrame,
  progress: number,
  easing: Easing = [0.42, 0, 0.58, 1]
): PoseFrame {
  const ease = cubicBezier(easing);
  const t = ease(Math.min(1, Math.max(0, progress)));
 
  const parts: PartName[] = [
    "helmet",
    "face",
    "head",
    "eyes",
    "mouth",
    "heart",
    "leftArm",
    "rightArm",
    "leftLeg",
    "rightLeg",
    "prop",
  ];
 
  const next: PoseFrame = {};
 
  for (const part of parts) {
    next[part] = interpolateTransform(from[part], to[part], t);
  }
 
  // Discrete sprite states should switch near the middle, not interpolate.
  next.eyesState = t < 0.5 ? from.eyesState : to.eyesState;
  next.mouthState = t < 0.5 ? from.mouthState : to.mouthState;
 
  return next;
}

React-idiomatic hook shape:

function useTweenedPose(targetFrame: PoseFrame, durationMs: number, easing: Easing) {
  const [current, setCurrent] = useState<PoseFrame>(targetFrame);
  const fromRef = useRef<PoseFrame>(targetFrame);
  const startRef = useRef<number>(0);
 
  useEffect(() => {
    fromRef.current = current;
    startRef.current = performance.now();
 
    let raf = 0;
 
    const tick = (now: number) => {
      const elapsed = now - startRef.current;
      const progress = Math.min(1, elapsed / durationMs);
 
      setCurrent(interpolateFrame(fromRef.current, targetFrame, progress, easing));
 
      if (progress < 1) {
        raf = requestAnimationFrame(tick);
      }
    };
 
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [targetFrame, durationMs, easing]);
 
  return current;
}

For 60fps, avoid allocating large nested objects for every particle and every body part if possible. For this mascot, object interpolation is fine, but memoize static frame definitions and only tween active parts.


3. Keyframe Specs For The 5 New Poses

lookLeft

pose: lookLeft
duration: 200ms
loop: false
easing: easeOut [0.16, 1, 0.3, 1]
frames:
  f0: {
    head: { rot: 0, tx: 0, ty: 0 },
    helmet: { rot: 0, tx: 0 },
    face: { tx: 0 },
    eyes: { tx: 0 },
    mouth: { tx: 0 },
    heart: { tx: 0 },
    leftArm: { rot: 0 },
    rightArm: { rot: 0 },
    eyesState: "open",
    mouthState: "neutral"
  }
  f1: {
    head: { rot: -5, tx: -1, ty: 0 },
    helmet: { rot: -4, tx: -1 },
    face: { tx: -1 },
    eyes: { tx: -1 },
    mouth: { tx: -1 },
    heart: { tx: -0.5 },
    leftArm: { rot: 2 },
    rightArm: { rot: -2 },
    eyesState: "open",
    mouthState: "neutral"
  }
  f2: {
    head: { rot: -10, tx: -2, ty: 0 },
    helmet: { rot: -8, tx: -2 },
    face: { tx: -2 },
    eyes: { tx: -2 },
    mouth: { tx: -1 },
    heart: { tx: -1 },
    leftArm: { rot: 3 },
    rightArm: { rot: -3 },
    eyesState: "open",
    mouthState: "neutral"
  }
trigger: mousemove within 200px of mascot center, debounce 50ms
exit: cursor leaves 240px radius -> reverse to f0 over 150ms

lookRight

pose: lookRight
duration: 200ms
loop: false
easing: easeOut [0.16, 1, 0.3, 1]
frames:
  f0: {
    head: { rot: 0, tx: 0, ty: 0 },
    helmet: { rot: 0, tx: 0 },
    face: { tx: 0 },
    eyes: { tx: 0 },
    mouth: { tx: 0 },
    heart: { tx: 0 },
    leftArm: { rot: 0 },
    rightArm: { rot: 0 },
    eyesState: "open",
    mouthState: "neutral"
  }
  f1: {
    head: { rot: 5, tx: 1, ty: 0 },
    helmet: { rot: 4, tx: 1 },
    face: { tx: 1 },
    eyes: { tx: 1 },
    mouth: { tx: 1 },
    heart: { tx: 0.5 },
    leftArm: { rot: 2 },
    rightArm: { rot: -2 },
    eyesState: "open",
    mouthState: "neutral"
  }
  f2: {
    head: { rot: 10, tx: 2, ty: 0 },
    helmet: { rot: 8, tx: 2 },
    face: { tx: 2 },
    eyes: { tx: 2 },
    mouth: { tx: 1 },
    heart: { tx: 1 },
    leftArm: { rot: 3 },
    rightArm: { rot: -3 },
    eyesState: "open",
    mouthState: "neutral"
  }
trigger: mousemove within 200px of mascot center, debounce 50ms
exit: cursor leaves 240px radius -> reverse to f0 over 150ms

wake

pose: wake
duration: 900ms
loop: false
easing: sleepy [0.45, 0, 0.2, 1]
frames:
  f0: {
    head: { rot: -8, tx: 0, ty: 5 },
    helmet: { rot: -8, ty: 5 },
    face: { ty: 5 },
    eyes: { ty: 5 },
    mouth: { ty: 5 },
    body: { ty: 4, sx: 1.04, sy: 0.94 },
    leftArm: { rot: 72, tx: -1, ty: 1 },
    rightArm: { rot: -68, tx: 1, ty: 1 },
    leftLeg: { rot: -8, ty: 2 },
    rightLeg: { rot: 8, ty: 2 },
    eyesState: "blink",
    mouthState: "sleep"
  }
  f1: {
    head: { rot: -4, tx: 0, ty: 3 },
    helmet: { rot: -4, ty: 3 },
    face: { ty: 3 },
    eyes: { ty: 3 },
    mouth: { ty: 3 },
    body: { ty: 3, sx: 1.02, sy: 0.97 },
    leftArm: { rot: 58, tx: -1, ty: -1 },
    rightArm: { rot: -55, tx: 1, ty: -1 },
    leftLeg: { rot: -5, ty: 1 },
    rightLeg: { rot: 5, ty: 1 },
    eyesState: "blink",
    mouthState: "o"
  }
  f2: {
    head: { rot: 3, tx: -1, ty: 1 },
    helmet: { rot: 3, tx: -1, ty: 1 },
    face: { tx: -1, ty: 1 },
    eyes: { tx: -1, ty: 1 },
    mouth: { tx: -1, ty: 1 },
    body: { ty: 1, sx: 0.98, sy: 1.04 },
    leftArm: { rot: 38, tx: -2, ty: -2 },
    rightArm: { rot: -35, tx: 2, ty: -2 },
    leftLeg: { rot: -3 },
    rightLeg: { rot: 3 },
    eyesState: "blink",
    mouthState: "o"
  }
  f3: {
    head: { rot: -3, tx: 1, ty: 0 },
    helmet: { rot: -3, tx: 1 },
    face: { tx: 1 },
    eyes: { tx: 1 },
    mouth: { tx: 1 },
    body: { ty: 0, sx: 1, sy: 1 },
    leftArm: { rot: 22, tx: -1, ty: 0 },
    rightArm: { rot: -22, tx: 1, ty: 0 },
    eyesState: "open",
    mouthState: "neutral"
  }
  f4: {
    head: { rot: 0, tx: 0, ty: 0 },
    helmet: { rot: 0, tx: 0, ty: 0 },
    face: { tx: 0, ty: 0 },
    eyes: { tx: 0, ty: 0 },
    mouth: { tx: 0, ty: 0 },
    body: { ty: 0, sx: 1, sy: 1 },
    leftArm: { rot: 0 },
    rightArm: { rot: 0 },
    leftLeg: { rot: 0 },
    rightLeg: { rot: 0 },
    eyesState: "open",
    mouthState: "smile"
  }
trigger: sleep -> any user activity
exit: after f4 -> idle

confused

pose: confused
duration: 700ms
loop: true
frameMs: 140ms
easing: easeInOut [0.42, 0, 0.58, 1]
frames:
  f0: {
    head: { rot: 0, tx: 0, ty: 0 },
    helmet: { rot: 0 },
    face: { tx: 0 },
    eyes: { tx: 0 },
    mouth: { tx: 0 },
    leftArm: { rot: 8, ty: -1 },
    rightArm: { rot: -8, ty: -1 },
    prop: { opacity: 0, tx: 8, ty: -22, sx: 0.8, sy: 0.8 },
    eyesState: "open",
    mouthState: "o"
  }
  f1: {
    head: { rot: -9, tx: -1, ty: 0 },
    helmet: { rot: -8, tx: -1 },
    face: { tx: -1 },
    eyes: { tx: -1 },
    mouth: { tx: -1 },
    leftArm: { rot: 20, ty: -2 },
    rightArm: { rot: -4, ty: 0 },
    prop: { opacity: 1, tx: 8, ty: -25, sx: 1, sy: 1 },
    eyesState: "open",
    mouthState: "o"
  }
  f2: {
    head: { rot: 7, tx: 1, ty: 0 },
    helmet: { rot: 6, tx: 1 },
    face: { tx: 1 },
    eyes: { tx: 1 },
    mouth: { tx: 1 },
    leftArm: { rot: 12, ty: -1 },
    rightArm: { rot: -16, ty: -1 },
    prop: { opacity: 1, tx: 10, ty: -26, sx: 1.05, sy: 1.05 },
    eyesState: "open",
    mouthState: "o"
  }
  f3: {
    head: { rot: -5, tx: -1, ty: 1 },
    helmet: { rot: -5, tx: -1, ty: 1 },
    face: { tx: -1, ty: 1 },
    eyes: { tx: -1, ty: 1 },
    mouth: { tx: -1, ty: 1 },
    leftArm: { rot: 18, ty: -2 },
    rightArm: { rot: -10, ty: -1 },
    prop: { opacity: 1, tx: 9, ty: -27, sx: 1, sy: 1 },
    eyesState: "blink",
    mouthState: "o"
  }
trigger: error notification, failed API request, validation error
prop: questionBubble
exit: after 1400-2200ms or next app state -> idle/work

celebrate

pose: celebrate
duration: 900ms
loop: false
frameMs: 90ms
easing: snapCute [0.2, 1.4, 0.35, 1]
frames:
  f0: {
    body: { ty: 0, sx: 1, sy: 1 },
    head: { rot: 0, ty: 0 },
    helmet: { rot: 0, ty: 0 },
    leftArm: { rot: 20, ty: -1 },
    rightArm: { rot: -20, ty: -1 },
    leftLeg: { rot: -4 },
    rightLeg: { rot: 4 },
    prop: { opacity: 0, tx: 0, ty: -24, sx: 0.7, sy: 0.7 },
    eyesState: "open",
    mouthState: "smile"
  }
  f1: {
    body: { ty: 2, sx: 1.08, sy: 0.9 },
    head: { rot: 0, ty: 1 },
    helmet: { rot: 0, ty: 1 },
    leftArm: { rot: 35, ty: 0 },
    rightArm: { rot: -35, ty: 0 },
    leftLeg: { rot: -8, ty: 1 },
    rightLeg: { rot: 8, ty: 1 },
    prop: { opacity: 1, tx: -8, ty: -26, sx: 0.9, sy: 0.9 },
    eyesState: "happy",
    mouthState: "smile"
  }
  f2: {
    body: { ty: -8, sx: 0.96, sy: 1.08 },
    head: { rot: -4, ty: -8 },
    helmet: { rot: -4, ty: -8 },
    leftArm: { rot: 78, ty: -6 },
    rightArm: { rot: -78, ty: -6 },
    leftLeg: { rot: 16, ty: -4 },
    rightLeg: { rot: -16, ty: -4 },
    prop: { opacity: 1, tx: 8, ty: -34, sx: 1.15, sy: 1.15 },
    eyesState: "happy",
    mouthState: "smile"
  }
  f3: {
    body: { ty: -4, sx: 1, sy: 1 },
    head: { rot: 5, ty: -4 },
    helmet: { rot: 5, ty: -4 },
    leftArm: { rot: 65, ty: -4 },
    rightArm: { rot: -65, ty: -4 },
    leftLeg: { rot: 10, ty: -2 },
    rightLeg: { rot: -10, ty: -2 },
    prop: { opacity: 1, tx: 0, ty: -38, sx: 1, sy: 1 },
    eyesState: "happy",
    mouthState: "smile"
  }
  f4: {
    body: { ty: 1, sx: 1.05, sy: 0.94 },
    head: { rot: 0, ty: 1 },
    helmet: { rot: 0, ty: 1 },
    leftArm: { rot: 45, ty: -1 },
    rightArm: { rot: -45, ty: -1 },
    leftLeg: { rot: -6, ty: 1 },
    rightLeg: { rot: 6, ty: 1 },
    prop: { opacity: 0, tx: 0, ty: -42, sx: 0.8, sy: 0.8 },
    eyesState: "happy",
    mouthState: "smile"
  }
trigger: success notification, completed task, saved setting
particles: sparkles, max 10, ttl 500-900ms
exit: f4 -> idle over 180ms

dance

pose: dance
duration: 1200ms
loop: true
frameMs: 120ms
easing: linear for frame stepping, easeInOut for tween
frames:
  f0: {
    body: { tx: 0, ty: 0, sx: 1, sy: 1 },
    head: { rot: 0, tx: 0, ty: 0 },
    helmet: { rot: 0, tx: 0 },
    leftArm: { rot: 28, ty: -1 },
    rightArm: { rot: -18, ty: 0 },
    leftLeg: { rot: -8, ty: 0 },
    rightLeg: { rot: 8, ty: 0 },
    heart: { sx: 1, sy: 1 },
    eyesState: "open",
    mouthState: "smile"
  }
  f1: {
    body: { tx: -2, ty: -2, sx: 0.98, sy: 1.04 },
    head: { rot: -7, tx: -1, ty: -2 },
    helmet: { rot: -6, tx: -1, ty: -2 },
    leftArm: { rot: 48, ty: -3 },
    rightArm: { rot: -8, ty: 1 },
    leftLeg: { rot: -16, ty: -1 },
    rightLeg: { rot: 4, ty: 1 },
    heart: { sx: 1.06, sy: 1.06 },
    eyesState: "happy",
    mouthState: "smile"
  }
  f2: {
    body: { tx: 0, ty: 1, sx: 1.04, sy: 0.95 },
    head: { rot: 0, tx: 0, ty: 1 },
    helmet: { rot: 0, tx: 0, ty: 1 },
    leftArm: { rot: 24, ty: 0 },
    rightArm: { rot: -24, ty: 0 },
    leftLeg: { rot: -4, ty: 1 },
    rightLeg: { rot: 4, ty: 1 },
    heart: { sx: 1, sy: 1 },
    eyesState: "open",
    mouthState: "smile"
  }
  f3: {
    body: { tx: 2, ty: -2, sx: 0.98, sy: 1.04 },
    head: { rot: 7, tx: 1, ty: -2 },
    helmet: { rot: 6, tx: 1, ty: -2 },
    leftArm: { rot: 8, ty: 1 },
    rightArm: { rot: -48, ty: -3 },
    leftLeg: { rot: -4, ty: 1 },
    rightLeg: { rot: 16, ty: -1 },
    heart: { sx: 1.06, sy: 1.06 },
    eyesState: "happy",
    mouthState: "smile"
  }
  f4: {
    body: { tx: 0, ty: 1, sx: 1.04, sy: 0.95 },
    head: { rot: 0, tx: 0, ty: 1 },
    helmet: { rot: 0, tx: 0, ty: 1 },
    leftArm: { rot: 20, ty: 0 },
    rightArm: { rot: -20, ty: 0 },
    leftLeg: { rot: -4, ty: 1 },
    rightLeg: { rot: 4, ty: 1 },
    heart: { sx: 1, sy: 1 },
    eyesState: "open",
    mouthState: "smile"
  }
trigger: double-click within 280ms
exit: after 2400ms, interruption by error/work/sleep
particles: optional 4 note/sparkle particles, max 8

4. State Machine Logic For The 3 New Functions

A. Status Indicator Pose Mapping

Use priority ordering. Error, drag, and explicit user interactions should override passive app state.

type AppStatus =
  | "idle"
  | "apiInFlight"
  | "streaming"
  | "success"
  | "error"
  | "offline"
  | "rateLimited";
 
type MascotPose =
  | "idle"
  | "blink"
  | "wave"
  | "work"
  | "think"
  | "confused"
  | "celebrate"
  | "tired"
  | "sleepy"
  | "sleep"
  | "wake"
  | "lookLeft"
  | "lookRight"
  | "dance"
  | "dizzy";
 
type MascotEvent =
  | { type: "APP_STATUS"; status: AppStatus }
  | { type: "USER_ACTIVITY" }
  | { type: "ERROR"; message?: string }
  | { type: "SUCCESS"; message?: string }
  | { type: "DOUBLE_CLICK" }
  | { type: "DRAG_START"; x: number; y: number }
  | { type: "DRAG_END"; x: number; y: number; vx: number; vy: number }
  | { type: "CURSOR_NEAR"; side: "left" | "right" }
  | { type: "CURSOR_AWAY" };
 
const STATUS_POSE: Record<AppStatus, MascotPose> = {
  idle: "idle",
  apiInFlight: "work",
  streaming: "think",
  success: "celebrate",
  error: "confused",
  offline: "tired",
  rateLimited: "confused",
};
 
const POSE_PRIORITY: Record<MascotPose, number> = {
  dizzy: 100,
  wake: 95,
  confused: 90,
  celebrate: 85,
  dance: 80,
  work: 70,
  think: 65,
  lookLeft: 40,
  lookRight: 40,
  wave: 35,
  tired: 30,
  sleepy: 25,
  sleep: 20,
  idle: 10,
  blink: 5,
};
 
function chooseStatusPose(status: AppStatus): MascotPose {
  return STATUS_POSE[status] ?? "idle";
}
 
function canInterrupt(current: MascotPose, next: MascotPose) {
  return POSE_PRIORITY[next] >= POSE_PRIORITY[current];
}
 
function reduceMascotState(state: MascotState, event: MascotEvent): MascotState {
  switch (event.type) {
    case "APP_STATUS": {
      const nextPose = chooseStatusPose(event.status);
 
      if (!canInterrupt(state.pose, nextPose)) return state;
 
      return {
        ...state,
        appStatus: event.status,
        pose: nextPose,
        poseStartedAt: performance.now(),
      };
    }
 
    case "ERROR":
      return {
        ...state,
        appStatus: "error",
        pose: "confused",
        poseStartedAt: performance.now(),
        transientUntil: performance.now() + 1800,
      };
 
    case "SUCCESS":
      return {
        ...state,
        appStatus: "success",
        pose: "celebrate",
        poseStartedAt: performance.now(),
        transientUntil: performance.now() + 900,
      };
 
    default:
      return state;
  }
}

Recommended priority:

drag/toss > wake > error > success > dance > working > cursor tracking > idle chain

B. Time-Of-Day Pose Selector With Timezone Awareness

Use app/user timezone, not server timezone. If unavailable, fall back to browser Intl.

type DayPhase = "morning" | "noon" | "afternoon" | "evening" | "night" | "lateNight";
 
function getHourInTimeZone(date: Date, timeZone: string): number {
  const parts = new Intl.DateTimeFormat("en-US", {
    timeZone,
    hour: "2-digit",
    hourCycle: "h23",
  }).formatToParts(date);
 
  return Number(parts.find((p) => p.type === "hour")?.value ?? 0);
}
 
function getDayPhase(date: Date, timeZone: string): DayPhase {
  const hour = getHourInTimeZone(date, timeZone);
 
  if (hour >= 5 && hour < 9) return "morning";
  if (hour >= 9 && hour < 13) return "noon";
  if (hour >= 13 && hour < 17) return "afternoon";
  if (hour >= 17 && hour < 21) return "evening";
  if (hour >= 21 && hour < 24) return "night";
  return "lateNight";
}
 
function selectAmbientPoseByTime(args: {
  now: Date;
  timeZone?: string;
  idleMs: number;
  appStatus: AppStatus;
  currentPose: MascotPose;
}): MascotPose {
  const timeZone =
    args.timeZone ??
    Intl.DateTimeFormat().resolvedOptions().timeZone ??
    "UTC";
 
  const phase = getDayPhase(args.now, timeZone);
 
  // App activity wins over ambient personality.
  if (args.appStatus === "apiInFlight") return "work";
  if (args.appStatus === "streaming") return "think";
  if (args.appStatus === "error") return "confused";
 
  // Existing idle chain still applies.
  if (args.idleMs > 30_000) return "sleep";
  if (args.idleMs > 15_000) return "sleepy";
 
  switch (phase) {
    case "morning":
      // Use wake/yawn only occasionally, not every tick.
      return args.idleMs > 8_000 ? "stretch" : "idle";
 
    case "noon":
      return args.idleMs > 5_000 ? "cheer" : "idle";
 
    case "afternoon":
      return args.idleMs > 10_000 ? "think" : "idle";
 
    case "evening":
      return args.idleMs > 8_000 ? "tired" : "idle";
 
    case "night":
    case "lateNight":
      return args.idleMs > 6_000 ? "sleepy" : "idle";
 
    default:
      return "idle";
  }
}

Add cooldowns so the mascot does not spam time-based reactions:

const AMBIENT_COOLDOWN_MS = 12_000;
const SPECIAL_TIME_REACTION_COOLDOWN_MS = 60_000;

C. Drag Toss Physics

Track pointer velocity over the last few samples. A toss registers when release speed and distance exceed thresholds.

type DragSample = {
  x: number;
  y: number;
  t: number;
};
 
type DragState = {
  isDragging: boolean;
  samples: DragSample[];
  startX: number;
  startY: number;
  lastX: number;
  lastY: number;
};
 
const DRAG = {
  maxSamples: 6,
  tossMinSpeed: 950,      // px/s
  tossMinDistance: 80,    // px
  tossMinVerticalSpeed: 500,
  dizzyMinMs: 900,
  dizzyMaxMs: 2200,
  recoverMs: 400,
  friction: 0.92,
  gravity: 1800,          // px/s^2 if mascot has screen-position physics
  maxParticles: 12,
};
 
function addDragSample(state: DragState, x: number, y: number, t = performance.now()) {
  const samples = [...state.samples, { x, y, t }].slice(-DRAG.maxSamples);
 
  return {
    ...state,
    samples,
    lastX: x,
    lastY: y,
  };
}
 
function computeReleaseVelocity(samples: DragSample[]) {
  if (samples.length < 2) return { vx: 0, vy: 0, speed: 0 };
 
  const first = samples[0];
  const last = samples[samples.length - 1];
  const dt = Math.max(16, last.t - first.t) / 1000;
 
  const vx = (last.x - first.x) / dt;
  const vy = (last.y - first.y) / dt;
  const speed = Math.hypot(vx, vy);
 
  return { vx, vy, speed };
}
 
function isToss(drag: DragState) {
  const { vx, vy, speed } = computeReleaseVelocity(drag.samples);
  const distance = Math.hypot(drag.lastX - drag.startX, drag.lastY - drag.startY);
 
  return {
    tossed:
      speed >= DRAG.tossMinSpeed &&
      distance >= DRAG.tossMinDistance &&
      Math.abs(vy) >= DRAG.tossMinVerticalSpeed,
    vx,
    vy,
    speed,
    distance,
  };
}
 
function dizzyDurationFromSpeed(speed: number) {
  const normalized = Math.min(1, Math.max(0, (speed - 900) / 1800));
  return DRAG.dizzyMinMs + normalized * (DRAG.dizzyMaxMs - DRAG.dizzyMinMs);
}
 
function onDragEnd(state: MascotState, drag: DragState): MascotState {
  const toss = isToss(drag);
 
  if (!toss.tossed) {
    return {
      ...state,
      pose: "idle",
      drag: { isDragging: false, samples: [] },
      transitionMs: 160,
    };
  }
 
  const now = performance.now();
  const dizzyMs = dizzyDurationFromSpeed(toss.speed);
 
  return {
    ...state,
    pose: "dizzy",
    poseStartedAt: now,
    transientUntil: now + dizzyMs,
    velocity: {
      x: Math.max(-1600, Math.min(1600, toss.vx * 0.35)),
      y: Math.max(-1200, Math.min(600, toss.vy * 0.35)),
    },
    angularVelocity: Math.max(-720, Math.min(720, toss.vx * 0.25)),
    drag: { isDragging: false, samples: [] },
    particles: spawnStars({
      count: 8,
      x: drag.lastX,
      y: drag.lastY,
      max: DRAG.maxParticles,
    }),
  };
}
 
function tickDizzyRecovery(state: MascotState, now: number, dt: number): MascotState {
  if (state.pose !== "dizzy") return state;
 
  const velocity = {
    x: state.velocity.x * DRAG.friction,
    y: state.velocity.y + DRAG.gravity * dt,
  };
 
  if (now < state.transientUntil) {
    return {
      ...state,
      velocity,
      headSpinDeg: (state.headSpinDeg + state.angularVelocity * dt) % 360,
    };
  }
 
  return {
    ...state,
    pose: "idle",
    velocity: { x: 0, y: 0 },
    angularVelocity: 0,
    headSpinDeg: 0,
    transitionMs: DRAG.recoverMs,
  };
}

Suggested dizzy pose:

dizzy:
  frames:
    f0: head rot -12, eyesState "dizzy", body tx -1
    f1: head rot 12, eyesState "dizzy", body tx 1
    f2: head rot -8, eyesState "dizzy", body tx -1
    f3: head rot 8, eyesState "dizzy", body tx 1
  loop: true
  particles: orbit stars, max 6

5. Color Palette Refinement

First: the provided list is 21 named colors, not 19. If the palette budget is truly locked to 19, merge either helmetW/helmetH or sparkle/sparkleW.

Suggested tweaks:

1. Warm the outline slightly

outline: "#2a1a08" -> "#241609"

Why: current outline is a bit brown-heavy. The replacement keeps the warm 8-bit ink feel but improves contrast against woods and golds.

2. Make helmet shadow less neutral gray

helmetS: "#bcc0cc" -> "#aeb5c8"

Why: slightly cooler blue-gray makes the white helmet feel richer without adding a new hue family.

3. Deepen inner ear / dark cavity

earIn: "#15101e" -> "#181022"

Why: gives a more deliberate purple-black shadow. This pairs better with eye #1a1024.

4. Shift smile warmer and cleaner

smile: "#d68b58" -> "#e09a64"

Why: improves readability against mouth #3a1c0a and adds a friendlier peach highlight.

5. Improve heart midtone saturation

heartM: "#2a52d4" -> "#2f5ee8"

Why: current heart ramp is good, but the midtone can feel slightly flat next to heartL #7aa6ff. This adds a more magical, game-like blue while staying 8-bit.

Optional if reducing to 19:

helmetW: "#f4f4f7"
helmetH: "#ffffff"

Keep both only if helmet shine is visually important. Otherwise use helmetH as a highlight and replace helmetW with collar #f3e4cc where acceptable.


6. Easter-Egg Interaction Ideas

  1. Konami-ish mini cheer
    If the user enters a hidden sequence like ↑ ↑ ↓ ↓ ← → ← →, Kokonkun does a tiny dance loop, spawns 8 sparkles, then returns to idle.

  2. Midnight nod-off
    Between 00:00-03:59 local time, if idle for 20s, Kokonkun briefly pulls out a tiny pillow prop before entering sleep.

  3. Cursor boop
    If the cursor circles Kokonkun 2 full rotations within 3s, trigger confused, then love. Use angle accumulation around mascot center.

  4. Onigiri share
    During eat, clicking the onigiri prop makes Kokonkun pause, hold it outward for 600ms, then smile.

  5. Perfect task streak
    After 5 success notifications without an error, trigger a rare celebrate variant with gold sparkles and a tiny crown/shine prop for one loop.