Build a Matrix Rain Effect in the Terminal (Step by Step)
Build The Matrix “digital rain” effect in the terminal with Python — green katakana and Latin characters falling down the screen with a trailing fade effect, fully customizable speed, density, and character set using ANSI escape codes and curses.
What You’ll Build
You’ll build an authentic Matrix rain animation that runs in your terminal. Columns of green characters fall at varying speeds with the classic trailing fade effect. Customize rain density, fall speed, character set (katakana, Latin, binary, custom), and colors. This project runs in any terminal with Python 3 and teaches terminal manipulation, animation loops, and ANSI escape sequences. The same rendering techniques are used in Durga Antivirus Pro’s boot animation.
Why Build a Terminal Matrix Rain?
Terminal animation teaches you low-level terminal control, frame-based rendering, efficient screen updates, and signal handling — skills useful for CLI tools, game engines, and monitoring dashboards. The Matrix rain is visually impressive, runs in any terminal, and is a fun way to learn about curses/ANSI escape codes, double buffering, and event loops.
Prerequisites
- Python 3.8+ installed
- Basic terminal familiarity
- Understanding of Python functions and loops
Step 1: ANSI Escape Code Version
Start with the simpler ANSI escape code approach — no external dependencies:
# matrix_ansi.py
import sys
import time
import random
import os
import signal
# ANSI escape codes
CSI = "\033["
CLEAR = CSI + "2J"
HIDE_CURSOR = CSI + "?25l"
SHOW_CURSOR = CSI + "?25h"
RESET = CSI + "0m"
HOME = CSI + "H"
GREEN = CSI + "32m"
BRIGHT_GREEN = CSI + "92m"
WHITE = CSI + "97m"
DIM_GREEN = CSI + "2;32m"
def get_terminal_size():
"""Get terminal dimensions."""
try:
return os.get_terminal_size()
except OSError:
return os.terminal_size((80, 24))
class MatrixRain:
def __init__(self, density: float = 0.05, speed: float = 0.05,
charset: str = "katakana"):
self.density = density
self.speed = speed
self.running = True
self.columns = []
self.charset = self._get_charset(charset)
signal.signal(signal.SIGINT, self._handle_sigint)
signal.signal(signal.SIGTERM, self._handle_sigint)
def _get_charset(self, name: str) -> str:
charsets = {
"katakana": "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
"latin": "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
"binary": "01",
"hex": "0123456789ABCDEF",
"full": "ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789",
}
return charsets.get(name, charsets["katakana"])
def _handle_sigint(self, sig, frame):
self.running = False
def _init_columns(self, width: int, height: int):
"""Initialize rain drops for each column."""
self.columns = []
for _ in range(width):
# Each column: [current_y, speed, chars_in_column, is_active]
if random.random() < self.density * 10:
start_y = random.randint(-height, 0)
col_speed = random.uniform(0.1, 0.6)
length = random.randint(5, 20)
self.columns.append([start_y, col_speed, length, True])
else:
self.columns.append(None)
def render_frame(self, width: int, height: int) -> str:
"""Render one frame of the Matrix rain."""
# Initialize frame buffer (list of lists for each position)
frame = [[" "] * width for _ in range(height)]
brightness = [[0] * width for _ in range(height)]
for col_idx, col in enumerate(self.columns):
if col is None:
# Randomly activate inactive columns
if random.random() < self.density:
start_y = random.randint(-height, 0)
col_speed = random.uniform(0.1, 0.6)
length = random.randint(5, 20)
self.columns[col_idx] = [start_y, col_speed, length, True]
continue
y, col_speed, length, active = col
# Draw the trail
for i in range(length):
row = int(y) - i
if 0 <= row < height:
char = random.choice(self.charset)
frame[row][col_idx] = char
# Brightness fades toward the tail
brightness[row][col_idx] = max(0, length - i)
# Update position
y += col_speed
if y > height + length:
# Reset: start from top
start_y = random.randint(-height, -5)
col_speed = random.uniform(0.1, 0.6)
length = random.randint(5, 20)
self.columns[col_idx] = [start_y, col_speed, length, True]
else:
col[0] = y
# Build the ANSI string
lines = []
for row in range(height):
line = ""
for col in range(width):
char = frame[row][col]
b = brightness[row][col]
if char == " ":
line += " "
elif b > 8:
line += BRIGHT_GREEN + char + RESET
elif b > 4:
line += GREEN + char + RESET
else:
line += DIM_GREEN + char + RESET
lines.append(line)
return "\n".join(lines)
def run(self):
try:
sys.stdout.write(HIDE_CURSOR)
sys.stdout.write(CLEAR)
while self.running:
w, h = get_terminal_size()
if len(self.columns) != w:
self._init_columns(w, h)
frame = self.render_frame(w, h)
sys.stdout.write(HOME)
sys.stdout.write(frame)
sys.stdout.flush()
time.sleep(self.speed)
finally:
sys.stdout.write(SHOW_CURSOR)
sys.stdout.write(RESET)
sys.stdout.flush()
print("Matrix rain stopped.")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Matrix Rain in Terminal")
parser.add_argument("--density", type=float, default=0.05,
help="Rain density (0.01-0.2, default: 0.05)")
parser.add_argument("--speed", type=float, default=0.05,
help="Frame delay in seconds (0.01-0.2, default: 0.05)")
parser.add_argument("--charset", choices=["katakana", "latin", "binary", "hex", "full"],
default="katakana", help="Character set (default: katakana)")
args = parser.parse_args()
rain = MatrixRain(density=args.density, speed=args.speed, charset=args.charset)
rain.run()Expected output when running:
python matrix_ansi.pyGreen katakana characters fall from the top of the terminal. The lead character is bright green, fading to dim green toward the tail. Characters are randomly chosen for each position every frame. Press Ctrl+C to exit gracefully.
Step 2: Curses Version (Optional)
For smoother rendering and better terminal control:
# matrix_curses.py
import curses
import random
import time
import signal
class CursesMatrixRain:
def __init__(self, stdscr, density: float = 0.05, speed: float = 0.05,
charset: str = "katakana"):
self.stdscr = stdscr
self.density = density
self.speed = speed
self.running = True
self.charset = self._get_charset(charset)
curses.curs_set(0)
self.stdscr.nodelay(1)
self.stdscr.timeout(int(speed * 1000))
if curses.has_colors():
curses.start_color()
curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLACK)
curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK)
def _get_charset(self, name: str) -> str:
charsets = {
"katakana": "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
"latin": "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
"binary": "01",
"hex": "0123456789ABCDEF",
"full": "ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789",
}
return charsets.get(name, charsets["katakana"])
def run(self):
height, width = self.stdscr.getmaxyx()
columns = [None] * width
try:
while self.running:
# Check for 'q' key to quit
key = self.stdscr.getch()
if key == ord("q"):
break
# Handle terminal resize
new_h, new_w = self.stdscr.getmaxyx()
if new_w != width or new_h != height:
height, width = new_h, new_w
if len(columns) != width:
columns = columns[:width] + [None] * (width - len(columns))
# Update each column
for col_idx in range(width):
if columns[col_idx] is None:
if random.random() < self.density:
start_y = random.randint(-height, 0)
col_speed = random.uniform(0.1, 0.4)
length = random.randint(3, 15)
columns[col_idx] = [start_y, col_speed, length]
continue
y, col_speed, length = columns[col_idx]
# Draw head (brightest)
head_y = int(y)
if 0 <= head_y < height:
try:
char = random.choice(self.charset)
self.stdscr.addstr(head_y, col_idx, char,
curses.A_BOLD | curses.color_pair(1))
except curses.error:
pass
# Draw trail
for i in range(1, length):
row = int(y) - i
if 0 <= row < height:
try:
char = random.choice(self.charset)
if i < 3:
attr = curses.color_pair(1)
elif i < 6:
attr = curses.A_DIM | curses.color_pair(1)
else:
attr = curses.A_DIM | curses.color_pair(3)
self.stdscr.addstr(row, col_idx, char, attr)
except curses.error:
pass
# Clear the tail end
tail_row = int(y) - length - 1
if 0 <= tail_row < height:
try:
self.stdscr.addstr(tail_row, col_idx, " ")
except curses.error:
pass
# Update position
y += col_speed
if y > height + length:
columns[col_idx] = None
else:
columns[col_idx][0] = y
self.stdscr.refresh()
time.sleep(self.speed)
finally:
curses.curs_set(1)
curses.nocbreak()
self.stdscr.keypad(False)
curses.echo()
def main(stdscr):
import argparse
parser = argparse.ArgumentParser(description="Matrix Rain with Curses")
rain = CursesMatrixRain(stdscr, density=0.05, speed=0.05)
rain.run()
if __name__ == "__main__":
curses.wrapper(main)Expected output:
python matrix_curses.pySame visual effect as the ANSI version but with smoother rendering via curses. Press q to quit.
Step 3: Advanced: Color Themes and Custom Characters
Add this to either version for extra customization:
# matrix_themes.py — Add to matrix_ansi.py
THEMES = {
"matrix": {
"head": CSI + "92m", # Bright green
"body": CSI + "32m", # Green
"tail": CSI + "2;32m", # Dim green
},
"fire": {
"head": CSI + "91m", # Bright red
"body": CSI + "33m", # Yellow
"tail": CSI + "2;33m", # Dim yellow
},
"ice": {
"head": CSI + "96m", # Bright cyan
"body": CSI + "36m", # Cyan
"tail": CSI + "2;36m", # Dim cyan
},
"neon": {
"head": CSI + "95m", # Bright magenta
"body": CSI + "35m", # Magenta
"tail": CSI + "2;35m", # Dim magenta
},
"ghost": {
"head": CSI + "37m", # White
"body": CSI + "90m", # Bright black (gray)
"tail": CSI + "2;37m", # Dim white
},
}
def random_char_with_weight(charset: str) -> str:
"""Return a character where katakana appear more frequently than numbers."""
# 80% katakana, 20% other
katakana = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
latin = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
if random.random() < 0.8:
return random.choice(katakana)
else:
return random.choice(latin)Architecture
flowchart LR
A[Initialize Terminal] --> B[Get Terminal Size]
B --> C[Create Column Array]
C --> D{Render Loop}
D --> E[For Each Column]
E --> F{Column Active?}
F -->|No| G[Random Activation]
F -->|Yes| H[Update Y Position]
H --> I[Draw Head Character]
I --> J[Draw Trail Characters]
J --> K[Clear Tail]
K --> L[Check Boundaries]
L -->|Reset| M[Deactivate Column]
M --> D
L -->|Continue| D
D --> N[Render Frame to Terminal]
N --> O[Sleep (speed)]
O --> D
D --> P[Ctrl+C / q] --> Q[Show Cursor, Reset]
Q --> R[Exit]
Common Errors
1. Terminal shows garbage characters instead of rain
Some terminals don’t support full Unicode (katakana). Use --charset latin to test with ASCII characters. If even Latin characters show as garbage, your terminal encoding is not UTF-8. Run export LANG=en_US.UTF-8 before launching the script.
2. Flickering screen
Without double buffering, each sys.stdout.write() causes a partial screen update visible to the eye. The ANSI version minimizes this by building the entire frame in memory and writing it at once with \033[H (cursor home). If flickering persists, increase --speed (e.g., 0.1 for slower updates).
3. “sys.stdout.write() argument must be a string” error
The render_frame method returns a string, but if os.get_terminal_size() fails (e.g., running in a non-TTY context like a pipe), the dimensions might be wrong. Always wrap terminal operations in try/except and provide fallback dimensions.
4. Curses version: “addstr() returned ERR”
Curses raises an error when trying to write to the bottom-right corner of the terminal. This is a known curses limitation. Our code wraps addstr in try/except to handle it gracefully. If you see many errors, reduce the print area by one row/column.
Practice Questions
1. How does the trailing fade effect work? Each column tracks a trail length (5-20 characters). The lead character (lowest Y position) gets the brightest color. Characters above it get progressively dimmer colors. This creates the illusion of a falling streak that fades at the top. The brightness levels are: bright green (head) → green (body) → dim green (tail).
2. Why does each column have its own speed? Randomized speeds create a natural, organic look. If all columns fell at the same speed, the rain would look like a single wave moving down. With varying speeds, columns overlap and create the chaotic, dense rain effect characteristic of the Matrix.
3. How does the ANSI version clear previous frames?
The \033[H escape code moves the cursor to the home position (top-left). Writing a new frame overwrites the previous one character by character. Spaces at positions where characters no longer exist effectively clear them. This is simpler than clearing the entire screen between frames (which causes flicker).
4. Challenge: Add a “leader” character effect In the original Matrix, the first character in each trail is often a different symbol (like a highlighted katakana). Modify the code to track a separate “leader char” per column that stays constant while the trail falls. Change it only when the column resets. Use white color for the leader.
5. Challenge: Add interactive mode Make the rain respond to keyboard input. When the user types a letter, spawn a new bright trail at that column. When the user presses Space, create a burst of trails across all columns. Track a “combo” counter that increases with each interaction and displays in the corner.
FAQ
Next Steps
- Learn Python generators for memory-efficient frame rendering
- Explore Linux terminal control and job control
- Build the CLI Tool project for more terminal-based applications
- Check the Platformer Game project for game loop patterns
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro