Skip to content
Build a Matrix Rain Effect in the Terminal (Step by Step)

Build a Matrix Rain Effect in the Terminal (Step by Step)

DodaTech Updated Jun 20, 2026 10 min read

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

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.py

Green 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.py

Same 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

Why does the ANSI version sometimes leave ghost characters?
Ghost characters appear when terminal size changes during execution (e.g., resizing the window). The frame buffer doesn’t match the new dimensions. Restart the script after resizing. The curses version handles resize detection automatically via getmaxyx() in the loop.
How do I make the rain fill the entire terminal without gaps?
Increase the --density parameter. At 0.01, only 1% of columns are active — lots of gaps. At 0.1, 10% are active — dense coverage. At 0.2, almost every column has a trail. Higher density uses more CPU because more columns need updating each frame.
Can I run this on Windows?
Yes, but Windows Terminal or Windows 10+ native terminal is required (not cmd.exe). Enable VT escape sequence support. For the curses version, install windows-curses: pip install windows-curses. The ANSI version works on Windows Terminal with Python 3.9+.

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