Matrix rain effect

2023-02-28

Ever since I first saw the 1999 film "The Matrix" I was captivated by the "rain" effect. Ten year old me thought it was the coolest thing in the world.

For the uninitiated: in the film the "Matrix" refers to a virtual world. On the inside of the Matrix the world feels and looks the same as the real world does. When on the outside you can look into the Matrix via PC monitors, what you see is this:

The rain effect of the "Matrix" film

In the film characters that have been staring at the "rain" for most of their lives do not see random characters, instead they see people, houses, bananas, etc, etc.

I always wanted to recreate this effect myself, this post will explain how my implementation works. The matrix rain animation you see above, is the final implementation of the animation.

The rules

Lets investigate the how "Matrix Rain" behaves, I've divined the rules from this video, which is a scene from the original 1999 film.

  • The screen is divided in as many columns as possible. The columns do not overlap, they are however positioned closely together.
  • Raindrops always fall down these columns from the top to the bottom.
  • As a raindrop falls, it will leave a trail. The trails length varies, some are short others very long.
  • A raindrop and its trail are made out of a specific predefined sset of characters and symbols. Most characters appear to be "Kanji", but there are also numerals and operators.
  • Only one raindrop can fall down a column at a time. The next drop always happens after the previous drop has finished. The next drop happens at a random interval, they spawn randomly.
  • The lowest point of a raindrop is always white. The trail is made of various shades of green. The trail does not fade from light to dark or dark to light, the trail colors are instead mixed, but never white!
  • Each character in a raindrop randomly changes. Each character changes at its own interval. Some change very quickly others stay the same for a long time.
  • Each color in a raindrops trail also changes randomly, each at its own interval.
  • Each raindrop falls at a random speed, but none feel slow. Some fall quite quickly and are difficult to follow, others fall slower and can be followed.

I have to admit I did not know the effect had so many rules :P.

The approach

I decided to recreate the animation using a HTML canvas element, the reason was simply to understand the canvas element a little better, so this was a nice excuse to use it.

I will also use React as this blog is written in Next.js, and using React will make it easier to render it in this blog post. I will however just use React to kickstart the canvas, and write most of the code in pure Typescript.

The code will be written in TypeScript mostly for the autocomplete, and to make it easier for me to write this post / code within a larger time frame. As life always tends to get in the way of my side project for some reason.

The general idea for the code behind the animation:

  • I will create a data structure which will represent the entire animation.
  • Next create a render function which will draw the data structure onto the given canvas.
  • Then I will create a tick function which will alter the data structure so it represents the new state.
  • Finally put everything into a loop: call tick and then render at a certain interval.

Lets get going.

Step 1: a simple prototype

I've never used a canvas in combination with React before, so I want to get that out of the way first:

import { useEffect, useRef } from 'react';

function MatrixRain() {
  // Will store the DOM node of the actual canvas element.
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (canvasRef.current) {
      // Now we can use the canvas without using React in any way.
      const canvas = canvasRef.current;
      
      // The following lines have nothing to do with React.
      const ctx = canvas.getContext('2d');
      ctx.fillStyle = 'green';
      ctx.fillText('M', 50, 50);
    }
  }, []);

  return (
    <div className="flex justify-center">
      <canvas ref={canvasRef} width={100} height={100} className="mb-4">
        The rain effect of the "Matrix" film
      </canvas>
    </div>
  );
}

This is what the code above produces visually:

The rain effect of the "Matrix" film

If you do not know React this may be a little hard to follow, but basically what this code does is create a canvas, and then inside of the useEffect draw a green rectangle onto that canvas.

Since our animation is two dimensional we use the 2d context of the canvas via canvas.getContext('2d'). You also use canvas for 3d drawings using canvas.getContext('webgl').

In React we can use useRef to get access to the real DOM element, in this case the actual canvas. useEffect allows you to run code after React has updated the DOM. This makes it a good place to manipulate the canvas and draw on it.

A green square is not that exciting, but we know have a canvas to work with.

Step 2: drawing a simple matrix

The next step is drawing a simplified version of a matrix.

First I'd like to define some TypeScript types:

type Column = string[];

type Matrix = Column[];

This defines a Column as an array of strings, and a Matrix as an array of Column. For this simplified version this should suffice.

Lets alter the useEffect:

const canvas = canvasRef.current;

const ctx = canvas.getContext('2d');

// This sets to font to a monospaced font for every letter
// that we will render.
ctx.font = '32px mono';

ctx.fillStyle = 'black';
ctx.fillRect(0, 0, CELL_SIZE * 3, CELL_SIZE * 3);

const matrix: Matrix = [
  ['a', 'b', 'c'],
  ['1', '2', '3'],
  ['a', 'b', 'c'],
];

render(matrix, ctx);

In the useEffect we set the font to a monospaced font which is 32px in size. Next we draw a black rectangle for the background of the matrix.

The way colors and fonts work inside of canvas is like this: once you set the font via ctx.font or the color via ctx.fillStyle, everything you draw has that font / color, until you change it.

One way to think about it is that whenever you change the color / font, you change the pencil / pen / paintbrush with which you draw on the canvas.

The const matrix: Matrix part defines a matrix which is hardcoded for now. We pass it along to a function called render:

const CELL_SIZE = 32;

function render(matrix: Matrix, ctx: CanvasRenderingContext2D): void {
  ctx.fillStyle = 'green';

  let x = 0;
  for (const column of matrix) {
    let y = 0;
    for (const char of column) {
      
      ctx.fillText(char, x, y);

      y += CELL_SIZE;
    }

    x += CELL_SIZE;
  }
}

The render function loops through the columns, and then through each columns characters and renders them using fillText. Each time increasing the x and y by the CELL_SIZE.

Visually this produces the following:

The rain effect of the "Matrix" film

This output is wrong because the first row is missing.

This bug is quite strange: drawing text at a y of 0 makes it invisible. To fix this I simply start the y at the CELL_SIZE:

function render(matrix: Matrix, ctx: CanvasRenderingContext2D): void {
  ctx.fillStyle = 'green';

  let x = 0;
  for (const column of matrix) {
    let y = ROW_HEIGHT;
    for (const char of column) {
      ctx.fillText(char, x, y);

      y += CELL_SIZE;
    }

    x += CELL_SIZE;
  }
}

This looks slightly better, but it is not there yet:

The rain effect of the "Matrix" film

Step 3: using matrix characters

Our matrix does not look very matrixy, because it does not use the right characters / symbols that the film uses.

I found this answer on scifi stackexchange which did all the heavy lifting for me. I did however end up removing some symbols such as because they rendered slightly to large.

I then added the following code:

// Utils

function createMatrix(): Matrix {
  const columns: Column[] = [];

  for (let i = 0; i < 3; i++) {
    const rows = [];

    for (let j = 0; j < 3; j++) {
      rows.push(randomChar());
    }

    columns.push(rows);
  }

  return columns;
}

function randomChar(): string {
  const random = Math.floor(Math.random() * MATRIX_CHARACTERS.length);

  return MATRIX_CHARACTERS[random];
}

const MATRIX_CHARACTERS = [
  'ハ',
  'ミ',
  'ヒ',
  // Many more symbols
] as const;

The createMatrix function generates a 3 x 3 matrix filled with random symbols from the film. It is now used inside of the useEffect instead of hardcoding the matrix.

The result looks a bit more like the real thing:

The rain effect of the "Matrix" film

Step 4: a larger matrix

The current 3 x 3 matrix is rather small, lets crank up the size.

Lets start by creating some configuration variables:

const WIDTH = 660;
const HEIGHT = 440;

const COLUMN_WIDTH = 20;
const COLUMNS = WIDTH / COLUMN_WIDTH;

const ROW_HEIGHT = 26;
const ROWS = Math.ceil(HEIGHT / ROW_HEIGHT);

The reason CELL_SIZE is replaced with COLUMN_WIDTH and ROW_HEIGHT, is so the characters are now rendered closer together, this mimics the original.

Note that the WIDTH is cleanly divisible by the COLUMN_WIDTH, leaving no remainders. The HEIGHT is not cleanly divisible by the ROW_HEIGHT which is why Math.ceil is called.

In the return of MatrixRain we can now use these new config variables:

return (
  <div className="flex justify-center">
    <canvas 
      ref={canvasRef} 
      width={WIDTH} 
      height={HEIGHT} 
      className="mb-4"
    >
      The rain effect of the "Matrix" film
    </canvas>
  </div>
);

And also in the createMatrix function so we generate the correct amount of columns and rows:

function createMatrix(): Matrix {
  const columns: Column[] = [];

  for (let i = 0; i < COLUMNS; i++) {
    const rows = [];

    for (let j = 0; j < ROWS; j++) {
      rows.push(randomChar());
    }

    columns.push(rows);
  }

  return columns;
}

Finally the render uses them as well:

function render(matrix: Matrix, ctx: CanvasRenderingContext2D): void {
  ctx.fillStyle = 'green';

  let x = 0;
  for (const column of matrix) {
    let y = ROW_HEIGHT;
    for (const char of column) {
      ctx.fillText(char, x, y);

      y += ROW_HEIGHT;
    }

    x += COLUMN_WIDTH;
  }
}

The result is a much larger matrix:

The rain effect of the "Matrix" film

Step 5: a resizable matrix

If the user resizes the page we are in trouble: the canvas could become wider than the page. To fix this we can add another useEffect which resizes the canvas whenever the user resizes the window:

useEffect(() => {
  function resizeCanvas() {
    if (canvasRef.current) {
      const width = Math.min(
        // prefer the WIDTH when there is enough room
        WIDTH, 
        // Otherwise make it as wide as the body 
        // but give it some padding.
        document.body.clientWidth - 16
      );

      // This automatically resizes the content
      // of the canvas, the content will zooms in. 
      canvasRef.current.style.width = `${width}px`;
    }
  }

  window.addEventListener('resize', resizeCanvas);

  resizeCanvas();

  return () => {
    window.removeEventListener('resize', resizeCanvas);
  };
}, []);

By resizing the style.width of the canvas the browser will automatically zoom in the canvas. This is why we do not have to worry about changing the number of columns / rows.

Secretly the previous example also includes this useEffect, so it scales. Sneaky me!

Step 6: a simple animation

Now that all the plumbing is in place we can finally start working on the animation.

First we will do a simple animation: changing all characters on screen to another random character. This way we can validate if our approach is working.

Lets begin by defining a tick function:

function tick(matrix: Matrix): void {
  for (const column of matrix) { 
    for (let i = 0; i < column.length; i++) {
      column[i] = randomChar();
    }
  }
}

When tick is called the animation should move to the next state. What this implementation does is simply change each cell to a random character.

Now we can alter the useEffect so it calls tick at an interval:

// The useEffect which draws on the canvas from within 
// the MatrixRain function / component.
useEffect(() => {
  if (canvasRef.current) {
    const canvas = canvasRef.current;

    const ctx = canvas.getContext('2d');

    ctx.font = '32px mono';

    const matrix: Matrix = createMatrix();

    const intervalId = window.setInterval(() => {
      tick(matrix);

      render(matrix, ctx);
    }, 1000);

    return () => {
      window.clearInterval(intervalId);
    };
  }
}, []);

tick is now called in the useEffect at an interval of 1000 milliseconds. After each tick the renderfunction is called, so the canvas is updated.

The render has also been altered, to fix a bug:

function render(matrix: Matrix, ctx: CanvasRenderingContext2D): void {
  // Clear the screen!
  ctx.fillStyle = 'rgb(0,16,0)';
  ctx.fillRect(0, 0, WIDTH, HEIGHT);

  ctx.fillStyle = 'green';

  let x = 0;
  for (const column of matrix) {
    let y = ROW_HEIGHT;
    for (const char of column) {
      ctx.fillText(char, x, y);

      y += ROW_HEIGHT;
    }

    x += COLUMN_WIDTH;
  }
}

render now also has to paint the background. The reason is so each render starts with a blank (or should I say black) canvas. If we did not do this we would paint over each previous "frame".

The background is also no longer black but a greenish black by setting the fillStyle to rgb(0,16,0).

The animation can be seen below, and you can toggle the glitch via the checkbox:

The rain effect of the "Matrix" film

Step 7: Adding raindrops

Now that we have a tick function we can start animating a raindrop. Lets start by changing the types:

type Cell = {
  /**
   * The position / index of the cell within the column. Used
   * to determine what the next cell in the column is.
   */
  position: number;

  /**
   * The character / symbol of the cell.
   *
   */
  char: string;
};

type Column = {
  /**
   * The cells that make up this column.
   */
  cells: Cell[];

  /**
   * The cell which is currently the head of the raindrop.
   */
  head: Cell;
};

type Matrix = Column[];

Column now contains a bunch of Cells. A Column knows which Cell is the current head of the raindrop.

A Cell knows which char it renders, and what the position of the Cell is within the Column

The render function must been adapted to deal with the new Cell type:

function render(matrix: Matrix, ctx: CanvasRenderingContext2D): void {
  // Abbreviated same green background render as before

  let x = 0;
  for (const column of matrix) {
    let y = ROW_HEIGHT;
    for (const cell of column.cells) {
      ctx.fillText(cell.char, x, y);

      y += ROW_HEIGHT;
    }

    x += COLUMN_WIDTH;
  }
}

The createMatrix function now creates a Matrix in which all first cells in a Column are heads:

function createMatrix(): Matrix {
  const columns: Column[] = [];

  for (let i = 0; i < COLUMNS; i++) {
    const cells: Cell[] = [];

    for (let j = 0; j < ROWS; j++) {
      const char = j === 0 ? randomChar() : '';

      const cell: Cell = {
        position: j,
        char,
      };

      cells.push(cell);
    }

    columns.push({ cells, head: cells[0] });
  }

  return columns;
}

Now we can finally update the tick function:

function tick(matrix: Matrix): void {
  for (const column of matrix) {
    const nextCell = column.cells[column.head.position + 1];

    // If there is a next cell we are not at the end of the screen.
    if (nextCell) {
      // Clear the previous head
      column.head.char = '';

      nextCell.char = randomChar();

      column.head = nextCell;
    } else {
      // Now that the head is off screen clear it
      column.head.char = '';
      column.head = column.cells[0];
    }
  }
}

The tick function moves the head down on each tick, it does this by clearing the previous heads char, and assigning the new head a randomChar().

When the nextCell is undefined we reset thehead back to the first cell.

The resulting animation looks like a line falling down:

The rain effect of the "Matrix" film

Step 8: spawning raindrops randomly

Raindrops should spawn at a random, and only when the previous raindrop is offscreen. To represent that not every Column has a raindrop we must change the type:

type Column = {
  /**
   * The cells that make up this column.
   */
  cells: Cell[];

  /**
   * The cell which is currently the head of the raindrop.
   * When it is `undefined` it means that the head of the raindrop
   * is not on the screen, but it could still have a trail.
   */
  head?: Cell;
};

By making the head property of Column optional via the ?: it can now be undefined, meaning no raindrop is present.

Lets alter the tick next:

const RAINDROP_SPAWN_RATE = 0.8;

function tick(matrix: Matrix): void {
  for (const column of matrix) {
    // Spawn a raindrop every once in a while, when there is no
    // head. As the animation should only repeat runs after the
    // complete raindrop is no longer on screen.
    const animationComplete = column.head === undefined;

    if (animationComplete && Math.random() > RAINDROP_SPAWN_RATE) {
      column.head = column.cells[0];

      column.head.char = randomChar();
    } else if (!animationComplete) {
      const nextCell = column.cells[column.head.position + 1];

      // If there is a next cell we are not at the end of the screen.
      if (nextCell) {
        column.head.char = '';

        nextCell.char = randomChar();

        column.head = nextCell;
      } else {
        // Now that the head is off screen clear it
        column.head.char = '';
        column.head = undefined;
      }
    }
  }
}

The tick function now uses the newly defined RAINDROP_SPAWN_RATE to determine if a raindrop should spawn. It only does so when the previous raindrop animation is complete.

Spawning basically means setting the head of the Column to be the first Cell.

Another change is that when there is no nextCell, we set the head back to undefined to mark the animation as complete. This then will allow a new raindrop to spawn again because animationComplete once again is false.

Finally in createMatrix there is no need to activate the first Cell anymore:

function createMatrix(): Matrix {
  const columns: Column[] = [];

  for (let i = 0; i < COLUMNS; i++) {
    const cells: Cell[] = [];

    for (let j = 0; j < ROWS; j++) {
      const cell: Cell = {
        position: j,
        char: ''
      };

      cells.push(cell);
    }

    columns.push({ cells, head: undefined });
  }

  return columns;
}

The result is beginning to look a little more like the real thing:

The rain effect of the "Matrix" film

Step 9: adding a trail

Each raindrop should have a randomly sized trail, we model this like so:

type Cell = {
  // position and char are still the same.

  /**
   * The amount of ticks the cell will be active / part of a raindrop.
   *
   * If the `activeFor` is 5 this means that for five ticks the
   * cell will be shown on the matrix. During those ticks it will
   * receive a new character.
   *
   * Each tick decreases `activeFor` by one.
   */
  activeFor: number;
};

type Column = {
  // head and cells are still the same.

  /**
   * The length of the current raindrop's trail.
   *
   * Each raindrop is assigned a new random trail.
   */
  trail: number;

  /**
   * The number of ticks left in the current raindrops animation.
   * 
   * Each tick decreases `ticksLeft` by one.
   */
  ticksLeft: number;
};

The big idea is adding a trail property to the Column it will get assigned a random number when a raindrop spawns.

Column also gets a ticksLeft property, which keep tracks of the remaining ticks until the raindrop has finished animating.

When ticksLeft is zero we know the animation is complete. Eachtick should decrease ticksLeft by one.

A Cell now needs to know for how many ticks it is active, we track this in activeFor. Each tick decreases it by one, once it hits zero the cell should no longer render.

The createMatrix has been updated so each new property is initialized:

function createMatrix(): Matrix {
  const columns: Column[] = [];

  for (let i = 0; i < COLUMNS; i++) {
    const cells: Cell[] = [];

    for (let j = 0; j < ROWS; j++) {
      const cell: Cell = {
        position: j,
        char: '',
        activeFor: 0
      };

      cells.push(cell);
    }

    columns.push({ 
      cells, 
      head: undefined, 
      trail: 0, 
      ticksLeft: 0 
    });
  }

  return columns;
}

Now we have all the pieces to update tick:

function tick(matrix: Matrix): void {
  for (const column of matrix) {
    // Spawn a raindrop every once in a while, when there is no
    // trail. As the animation should only repeat runs after the
    // complete raindrop is no longer on screen.
    const animationComplete = column.ticksLeft <= 0;

    if (animationComplete && Math.random() > RAINDROP_SPAWN_RATE) {
      // Some drops are really quite long!
      column.trail = randomNumberBetween(3, ROWS * 2);

      // The animation is done once the HEAD has moved through all
      // ROWS, and when the trail has moved past all ROWS.
      column.ticksLeft = ROWS + column.trail;

      column.head = column.cells[0];

      column.head.char = randomChar();

      column.head.activeFor = column.trail;
    } else {
      if (column.head) {
        const nextCell = column.cells[column.head.position + 1];

        // If there is a next cell we are not at the end of the screen.
        if (nextCell) {
          column.head = nextCell;

          nextCell.activeFor = column.trail;
        } else {
          column.head = undefined;
        }
      }

      // Remember the head can already offscreen, but this 
      // does not mean that the trail is. So always decrease
      // the ticksLeft.
      column.ticksLeft -= 1;
    }

    // Animate the cells
    for (const cell of column.cells) {
      if (cell.activeFor > 0) {
        cell.char = randomChar();

        cell.activeFor -= 1;
      } else {
        cell.char = '';
      }
    }
  }
}

// Utils

function randomNumberBetween(min: number, max: number): number {
  return Math.ceil(Math.random() * (max - min) + min);
}

Lets first look at the new for loop through each cell. This loop animates the cells which are active. A cell is active when activeFor is greater than zero. If a cell is active it gets a new random char. Now that the char is handled here, there is no need to call column.head.char = ''; anymore.

The animationComplete boolean has now changed so it looks at the ticksLeft instead of checking if there is a head.

When spawning a new raindrop a random trail is generated using a new util function called randomNumberBetween. We now also calculate how many ticks this raindrop animation will take and store it in ticksLeft.

I also had to remove the if-else now because the ticksLeft always needs to decrease when animating, even if the head is now undefined, because the animation is no longer tied only to the head.

Now the trickiest bit in the entire code is the following line: nextCell.activeFor = column.trail why does this work? At this point you might think: should the trail not be decreased? The answer is no, take a look at the following ASCII art:

