In the previous chapter, ASCII Sprite Renderer, we built a "Paper Doll" system. We can generate a Duck, put a hat on it, and render it to text strings.
But right now, our friend is frozen in time. It is a statue.
To turn a statue into a companion, it needs a Heartbeat. It needs to breathe, blink, and look around, even when you aren't typing commands.
In this chapter, we will build the stage where our actor performs: the Live Component.
Standard command-line tools run once and exit (like ls or git status). They print text and die.
buddy is different. It uses a library called Ink (React for the terminal) to stay alive. It redraws the screen many times per second.
To animate our companion, we need three things:
We use a React state variable to count time. Every 500 milliseconds (half a second), we increase this number by 1.
We don't want our pet to move constantly; that would be distracting. We want it to sit still mostly, and occasionally blink or wiggle.
Instead of complex AI, we use a simple array of numbers called the IDLE_SEQUENCE.
// 0 = Stand Still, 1 = Wiggle, -1 = Blink
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0];
As the Tick counts up, we step through this list.
Terminals are usually just lines of text. But Ink allows us to use Flexbox (like in web design). This lets us put a Speech Bubble next to the Companion, or float hearts above it.
From the perspective of the main application, using the companion is as simple as dropping a generic HTML tag into the code.
import { CompanionSprite } from './CompanionSprite'
// Inside our main App layout
export function App() {
return (
<Box>
<Header />
<CompanionSprite />
<Footer />
</Box>
)
}
The CompanionSprite handles its own internal time. The main app doesn't need to tell it to blink; it just does.
Let's visualize the "Heartbeat" loop.
We use useEffect to start a timer when the component loads. This timer updates the tick state variable every 500ms (TICK_MS).
// CompanionSprite.tsx
const TICK_MS = 500;
export function CompanionSprite() {
const [tick, setTick] = useState(0);
useEffect(() => {
// Start the metronome
const timer = setInterval(() => setTick(t => t + 1), TICK_MS);
// Cleanup when component closes
return () => clearInterval(timer);
}, []);
// ... rest of code
}
Result: Every 0.5 seconds, the component re-runs its code with a new tick number.
Now that we have a changing number (tick), we use it to pick a frame from our sequence.
We use the Modulo Operator (%). This effectively loops the array forever. If the array has 10 items, tick 11 becomes index 1.
// CompanionSprite.tsx
// 0=Rest, 1=Move, -1=Blink
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0];
// Inside the component...
const stepIndex = tick % IDLE_SEQUENCE.length; // Loops 0 to 9
const action = IDLE_SEQUENCE[stepIndex]; // Get the action
let frame = 0;
let blink = false;
if (action === -1) blink = true; // Special blink flag
else frame = action; // Standard frame (0 or 1)
Now we connect back to Chapter 2: ASCII Sprite Renderer. We pass our calculated frame to the renderer.
// CompanionSprite.tsx
const companion = getCompanion(); // From Chapter 1
// Get the raw lines for this specific frame
const lines = renderSprite(companion, frame);
// If we need to blink, we replace eyes with dashes '-'
const finalLines = blink
? lines.map(l => l.replaceAll(companion.eye, '-'))
: lines;
Finally, we render the result using Ink components (Box and Text). We check if there is a reaction (text to say).
If there is a reaction, we render a SpeechBubble component next to the sprite.
// CompanionSprite.tsx (Simplified Return)
return (
<Box flexDirection="row" alignItems="flex-end">
{/* 1. The Speech Bubble (Optional) */}
{reaction && (
<SpeechBubble text={reaction} color="green" />
)}
{/* 2. The Sprite Body */}
<Box flexDirection="column">
{finalLines.map((line, i) => (
<Text key={i}>{line}</Text>
))}
</Box>
</Box>
);
IDLE_SEQUENCE, we can make the pet look hyperactive (lots of 1s) or sleepy (mostly 0s).Box layout automatically adjusts the speech bubble position.We have breathed life into our companion!
Currently, our buddy is alive, but it is living in a bubble. It doesn't know what you are doing in the terminal. It creates animations, but it doesn't react to your work.
In the next chapter, we will learn how to inject "Context" so the companion can see what command you just ran and comment on it.
Generated by Code IQ