diff --git a/packages/pokemon/src/index.ts b/packages/pokemon/src/index.ts
index 94eedab8a..d1c4f17c8 100644
--- a/packages/pokemon/src/index.ts
+++ b/packages/pokemon/src/index.ts
@@ -46,3 +46,4 @@ export { EggView } from './ui/EggView'
export { EvolutionAnim } from './ui/EvolutionAnim'
export { StatBar } from './ui/StatBar'
export { SpeciesDetail } from './ui/SpeciesDetail'
+export { SpriteAnimator } from './ui/SpriteAnimator'
diff --git a/packages/pokemon/src/sprites/renderer.ts b/packages/pokemon/src/sprites/renderer.ts
index 1ebdee02c..83eaf6059 100644
--- a/packages/pokemon/src/sprites/renderer.ts
+++ b/packages/pokemon/src/sprites/renderer.ts
@@ -1,5 +1,107 @@
import type { AnimMode } from '../types'
+// ─── Idle Sequence ────────────────────────────────────
+// Natural-looking idle: mostly still with occasional breathe/blink/bounce/fidget
+const IDLE_SEQUENCE: AnimMode[] = [
+ 'idle', 'idle', 'idle',
+ 'breathe',
+ 'idle', 'idle',
+ 'blink',
+ 'idle', 'idle', 'idle',
+ 'bounce',
+ 'idle', 'idle', 'idle',
+ 'fidget',
+ 'idle', 'idle',
+]
+
+export function getIdleAnimMode(tick: number): AnimMode {
+ return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]
+}
+
+// ─── Main Render ──────────────────────────────────────
+
+export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] {
+ switch (mode) {
+ case 'idle':
+ return lines
+ case 'breathe':
+ return breathe(lines, tick)
+ case 'blink':
+ return blinkEyes(lines)
+ case 'fidget':
+ return shiftLines(lines, tick % 2 === 0 ? 0 : 1)
+ case 'bounce':
+ return bounce(lines, tick)
+ case 'walkLeft':
+ return walkLeft(lines, tick)
+ case 'walkRight':
+ return walkRight(lines, tick)
+ case 'flip':
+ return flipHorizontal(lines)
+ case 'excited':
+ return shiftLines(lines, tick % 2 === 0 ? -1 : 1)
+ case 'pet':
+ return addPetParticles(lines, tick)
+ default:
+ return lines
+ }
+}
+
+// ─── Animation Transforms ─────────────────────────────
+
+/** Subtle horizontal oscillation — shift right 1px on even ticks */
+function breathe(lines: string[], tick: number): string[] {
+ return tick % 4 < 2 ? shiftLines(lines, 1) : lines
+}
+
+/** Parabolic bounce — sprite hops up and back down */
+function bounce(lines: string[], tick: number): string[] {
+ const PATTERN = [0, 1, 2, 2, 1, 0, 0, 0]
+ const h = PATTERN[tick % PATTERN.length]
+ if (h === 0 || lines.length === 0) return lines
+ return [
+ ...Array(h).fill(''),
+ ...lines.slice(0, lines.length - h),
+ ]
+}
+
+/** Walk left — shift left a few steps then reset */
+function walkLeft(lines: string[], tick: number): string[] {
+ const phase = tick % 8
+ if (phase >= 4) return lines
+ return shiftLines(lines, -(phase + 1))
+}
+
+/** Walk right — shift right a few steps then reset */
+function walkRight(lines: string[], tick: number): string[] {
+ const phase = tick % 8
+ if (phase >= 4) return lines
+ return shiftLines(lines, phase + 1)
+}
+
+/** Flip sprite horizontally — reverse each line's characters */
+function flipHorizontal(lines: string[]): string[] {
+ return lines.map(reverseLine)
+}
+
+// ─── Helpers ──────────────────────────────────────────
+
+/** Shift all lines left (negative) or right (positive) by offset columns */
+function shiftLines(lines: string[], offset: number): string[] {
+ if (offset === 0) return lines
+ if (offset > 0) {
+ const pad = ' '.repeat(offset)
+ return lines.map(line => pad + line)
+ }
+ const abs = Math.abs(offset)
+ return lines.map(line => line.slice(abs))
+}
+
+/** Replace eye characters with blink indicator */
+function blinkEyes(lines: string[]): string[] {
+ return lines.map(line => line.replace(/[·✦×◉@°oO]/g, '—'))
+}
+
/** Heart particle frames for pet animation */
const PET_HEARTS = [
[' ♥ ', ' '],
@@ -9,68 +111,20 @@ const PET_HEARTS = [
[' ♥ ', ' ♥ ♥ '],
]
-/**
- * Render animated sprite by applying mode-specific transformations.
- * All species share the same animation logic - only the base sprite differs.
- */
-export function renderAnimatedSprite(lines: string[], tick: number, mode: AnimMode): string[] {
- switch (mode) {
- case 'idle':
- return lines
- case 'fidget':
- return shiftLines(lines, tick % 2 === 0 ? 0 : 1)
- case 'blink':
- return blinkEyes(lines)
- case 'excited':
- return shiftLines(lines, tick % 2 === 0 ? -1 : 1)
- case 'pet':
- return addPetParticles(lines, tick)
- default:
- return lines
- }
-}
-
-/**
- * Shift all lines left or right by offset columns.
- */
-function shiftLines(lines: string[], offset: number): string[] {
- if (offset === 0) return lines
- if (offset > 0) {
- return lines.map((line) => ' '.repeat(offset) + line)
- }
- // Shift left: remove leading characters
- const absOffset = Math.abs(offset)
- return lines.map((line) => line.slice(absOffset))
-}
-
-/**
- * Replace eye characters with blink indicator.
- */
-function blinkEyes(lines: string[]): string[] {
- // Eye characters that should be replaced for blink
- return lines.map((line) =>
- line.replace(/[·✦×◉@°oO]/g, '—'),
- )
-}
-
-/**
- * Add heart particle frames above the sprite for pet animation.
- */
+/** Add heart particle frames above the sprite */
function addPetParticles(lines: string[], tick: number): string[] {
const hearts = PET_HEARTS[tick % PET_HEARTS.length]
return [...hearts, ...lines]
}
/**
- * Get the animation mode for a given tick in the idle sequence.
- * IDLE_SEQUENCE replicates the original buddy design pattern.
+ * Reverse a line's visible characters while preserving leading ANSI codes.
+ * Handles simple cases: ANSI at start + visible text.
*/
-const IDLE_SEQUENCE: AnimMode[] = [
- 'idle', 'idle', 'idle', 'idle',
- 'fidget', 'idle', 'idle', 'idle',
- 'blink', 'idle', 'idle', 'idle', 'idle',
-]
-
-export function getIdleAnimMode(tick: number): AnimMode {
- return IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]
+function reverseLine(line: string): string {
+ // eslint-disable-next-line no-control-regex
+ const ansiMatch = line.match(/^(\x1b\[[0-9;]*m)+/)
+ const stripped = line.replace(/\x1b\[[0-9;]*m/g, '')
+ const reversed = stripped.split('').reverse().join('')
+ return ansiMatch ? ansiMatch[0] + reversed : reversed
}
diff --git a/packages/pokemon/src/types.ts b/packages/pokemon/src/types.ts
index 9f6f96215..293d581e9 100644
--- a/packages/pokemon/src/types.ts
+++ b/packages/pokemon/src/types.ts
@@ -140,4 +140,14 @@ export type SpriteCache = {
}
// Animation mode
-export type AnimMode = 'idle' | 'fidget' | 'blink' | 'excited' | 'pet'
+export type AnimMode =
+ | 'idle'
+ | 'breathe'
+ | 'blink'
+ | 'fidget'
+ | 'bounce'
+ | 'walkLeft'
+ | 'walkRight'
+ | 'flip'
+ | 'excited'
+ | 'pet'
diff --git a/packages/pokemon/src/ui/SpriteAnimator.tsx b/packages/pokemon/src/ui/SpriteAnimator.tsx
new file mode 100644
index 000000000..cb7203c79
--- /dev/null
+++ b/packages/pokemon/src/ui/SpriteAnimator.tsx
@@ -0,0 +1,73 @@
+import React, { useEffect, useState } from 'react'
+import { Box, Text, type Color, stringWidth } from '@anthropic/ink'
+import type { AnimMode } from '../types'
+import { renderAnimatedSprite, getIdleAnimMode } from '../sprites/renderer'
+
+interface SpriteAnimatorProps {
+ /** Base sprite lines (before animation transforms) */
+ lines: string[]
+ /** Text color for the sprite */
+ color?: Color
+ /** Tick interval in milliseconds (default 500) */
+ tickMs?: number
+ /** Single animation mode. Omit for idle sequence auto-play */
+ mode?: AnimMode
+ /** Whether to center the sprite horizontally (default true) */
+ centered?: boolean
+ /** Extra content to render above the sprite (e.g. hearts) */
+ overlay?: string[] | null
+}
+
+/**
+ * Animated sprite renderer with built-in tick loop.
+ *
+ * Renders base sprite lines with animation transforms applied per-tick.
+ * Uses the idle sequence by default; pass `mode` to force a single animation.
+ *
+ * @example
+ * ```tsx
+ * // Idle animation, auto-centered
+ *
+ *
+ * // Forced excited mode
+ *
+ *
+ * // With heart overlay
+ *
+ * ```
+ */
+export function SpriteAnimator({
+ lines,
+ color,
+ tickMs = 500,
+ mode,
+ centered = true,
+ overlay,
+}: SpriteAnimatorProps) {
+ const [tick, setTick] = useState(0)
+
+ useEffect(() => {
+ const timer = setInterval(() => setTick(t => t + 1), tickMs)
+ return () => clearInterval(timer)
+ }, [tickMs])
+
+ const currentMode = mode ?? getIdleAnimMode(tick)
+ const animated = renderAnimatedSprite(lines, tick, currentMode)
+ const displayLines = overlay ? [...overlay, ...animated] : animated
+
+ const spriteBlock = (
+
+ {displayLines.map((line, i) => (
+ {line}
+ ))}
+
+ )
+
+ if (!centered) return spriteBlock
+
+ return (
+
+ {spriteBlock}
+
+ )
+}
diff --git a/src/commands/buddy/BuddyPanel.tsx b/src/commands/buddy/BuddyPanel.tsx
index 12454c04b..f760aeabc 100644
--- a/src/commands/buddy/BuddyPanel.tsx
+++ b/src/commands/buddy/BuddyPanel.tsx
@@ -18,7 +18,7 @@ import { calculateStats, getCreatureName, getTotalEV, getActiveCreature } from '
import { getXpProgress } from '@claude-code-best/pokemon';
import { getEVSummary } from '@claude-code-best/pokemon';
import { getGenderSymbol } from '@claude-code-best/pokemon';
-import { StatBar } from '@claude-code-best/pokemon';
+import { StatBar, SpriteAnimator, getFallbackSprite } from '@claude-code-best/pokemon';
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
const CYAN: Color = 'ansi:cyan';
@@ -151,10 +151,12 @@ function BuddyTab({
{spriteLines && (
-
- {spriteLines.map((line, i) => (
- {line}
- ))}
+
+
)}