/*
  In this scenario the trail is set to 2, the number of 
  ROWS are 4. Below you will see 5 ticks:  

  Legend:

  P = position in matrix
  C = char, is always 'x'
  A = is activeFor

  --- 0 ---   --- 1 ---   --- 2 ---   --- 4 ---   --- 5 ---
  =========   =========   =========   =========   =========
  P   C   A   P   C   A   P   C   A   P   C   A   P   C   A
  0 | x | 2   0 | x | 1   0 |   | 0   0 |   | 0   0 |   | 0
  1 |   | 0   1 | x | 2   1 | x | 1   1 |   | 0   1 |   | 0
  2 |   | 0   2 |   | 0   2 | x | 2   2 | x | 1   2 |   | 0
  3 |   | 0   3 |   | 0   3 |   | 0   3 | x | 2   3 | x | 1
*/

Hopefully this explains why the head should be visible for trail amount of ticks.

The matrix now animates with trails:

The rain effect of the "Matrix" film

Step 10: fixing the colors

A raindrops head should be white, and the trail shades of green. Lets first define some colors:

const GREENS = [
  '#15803d',
  '#16a34a',
  '#22c55e',
  '#4ade80',
] as const;

const WHITE = '#f0fdf4';

The greens where lifted straight from Tailwind CSS. The as const makes the array readonly, so nothing is accidentally changed.

A Cell needs a color, so lets add it to the type:

type Greens = typeof GREENS[number];

type Color = typeof WHITE | Greens;

type Cell = {
  /**
   * The color the cell has, will be WHITE when head, and a
   * GREENS when in the trail.
   */
  color: Color;
};

The effect of type Greens = typeof GREENS[number]; is that we create a new type which can only be one of the values of the GREENS array. When the array changes the type automatically changes as well.

I only recommend doing it this way instead of a regular union, when you need runtime access to the values. In our case we need runtime access to assign random GREENS to trails.

By defining Color in the way above, the accepted values for Color is either white or one of the greens.

The typeof WHITE creates a type from the const WHITE; Whenever we change WHITE the type also updates.

Next we change createMatrix by making all cells white so TypeScript is satisfied that each Cell has a valid Color:

const cell: Cell = {
  position: j,
  char: '',
  activeFor: 0,
  color: WHITE,
};

Now we can alter the render and actually use the color:

ctx.fillStyle = cell.color;
ctx.fillText(cell.char, x, y);

Instead of setting the color via ctx.fillStyle = 'green'; we set the color just before calling fillText, so the char is rendered with the right color.

Now lets alter the cell animation routine inside of tick:

for (const cell of column.cells) {
  if (cell.activeFor > 0) {
    if (column.head === cell) {
      cell.color = WHITE;
    } else {
      cell.color = randomGreen();
    }

    cell.char = randomChar();

    cell.activeFor -= 1;
  } else {
    cell.char = '';
  }
}

So when a cell is the head it becomes WHITE otherwise it becomes a random green.

I've added randomGreen to the utils, and added another helper called randomFromArray so both randomGreen and randomChar can use it.

function randomChar(): string {
  return randomFromArray(MATRIX_CHARACTERS);
}

function randomGreen(): Greens {
  return randomFromArray(GREENS);
}

function randomFromArray<T>(array: readonly T[]): T {
  const random = Math.floor(Math.random() * array.length);

  return array[random];
}

This brings us much closer to the final effect:

The rain effect of the "Matrix" film

Step 11: perfecting the cells animation

An active cell should change the color and character at varying speeds. Some change very quickly and others more slowly.

The plan is to add individual counters for the color and char. When these counters hit zero, the char / color need to update. This would allow us to change the color and char at different intervals:

type Cell = {
  // Abbreviated rest of the props are still the same.

  /**
   * The character / symbol of the cell.
   * 
   * The `char` will change when `retainChar` is `0`.
   */
  char: string;

  /**
   * The number of ticks to retain the 'char' for.
   */
  retainChar: number;

  /**
   * The color the cell has, will be WHITE when head, and a
   * GREENS when in the trail.
   * 
   * The `color` will change when `retainChar` is `0`.
   */
  color: Color;

  /**
   * The number of ticks to retain the 'color' for.
   */
  retainColor: number;
};

