Matrix Digital Rain - xelsed.ai

This sketch recreates the iconic Matrix 'digital rain' - columns of glowing green symbols that continuously scream down a black canvas, each column moving at its own speed with a bright leading character and a fading trail behind it. Semi-transparent backgrounds and Canvas 2D glow effects give the characters a soft neon shimmer, while random character-switching makes the whole screen flicker like scrolling code.

🧪 Try This!

Experiment with the code by making these changes:

  1. Slow down the rain — Narrowing the random speed range makes every column fall much more slowly and gently.
  2. Grow the ghost trails — Lowering the background alpha means less of each frame gets covered, leaving longer glowing streaks behind every character.
  3. Turn the matrix red — Swapping the RGB values used for both the head and trail fill colors recolors the entire effect.
  4. Make characters bigger and blockier — Raising the clamp range lets recalcSymbolSize produce much larger, chunkier text and wider columns.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch fills the browser window with cascading columns of Katakana-like symbols, numbers, and letters that endlessly scroll downward - the classic 'Matrix code' look. Each column is its own independent stream with a randomized fall speed, a bright glowing 'head' character, and a dimmer trailing tail, all rendered in a monospace font on pure black. The glow itself comes from directly manipulating the underlying Canvas 2D context's shadowBlur property, while the ghostly fading trails are created with a clever trick: instead of clearing the background fully every frame, a semi-transparent black rectangle is drawn over everything.

The code is organized around two small classes - Stream, which represents one vertical column, and MatrixSymbol, which represents a single glowing character inside that column - plus a handful of p5.js lifecycle functions (setup, draw, windowResized) that wire everything together. Studying it will teach you how to structure animated systems with classes, how partial-alpha backgrounds fake motion trails, how to reach into p5's raw drawingContext for effects p5 doesn't expose directly, and how to keep a full-screen sketch responsive with windowResized.

⚙️ How It Works

  1. When the page loads, setup() creates a canvas that fills the entire browser window, switches to a monospace font, computes an appropriate character size for the screen width, and builds one Stream object per column of the grid.
  2. Each Stream constructs an array of MatrixSymbol objects stacked vertically above each other, with brightness fading from a bright 'head' at the bottom to dim 'tail' characters higher up, using map() to interpolate opacity.
  3. Every frame, draw() does NOT fully clear the canvas - instead it paints a black rectangle with partial transparency (background(0, 150)) over the previous frame, which is what makes older characters visually linger and fade instead of vanishing instantly.
  4. draw() then loops through every stream and calls its render() method, which in turn draws and updates every symbol in that column: draws the character at its current position, then moves it downward by its own speed value.
  5. When a symbol's position passes the bottom of the screen it wraps back above the top at a random height, and periodically (based on a random switchInterval) it swaps to a brand-new random character, producing the flickering, ever-changing code effect.
  6. If the browser window is resized, windowResized() recalculates the character grid size and completely rebuilds all the streams so the effect always fills the current window dimensions.

🎓 Concepts You'll Learn

Object-oriented classes in JavaScriptAnimation loop (draw)Alpha transparency for motion trailsCanvas 2D drawingContext accessResponsive canvas with windowResizedArrays of custom objectsmap() for value interpolationRandomness with random()

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It's the right place to configure the canvas, fonts, and any one-time calculations before the animation loop begins.

function setup() {
  createCanvas(windowWidth, windowHeight);
  frameRate(60);
  background(0);

  // Choose a monospaced look for nice columns
  textFont('monospace');
  recalcSymbolSize();
  textSize(symbolSize);
  textAlign(LEFT, TOP); // so y is the top of each glyph

  initStreams();
}
Line-by-line explanation (8 lines)
createCanvas(windowWidth, windowHeight);
Creates a canvas that exactly matches the current browser window size, so the rain fills the whole screen.
frameRate(60);
Requests that draw() runs 60 times per second, giving smooth, fast-falling motion.
background(0);
Paints the canvas fully black once at startup so there's no leftover white canvas before the first frame.
textFont('monospace');
Switches to a monospace font so every character takes up the same width, keeping columns perfectly aligned.
recalcSymbolSize();
Calls the helper function that figures out an appropriate character size based on the screen's width.
textSize(symbolSize);
Applies that calculated size to all text drawn from now on.
textAlign(LEFT, TOP);
Tells p5 to treat the x,y you give text() as the top-left corner of the glyph, which makes stacking characters vertically simple and predictable.
initStreams();
Builds the full array of Stream objects, one per column, so the rain is ready to animate.

draw()

draw() runs continuously after setup(), by default 60 times per second. Everything that needs to move or update over time belongs here.

🔬 This loop renders every stream once per frame. What do you think happens visually if you call stream.render() twice inside the loop - will the rain fall twice as fast, or just look brighter?

  for (let stream of streams) {
    stream.render();
  }
function draw() {
  // Semi-transparent black to create fading trails
  // background(v, alpha): https://p5js.org/reference/#/p5/background
  background(0, 150);

  for (let stream of streams) {
    stream.render();
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Render Every Stream for (let stream of streams) { stream.render(); }

Loops through every column's Stream object and tells it to draw and update its characters this frame.

background(0, 150);
Draws a black rectangle over the whole canvas but only 150/255 opaque, so older frames show through faintly - this is the entire trick behind the fading trails.
for (let stream of streams) {
Starts a loop that visits every Stream object in the global streams array, one per column.
stream.render();
Calls the Stream's render method, which draws and moves every character in that column.

windowResized()

windowResized() is a special p5.js function that automatically runs whenever the browser window changes size, letting you keep full-screen sketches responsive.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  background(0);
  recalcSymbolSize();
  textSize(symbolSize);
  initStreams();
}
Line-by-line explanation (5 lines)
resizeCanvas(windowWidth, windowHeight);
Resizes the existing canvas to match the browser's new width and height, instead of creating a whole new canvas.
background(0);
Clears the freshly resized canvas to solid black so there's no stretched or blank leftover image.
recalcSymbolSize();
Recomputes the ideal character size for the new window width.
textSize(symbolSize);
Applies the newly calculated font size.
initStreams();
Rebuilds every Stream from scratch so the grid of columns fits the new canvas size exactly.

recalcSymbolSize()

This helper keeps the sketch legible on any screen size by deriving character size from window width instead of using one fixed number.

🔬 The divisor 40 controls how many columns roughly fit on screen. What happens to the density of the rain if you change 40 to 20? To 80?

  symbolSize = floor(width / 40);
  symbolSize = constrain(symbolSize, 14, 32);
function recalcSymbolSize() {
  // Adapt character size to screen width; clamp to usable range
  symbolSize = floor(width / 40);
  symbolSize = constrain(symbolSize, 14, 32);
}
Line-by-line explanation (2 lines)
symbolSize = floor(width / 40);
Divides the canvas width by 40 to get a character size that scales with screen size, then rounds down to a whole number of pixels.
symbolSize = constrain(symbolSize, 14, 32);
Clamps that value so it never gets smaller than 14px (unreadable) or bigger than 32px (too chunky), regardless of screen size.

initStreams()

This function turns the abstract idea of 'columns' into concrete Stream objects, one of the core patterns of object-oriented creative coding: use a loop to instantiate many objects from a class.

🔬 What happens to the rain's density if you only create a stream for every other column, by changing i++ to i += 2?

  for (let i = 0; i < columns; i++) {
    const x = i * symbolSize;
    streams.push(new Stream(x));
  }
function initStreams() {
  streams = [];
  const columns = floor(width / symbolSize);

  for (let i = 0; i < columns; i++) {
    const x = i * symbolSize;
    streams.push(new Stream(x));
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Build One Stream Per Column for (let i = 0; i < columns; i++) { const x = i * symbolSize; streams.push(new Stream(x)); }

Creates a new Stream object positioned at each column's x coordinate and adds it to the global streams array.

streams = [];
Empties the global streams array so old streams from a previous size don't linger.
const columns = floor(width / symbolSize);
Figures out how many character-wide columns fit across the canvas.
const x = i * symbolSize;
Calculates the horizontal pixel position for column number i.
streams.push(new Stream(x));
Creates a brand-new Stream object at that x position and adds it to the array so draw() can animate it.

Stream constructor

Giving each Stream a random speed is what creates the illusion of depth - faster columns feel closer to the viewer, slower ones feel further away.

constructor(x) {
    this.x = x;
    // Different speeds per stream for depth effect
    this.speed = random(3, 12);
    this.symbols = [];

    this.createSymbols();
  }
Line-by-line explanation (4 lines)
this.x = x;
Stores this stream's fixed horizontal position - every symbol in it will share this x coordinate.
this.speed = random(3, 12);
Picks a random fall speed between 3 and 12 pixels per frame, so different columns move at different rates for a sense of depth.
this.symbols = [];
Creates an empty array that will hold this stream's individual MatrixSymbol characters.
this.createSymbols();
Immediately builds the full set of characters for this stream.

Stream.createSymbols()

This method demonstrates using map() to smoothly interpolate a value (brightness) across a sequence, a very common pattern for creating gradients in creative coding.

🔬 map() controls the brightness gradient from head to tail. What happens to the trail's contrast if you change the minimum from 60 to 0? Or to 200?

      const opacity = map(i, 0, totalSymbols - 1, 255, 60);
createSymbols() {
    const totalSymbols = floor(random(8, 25));
    const startY = random(-height, 0);

    let y = startY;
    for (let i = 0; i < totalSymbols; i++) {
      const isHead = (i === 0); // bottom of the stream is the "head"
      // Brighter towards the bottom (head)
      const opacity = map(i, 0, totalSymbols - 1, 255, 60);
      const symbol = new MatrixSymbol(this.x, y, this.speed, isHead, opacity);
      this.symbols.push(symbol);
      y -= symbolSize; // move upward for the next symbol in this stream
    }
  }
Line-by-line explanation (6 lines)

🔧 Subcomponents:

for-loop Stack Symbols Vertically for (let i = 0; i < totalSymbols; i++) { const isHead = (i === 0); const opacity = map(i, 0, totalSymbols - 1, 255, 60); const symbol = new MatrixSymbol(this.x, y, this.speed, isHead, opacity); this.symbols.push(symbol); y -= symbolSize; }

Creates a chain of MatrixSymbol objects stacked above each other, fading in brightness from the bottom head to the dim tail.

const totalSymbols = floor(random(8, 25));
Randomly decides how many characters (8 to 24) make up this stream's tail length.
const startY = random(-height, 0);
Picks a random starting y position above the visible canvas so streams don't all start at the same height.
const isHead = (i === 0);
The very first symbol created (i=0) is flagged as the bright 'head' of the stream.
const opacity = map(i, 0, totalSymbols - 1, 255, 60);
Uses map() to convert the symbol's index into an opacity value, so index 0 is fully bright (255) and the last symbol is dim (60).
const symbol = new MatrixSymbol(this.x, y, this.speed, isHead, opacity);
Creates a new character object with this stream's shared x and speed, its own y position, and its calculated brightness.
y -= symbolSize;
Moves the next symbol's position up by one character height, stacking them into a vertical column.

Stream.render()

Notice render happens before update - the symbol is drawn at its current position first, then moved, which is a common animation pattern of draw-then-step.

render() {
    for (let s of this.symbols) {
      s.render();
      s.update();
    }
  }
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Draw and Move Each Symbol for (let s of this.symbols) { s.render(); s.update(); }

Draws every character in this stream to the canvas, then updates its position so it moves next frame.

for (let s of this.symbols) {
Loops through every MatrixSymbol object belonging to this stream.
s.render();
Draws that symbol's current character to the canvas at its current position.
s.update();
Moves that symbol downward and possibly swaps its character, preparing it for the next frame.

MatrixSymbol constructor

Each character gets its own independent flicker timing, which is what makes the rain feel alive and organic rather than mechanically synchronized.

constructor(x, y, speed, isHead, opacity) {
    this.x = x;
    this.y = y;
    this.speed = speed;
    this.isHead = isHead;
    this.opacity = opacity;

    // How often this symbol changes its character
    this.switchInterval = floor(random(2, 20));
    this.value = '';
    this.setToRandomSymbol();
  }
Line-by-line explanation (3 lines)
this.switchInterval = floor(random(2, 20));
Randomly decides how many frames must pass before this specific character flickers to a new symbol - between every 2 and 19 frames.
this.value = '';
Starts with an empty placeholder character before one is chosen.
this.setToRandomSymbol();
Immediately picks the first random character to display.

MatrixSymbol.setToRandomSymbol()

This function shows a common pattern: build a pool of possible values, then pick one randomly using floor(random(...)) to get a valid array/string index.

🔬 This pool decides every character that can appear. What happens visually if you remove all the letter and number lines, leaving only the Katakana-style string?

    const charPool =
      'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン' +
      '0123456789' +
      'ABCDEFGHIJKLMNOPQRSTUVWXYZ' +
      '@#$%&';
setToRandomSymbol() {
    // Mix of Japanese Katakana-like chars and ASCII
    const charPool =
      'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン' +
      '0123456789' +
      'ABCDEFGHIJKLMNOPQRSTUVWXYZ' +
      '@#$%&';
    const index = floor(random(charPool.length));
    this.value = charPool.charAt(index);
  }
Line-by-line explanation (3 lines)
const charPool = '...' + '0123456789' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + '@#$%&';
Builds one long string containing every possible character this sketch might display: Katakana-style glyphs, digits, letters, and symbols.
const index = floor(random(charPool.length));
Picks a random whole-number index somewhere inside that string's length.
this.value = charPool.charAt(index);
Grabs the single character at that random index and stores it as this symbol's currently displayed value.

MatrixSymbol.update()

update() is where per-frame logic (movement, wrapping, timed events) lives, separate from render() which only draws - keeping these concerns separate is a common and useful pattern in animation code.

🔬 This is what makes the rain loop endlessly. What happens if you change random(-200, 0) to a fixed value like -symbolSize - do all symbols re-enter at exactly the same height now?

    if (this.y > height + symbolSize) {
      this.y = random(-200, 0);
    }
update() {
    this.y += this.speed;

    // Wrap back to above the top when we pass the bottom
    if (this.y > height + symbolSize) {
      this.y = random(-200, 0);
    }

    // Randomly change character over time
    if (frameCount % this.switchInterval === 0) {
      this.setToRandomSymbol();
    }
  }
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Wrap Below Bottom if (this.y > height + symbolSize) { this.y = random(-200, 0); }

Detects when a symbol has fallen past the bottom edge and teleports it back above the top of the canvas.

conditional Flicker to New Character if (frameCount % this.switchInterval === 0) { this.setToRandomSymbol(); }

Uses the modulo operator to periodically trigger a random character swap based on this symbol's own switchInterval timing.

this.y += this.speed;
Moves the symbol downward by its speed amount every single frame, which is what makes it fall.
if (this.y > height + symbolSize) {
Checks whether the symbol has moved completely past the bottom edge of the canvas (plus a small buffer).
this.y = random(-200, 0);
Sends the symbol back to a random position just above the top of the screen, so it re-enters as if falling from off-screen.
if (frameCount % this.switchInterval === 0) {
Uses the modulo operator to check whether the current frame number divides evenly by this symbol's switchInterval - true only once every switchInterval frames.
this.setToRandomSymbol();
Swaps the displayed character to a new random one, creating the flickering code effect.

MatrixSymbol.render()

This function shows how to escape p5's normal drawing API and use drawingContext to access native Canvas 2D features like shadowBlur, unlocking glow effects p5 doesn't support out of the box.

🔬 The head character gets a special pale, extra-glowy look. What happens if you make the head use the exact same color and glow as the trail - does the effect lose its 'leading edge' feel?

    if (this.isHead) {
      // Brighter, more intense glow for the head
      ctx.shadowBlur = 18;
      ctx.shadowColor = 'rgba(180, 255, 180, 0.9)';
      fill(180, 255, 180, this.opacity);
    } else {
render() {
    push();

    // Use the underlying Canvas 2D context for glow
    // drawingContext: https://p5js.org/reference/#/p5/drawingContext
    const ctx = drawingContext;

    if (this.isHead) {
      // Brighter, more intense glow for the head
      ctx.shadowBlur = 18;
      ctx.shadowColor = 'rgba(180, 255, 180, 0.9)';
      fill(180, 255, 180, this.opacity);
    } else {
      // Slightly dimmer green with softer glow
      ctx.shadowBlur = 10;
      ctx.shadowColor = 'rgba(0, 255, 70, 0.6)';
      fill(0, 255, 70, this.opacity);
    }

    noStroke();
    text(this.value, this.x, this.y);

    // Reset shadow so it doesn't leak into other drawings
    ctx.shadowBlur = 0;
    ctx.shadowColor = 'rgba(0,0,0,0)';

    pop();
  }
Line-by-line explanation (11 lines)

🔧 Subcomponents:

conditional Head vs Trail Styling if (this.isHead) { ctx.shadowBlur = 18; ctx.shadowColor = 'rgba(180, 255, 180, 0.9)'; fill(180, 255, 180, this.opacity); } else { ctx.shadowBlur = 10; ctx.shadowColor = 'rgba(0, 255, 70, 0.6)'; fill(0, 255, 70, this.opacity); }

Chooses brighter, whiter-green glow and fill for the lead character versus dimmer green glow for trailing characters.

push();
Saves the current drawing style settings so changes made in this function don't leak into other drawings.
const ctx = drawingContext;
Grabs p5's underlying raw HTML5 Canvas 2D context, which exposes extra features like shadowBlur that p5 doesn't wrap directly.
ctx.shadowBlur = 18;
Sets a strong blur radius around anything drawn next, creating a glowing halo effect for the bright head character.
ctx.shadowColor = 'rgba(180, 255, 180, 0.9)';
Sets the glow's color to a pale, almost-white green, matching the brightest part of the stream.
fill(180, 255, 180, this.opacity);
Sets the actual text fill color to that same pale green, using this symbol's calculated opacity.
ctx.shadowBlur = 10;
For non-head characters, uses a softer, smaller glow radius.
ctx.shadowColor = 'rgba(0, 255, 70, 0.6)';
Sets a more saturated, classic Matrix green glow color for trailing characters.
fill(0, 255, 70, this.opacity);
Fills trailing characters with that classic green, again scaled by their opacity for a fading effect.
text(this.value, this.x, this.y);
Draws the actual character string at this symbol's x and y position using all the fill and glow settings just configured.
ctx.shadowBlur = 0;
Resets the glow blur back to zero so it doesn't accidentally affect anything drawn afterward.
pop();
Restores the drawing style settings that were active before this function ran.

📦 Key Variables

streams array

Holds every Stream object currently animating - one per column of the grid. Rebuilt whenever the sketch starts or the window resizes.

let streams = [];
symbolSize number

Stores the current pixel size used for both the font and the column width, recalculated to fit the screen.

let symbolSize = 20;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

PERFORMANCE MatrixSymbol.setToRandomSymbol()

The charPool string is rebuilt from four concatenated string literals every single time a symbol switches characters, which could happen dozens of times per frame across the whole grid.

💡 Move charPool out to a module-level constant declared once (e.g. const CHAR_POOL = '...') so it's created a single time instead of on every call.

BUG initStreams()

Using floor(width / symbolSize) for the column count can leave a thin gap of empty canvas on the right edge if the width doesn't divide evenly by symbolSize.

💡 Use Math.ceil(width / symbolSize) instead of floor so the last partial column still gets a stream, fully covering the canvas edge to edge.

PERFORMANCE windowResized()

Every resize event immediately calls initStreams(), which throws away all existing streams and rebuilds the whole grid - during a drag-resize this can fire many times per second and cause jank.

💡 Debounce windowResized with a short setTimeout/clearTimeout so initStreams() only runs once resizing has paused for ~150ms.

STYLE MatrixSymbol.render()

Magic numbers like 18, 10, and the RGBA color strings are duplicated inline, making it harder to keep head/trail styling consistent if you want to theme the sketch later.

💡 Extract these into named constants (e.g. HEAD_GLOW, HEAD_COLOR, TRAIL_GLOW, TRAIL_COLOR) declared once near the top of the file.

🔄 Code Flow

Code flow showing setup, draw, windowresized, recalcsymbolsize, initstreams, streamconstructor, createsymbols, streamrender, matrixsymbolconstructor, settorandomsymbol, matrixsymbolupdate, matrixsymbolrender

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> stream-loop[Render Every Stream] stream-loop --> symbol-loop[Draw and Move Each Symbol] symbol-loop --> wrap-check[Wrap Below Bottom] symbol-loop --> flicker-check[Flicker to New Character] symbol-loop --> head-vs-trail[Head vs Trail Styling] draw --> windowresized[windowResized] draw --> recalcsymbolsize[recalcSymbolSize] draw --> initstreams[initStreams] initstreams --> create-columns-loop[Build One Stream Per Column] create-columns-loop --> streamconstructor[streamConstructor] streamconstructor --> createsymbols[createSymbols] createsymbols --> symbol-build-loop[Stack Symbols Vertically] symbol-build-loop --> symbol-loop click setup href "#fn-setup" click draw href "#fn-draw" click stream-loop href "#sub-stream-loop" click symbol-loop href "#sub-symbol-loop" click wrap-check href "#sub-wrap-check" click flicker-check href "#sub-flicker-check" click head-vs-trail href "#sub-head-vs-trail" click windowresized href "#fn-windowresized" click recalcsymbolsize href "#fn-recalcsymbolsize" click initstreams href "#fn-initstreams" click create-columns-loop href "#sub-create-columns-loop" click streamconstructor href "#fn-streamconstructor" click createsymbols href "#fn-createsymbols" click symbol-build-loop href "#sub-symbol-build-loop"

❓ Frequently Asked Questions

What visual effect does the Matrix Digital Rain sketch create?

The sketch produces a classic Matrix-style digital rain effect with green characters cascading down the screen in columns, featuring bright characters at the forefront and dimmer trails behind.

Is there any user interaction available in the Matrix Digital Rain sketch?

This sketch does not include interactive features; it simply runs the animation continuously based on the frame rate.

What creative coding concepts are demonstrated in this p5.js sketch?

The sketch showcases techniques like manipulating background transparency for fading effects, dynamic symbol generation, and responsive design through window resizing.

Preview

Matrix Digital Rain - xelsed.ai - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Matrix Digital Rain - xelsed.ai - Code flow showing setup, draw, windowresized, recalcsymbolsize, initstreams, streamconstructor, createsymbols, streamrender, matrixsymbolconstructor, settorandomsymbol, matrixsymbolupdate, matrixsymbolrender
Code Flow Diagram