You know those moments during vacation when you should be relaxing, but the creative coding brain just won’t shut off? That was me last week, sitting in an Airbnb with my laptop, a decent WiFi connection, and way too much coffee. The goal? Create my own version of the iconic Matrix digital rain effect – not just another web animation, but a proper terminal-based experience. Something I could casually fire up during Zoom calls or while working from a coffee shop – because let’s be honest, who doesn’t want to look like they’re diving deep into the Matrix while actually just debugging a for loop?
“To deny our own impulses is to deny the very thing that makes us human.” — Mouse, The Matrix (1999)
The Matrix wasn’t just another sci-fi movie for me – it was my gateway into cyberpunk literature. Soon I was lost in the neon-lit worlds of William Gibson’s Neuromancer, where “bright lattices of logic” stretched through cyberspace, and Neal Stephenson’s Snow Crash, with its vision of the Metaverse. That iconic digital rain effect became more than just falling characters – it was a visual bridge between these fictional futures and our present-day terminals.
Between long flights and jet-lagged nights in the Airbnb, I found myself thinking about recreating that cascading code. Why not build my own version? Not just as another web animation, but as something that could transform our everyday terminal into a small piece of that cyberpunk future I fell in love with through those books. After all, what’s more cyberpunk than making your CLI look like it’s straight out of The Matrix? 🕶️
Having recently worked with Ink for other CLI projects, my first instinct was to use React components. The idea seemed solid – components for raindrops, state management for positions, and JSX for layout:
// Initial approach with Ink components
const RainDrop: FC<{ x: number; y: number; char: string }> = ({ x, y, char }) => (
<Box position="absolute" left={x} top={y}>
<Text color="green">{char}</Text>
</Box>
);
const MatrixRain: FC = () => {
const [drops, setDrops] = useState<DropState[]>([]);
useEffect(() => {
const timer = setInterval(() => {
setDrops(prevDrops => updateDrops(prevDrops));
}, 50);
return () => clearInterval(timer);
}, []);
return (
<Box flexDirection="column">
{drops.map((drop, i) => (
<RainDrop key={i} x={drop.x} y={drop.y} char={drop.char} />
))}
</Box>
);
};
TypeScriptWhile the code looked clean, problems quickly emerged:
Here’s a quick performance comparison that made me realize I needed a different approach:
Metric | React/Ink Approach | Direct Terminal |
---|---|---|
FPS | ~20-30 | 60+ |
Memory Usage | ~80MB | ~30MB |
CPU Usage | ~25-30% | ~10-15% |
Screen Tearing | Visible | None |
After hitting those performance walls with React, I decided to start fresh with direct terminal manipulation. The journey from basic implementation to optimized animation was a series of interesting challenges and discoveries:
// Direct terminal manipulation approach
class MatrixRain {
private renderFrame(): void {
// Buffer the entire frame in memory
const buffer: Cell[][] = Array.from({ length: this.rows }, () =>
Array.from({ length: this.columns }, () => ({
char: ' ',
color: chalk.green,
intensity: 0
}))
)
// Update and fill buffer
this.drops.forEach(drop => {
// ... update drop positions ...
// Single write operation per frame
process.stdout.write(this.formatBuffer(buffer))
})
}
}
TypeScriptThe benefits were immediate:
The most significant improvements came from:
// Before (React/Ink): Multiple renders per frame
drops.map((drop, i) => (
<RainDrop key={i} x={drop.x} y={drop.y} char={drop.char} />
))
// After: Single write operation per frame
process.stdout.write(`\x1b[${y + 1};${x + 1}H${char}`)
TypeScriptEven better, the direct approach made it easier to add features like trail effects:
// Smooth trail fading with direct buffer access
const fadeStart = Math.floor(trailChars.length * 0.3)
const fadeLength = trailChars.length - fadeStart
let intensity = i < fadeStart
? 1
: Math.max(0, 1 - ((i - fadeStart) / fadeLength))
// Apply color based on intensity
const color = intensity > 0.7
? palette.bright
: intensity > 0.3
? palette.medium
: palette.dim
TypeScriptThe first step in the new approach was designing a simple but efficient data structure for our raindrops:
interface Droplet {
pos: number // x in vertical mode, y in horizontal mode
head: number // y in vertical mode, x in horizontal mode
trail: string[] // Characters in the trail
speed: number // How fast it falls
}
TypeScriptThis simple data structure became the foundation of each rain drop. The real challenge was managing multiple drops simultaneously while maintaining smooth animation.
The first version was simple: green characters falling straight down. I used ANSI escape codes for positioning and color:
const pos = `\x1b[${y + 1};${x + 1}H`
output += pos + cell.color(cell.char)
TypeScriptBut this approach had issues – screen flicker and poor performance. The solution? Buffer the entire frame before rendering:
// Create a buffer for the frame
const buffer: Cell[][] = Array.from({ length: rows }, () =>
Array.from({ length: columns }, () => ({
char: ' ',
color: chalk.green,
intensity: 0
}))
);
TypeScriptWith the basic animation working smoothly, I could focus on making it look more authentic. The first enhancement was adding support for different character sets, particularly katakana characters for that authentic Matrix feel. The challenge was doing this without impacting performance:
private generateChar(): string {
switch (this.charset) {
case 'ascii':
return String.fromCharCode(Math.floor(Math.random() * 94) + 33)
case 'binary':
return Math.round(Math.random()).toString()
case 'braille':
return String.fromCharCode(Math.floor(Math.random() * 256) + 0x2800)
case 'emoji':
return random().emoji
case 'katakana':
return String.fromCharCode(Math.floor(Math.random() * 96) + 0x30a0)
}
}
TypeScriptThe most challenging aspect was creating natural-looking trails while maintaining performance. The solution involved careful buffer management and smart fade calculations:
// Calculate intensity based on position in trail
const fadeStart = Math.floor(trailChars.length * 0.3) // Start fading after 30% of trail
const fadeLength = trailChars.length - fadeStart
let intensity = i < fadeStart
? 1
: Math.max(0, 1 - ((i - fadeStart) / fadeLength))
// Additional fade out when near screen boundary
if (isHeadNearBoundary) {
const distanceFromHead = i
const boundaryFade = Math.max(0, 1 - (distanceFromHead / 3))
intensity *= boundaryFade
}
TypeScriptOne of the trickiest bugs was the “stuck character” issue – sometimes characters would get stuck at the bottom of the screen. The problem? I was resetting entire columns when a drop reached the bottom, which affected other drops in the same column. The fix was to manage each drop independently:
// Reset individual drops when they reach the bottom
if (newHead >= maxDim) {
// Move drop back to top with a random delay
return {
...drop,
head: -Math.floor(Math.random() * 20), // Longer random delay before reappearing
trail: [] // Clear this drop's trail
}
}
TypeScriptWith the core animation running smoothly, I could focus on customization. The challenge was adding flexibility without compromising the performance gains we’d achieved. This led to several carefully implemented options:
The configuration grew into a robust interface:
interface MatrixConfig {
direction?: 'vertical' | 'horizontal'
charset?: 'ascii' | 'binary' | 'braille' | 'emoji' | 'katakana'
color?: string
density?: number // 0-1, controls gap frequency, default 1 (no gaps)
}
TypeScriptPerformance was critical for smooth animation – nobody wants choppy Matrix rain! Here are some key optimizations that made a big difference:
Instead of writing characters directly to the terminal (which caused flickering), we buffer the entire frame before rendering:
// Build the frame in memory first
let output = ''
let lastPos = ''
for (let y = 0; y < buffer.length; y++) {
const row = buffer[y]
if (row) {
for (let x = 0; x < row.length; x++) {
const cell = row[x]
if (cell && cell.char !== ' ') {
const pos = `\x1b[${y + 1};${x + 1}H`
// Only output position when it changes
output += (pos !== lastPos ? pos : '') + cell.color(cell.char)
lastPos = pos
}
}
}
}
// Single write operation to terminal
process.stdout.write(output)
TypeScriptTo prevent performance issues, we limit the total number of active drops based on screen size and density:
// Limit total number of drops to prevent performance issues
const maxDrops = Math.floor((this.rows + this.columns) * this.density)
if (this.drops.length > maxDrops) {
this.drops = this.drops
.sort((a, b) => b.head - a.head) // Sort by position (keep visible drops)
.slice(0, maxDrops)
}
TypeScriptWe optimize trail rendering by:
// Calculate and limit trail length
const maxTrailLength = Math.min(maxDim * 0.4, 15)
// Add new character to trail with smart spacing
let trail: string[]
if (this.charset === 'katakana') {
// Katakana looks good with dense trails
trail = [newChar, ...drop.trail]
} else {
// Other charsets need spaces for visual separation
const shouldAddSpace = Math.random() < 0.3 // 30% chance for space
trail = [shouldAddSpace ? ' ' : newChar, ...drop.trail]
}
// Remove consecutive spaces for efficiency
trail = trail.filter((char, i) =>
char !== ' ' || (i > 0 && trail[i - 1] !== ' ')
)
// Limit trail length
while (trail.length > maxTrailLength) {
trail.pop()
}
TypeScriptWe minimize ANSI escape codes by only updating cursor position when necessary:
const pos = `\x1b[${y + 1};${x + 1}H`
// Only output position when it changes
output += (pos !== lastPos ? pos : '') + cell.color(cell.char)
lastPos = pos
TypeScriptThese optimizations together help maintain a smooth 60+ FPS animation, even with multiple drops and complex character sets. The key was finding the right balance between visual quality and performance – after all, we want it to look cool in that coffee shop window, but not at the cost of burning through our laptop battery! 🔋
Adding Chalk brought better color support and cleaner code:
private palettes = {
green: {
head: chalk.bold.white,
bright: chalk.bold.green,
medium: chalk.green,
dim: chalk.rgb(0, 100, 0)
},
blue: {
head: chalk.bold.white,
bright: chalk.bold.blue,
medium: chalk.blue,
dim: chalk.rgb(0, 0, 100)
},
purple: {
head: chalk.bold.white,
bright: chalk.bold.magenta,
medium: chalk.magenta,
dim: chalk.rgb(100, 0, 100)
},
rainbow: {
head: chalk.bold.white,
bright: chalk.bold.green,
medium: chalk.hex('#00ff00'),
dim: chalk.hex('#004400')
}
}
TypeScriptSo there you have it – what started as a React component experiment during a jet-lagged coding session turned into a deep dive into terminal graphics optimization. The journey taught me several valuable lessons:
But beyond the technical lessons, I ended up with something I actually use. Whether I’m trying to look busy in a coffee shop, add some flair to a presentation, or just want to pretend I’m hacking the mainframe during a Zoom call, it’s become my go-to terminal trick. And it runs at 60+ FPS, which is more than I can say for my first React version!
The result is digi-rain, and while it might not help you dodge bullets or learn kung fu, it will definitely make your terminal look cooler than the average developer’s. Plus, you can customize it to your heart’s content – want emoji rain? Got you covered. Prefer your digital rain horizontal? Why not!
Sometimes the best projects come from those moments when we should be relaxing but can’t help ourselves from opening the laptop and writing “just a little code.” And sometimes, the path to the best solution isn’t the one you initially thought would work – but that’s exactly what makes these vacation coding sessions so fun.
Want to make your own terminal look like you’re in the Matrix? The code’s up on GitHub, and you can try it right now with:
npx digi-rain
Now if you’ll excuse me, I have a coffee shop to nerd out ✨🧙♂️ Happy hacking!
Published on
January 23, 2025