Now we need to alter the createMatrix again to accommodate these new properties:

const cell: Cell = {
  position: j,
  activeFor: 0,
  char: '',
  retainChar: 0,
  color: WHITE,
  retainColor: 0,
};

Starting them at 0 is fine as the tick sets them to the proper values:

for (const cell of column.cells) {
  if (cell.activeFor > 0) {
    if (column.head === cell) {
      // Make head white and update it the next tick
      cell.color = WHITE;
      cell.retainColor = 0;

      // Always give the head a random char
      cell.char = randomChar();
      cell.retainChar = randomNumberBetween(1, 10);
    } else {
      if (cell.retainColor <= 0) {
        cell.color = randomGreen();
        cell.retainColor = randomNumberBetween(1, 10);
      } else {
        cell.retainColor -= 1;
      }

      if (cell.retainChar <= 0) {
        cell.char = randomChar();
        cell.retainChar = randomNumberBetween(1, 10);
      } else {
        cell.retainChar -= 1;
      }
    }

    cell.activeFor -= 1;
  } else {
    cell.char = '';
  }
}

The code makes sure that a head cell is always WHITE for a single tick, and that it always has a char.

By decrementing retainColor and retainChar on each tick we move them closer to zero. When they become zero a new color or char is assigned for a random duration between 1 and 10 ticks.

The code above could even become more condensed if we add some helper functions, but that seemed a bit overkill to me.

We now have changing colors and chars in the animation:

The rain effect of the "Matrix" film

Step 12: adding raindrop speed

Each raindrop should drop at a different speed, I choose to model this at the Column level:

type Column = {
  // Abbreviated rest of Column is still the same.

  /**
   * The speed factor of the raindrop. The lower the number the higher
   * the speed.
   *
   * Each raindrop is assigned a new random speed.
   */
  speed: number;
};

You might be tempted to run each column in their own setInterval but this creates a lot of race conditions. Instead what I do is keep track of the amount of ticks that occurred, and only "tick" a column at every other speed:

// Keep track of the number of ticks made, this is used to determine
// the speed of a column.
let tickNo = 0;

function tick(matrix: Matrix): void {
  for (const column of matrix) {
    // Move to the next column if the current column should not tick.
    // This will give raindrops different speeds.
    if (tickNo % column.speed === 0) {
      continue;
    }

    const animationComplete = column.ticksLeft <= 0;

    if (animationComplete && Math.random() > RAINDROP_SPAWN_RATE) {
      // Manually vetted these speeds, 1 feels nice and fast,
      // and 6 can just barely be followed along.
      column.speed = randomNumberBetween(1, 6);
    
      // Abbreviated rest of the code is still the same.

  // Finally update the tickNo at the end of tick()
  tickNo += 1;
}

tickNo % column.speed !== 0 is where the magic happens. The modulo % operator returns the remainder after division. So for example:

  • 0 % 3 results in 0
  • 1 % 3 results in 1
  • 2 % 3 results in 2
  • 3 % 3 results in 0
  • 4 % 3 results in 1
  • 5 % 3 results in 2
  • 6 % 3 results in 0

As you can see the result is 0 once every 3 times.

So whenever the result is not 0 we use the continue keyword to move the loop over to the next column and leave the current column as is.

The last piece is changing createMatrix so it initializes each column with an initial speed of 1:

columns.push({
  cells,
  head: undefined,
  trail: 0,
  ticksLeft: 0,
  speed: 1,
});

If we would initialize speed with 0 instead the % expression would always result in 0, resulting in no raindrops being spawned.

Now we are have raindrops that move a varying speeds:

The rain effect of the "Matrix" film

Step 13: speeding up the animation

The animation feels too slow, so lets increase the framerate. We start by defining a new config variable:

// Twenty times a second seems like the sweet spot.
const FRAME_RATE = 1000 / 20;

Next we remove the hardcoded 1000 and plug in the FRAME_RATE into the setInterval:

const intervalId = window.setInterval(() => {
  tick(matrix);

  render(matrix, ctx);
}, FRAME_RATE);

The result is a why snappier animation:

The rain effect of the "Matrix" film

Presto

I'm quite pleased with how the animation turned out.

Here a link to the full code so you can play with it yourself.

Please share this post if you enjoyed it.