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 Type | Frames | Frame Time | Notes |
|---|---|---|---|
| idle / breathe | 4-6 | 180-260ms | Slow, loopable, tiny deltas |
| blink overlay | 1-3 | 60-90ms | Fast, independent overlay |
| reaction | 3-6 | 80-140ms | Snappy anticipation + hold |
| locomotion | 4-8 | 80-120ms | Walk slower, run faster |
| emotional pose | 4-7 | 100-180ms | Needs readable silhouette |
| transition pose | 3-5 | 80-130ms | Wake, 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 frameLinear 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 150mslookRight
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 150mswake
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 -> idleconfused
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/workcelebrate
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 180msdance
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 84. 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 chainB. 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 65. 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
-
Konami-ish mini cheer
If the user enters a hidden sequence like↑ ↑ ↓ ↓ ← → ← →, Kokonkun does a tinydanceloop, spawns 8 sparkles, then returns to idle. -
Midnight nod-off
Between 00:00-03:59 local time, if idle for 20s, Kokonkun briefly pulls out a tiny pillow prop before enteringsleep. -
Cursor boop
If the cursor circles Kokonkun 2 full rotations within 3s, triggerconfused, thenlove. Use angle accumulation around mascot center. -
Onigiri share
Duringeat, clicking the onigiri prop makes Kokonkun pause, hold it outward for 600ms, then smile. -
Perfect task streak
After 5 success notifications without an error, trigger a rarecelebratevariant with gold sparkles and a tiny crown/shine prop for one loop.