Let's Build: Digital Rain (Matrix Code)

Published: July 17, 2025
Written by:
Natan Rolnik
Natan Rolnik

It’s time for another post in the Let’s Build series!

The last article showed how to obtain the size of the terminal window, how to perform animations in the terminal, and packaged all that in a tiny Swift package. A few weeks ago, I though: how hard could it be to build the famous Matrix Code (a.k.a. Digital Rain) for the terminal?

The original Digital Rain effect
The original Digital Rain effect

With Terminal Utilities handy, we have made our way easier. This post will use the same techniques described in the last article, and go through the process of building Digital Rain in Swift!

As the point of this series is to learn from building, no AI was used to build this program! We recommend you to do the same and exercise your own skills 💪

Getting Started

To create a new Swift executable, the easiest way is to use the init command of SPM:

swift package init --type executable

This creates a Package.swift file, and in the Sources directory, a main.swift file.

Dependencies

As the point of this post is not to focus on the implementation of colors the executable will rely on ColorizeSwift to handle the colors and other style-related features.

The second dependency, as mentioned in the introduction, is our own Terminal Utilities package.

Main File vs @main

To adopt a more modern approach, I prefer to use the @main attribute instead of a main file. After renaming the file to DigitalRain.swift, the package structure looks like this:

DigitalRain/
├── Package.swift
└── Sources/
    └── DigitalRain.swift

And for now, its content is only the @main attribute and function. For now it will contain a struct - but we’ll need to make it a class later:

@main
struct DigitalRain {
    static func main() {}
}

Lifecycle

The execution of the program starts in the main function. From there, it will be necessary to start a run loop, to keep the program running until the user terminates it, and refresh the screen to animate the changes.

Taking that into account, we add the following code:

static func main() async {
    await DigitalRain().start()
}

private func start() async {
    // 1
    Terminal.showCursor(false)

    // 2
    let timerTask = Task.repeatingTimer(interval: 0.08) {
        self.updateLines()
        self.render()
    }

    // 3
    await timerTask.value
}

The code above wraps the initialization code inside the start function, which is called by the main function. Its implementation is composed of the following steps:

  1. Use Terminal Utilities to hide the cursor
  2. Create a repeating timer that is triggered every 80ms, to update the contents of the screen and rerender the lines. Both functions inside the timer block are going to be explained in the next sections.
  3. Until the timer is cancelled, the program will keep running. Alternatively, instead of awaiting the timer, accessing the main run loop and running it with RunLoop.main.run() could also work.

Designing the Line Data Structure

When analyzing the original effect, I imagined I would have to store the characters in groups (i.e. a line), possibly divided by the column they belong to. Some lines reach the bottom of the window, and are not displayed anymore. To represent each moving line, a struct with the properties below should be enough:

struct Line {
    let letters: [Character]
    let duration: TimeInterval
    let appearedAt: Date
    var maxY: Int
}

Explaining each property:

  • The letters array stores the characters of each line
  • duration determines how long the line will be displayed before disappearing, as it might not reach the end of the window
  • appearedAt is the date when the line was added to the screen, which is used along with duration to calculate the moment when the line will disappear.
  • maxY is the row where the first character of the line is located. Because it will change as the line moves down the screen, it is a var and not a let.

Besides these stored properties, there are two computed properties that will be handy later on:

extension Line {
    var minY: Int {
        maxY - letters.count
    }

    var timeSinceAppearance: TimeInterval {
        Date().timeIntervalSince(appearedAt)
    }

    var shouldBeRemoved: Bool {
        timeSinceAppearance >= duration
    }
}

The minY property represents the row where the last character of the line is located, and can be calculated given the current maxY property, minus the number of characters in the line. The second property, timeSinceAppearance, gives the number of seconds since the line became visible, and is used to determine if the line shouldBeRemoved from the screen, based on the duration property.

Storing the Lines

To store the lines to be displayed, a dictionary can be enough. The key is the column number, and the value is an array of lines, as there might be multiple lines in the same column, as long as they are not overlapping.

💡 A dictionary is a better choice, instead of an array, to allow getting the lines in a column in constant time, instead of iterating over an array of arrays.
var lines: [Int: [Line]] = [:]

Before implementing the updateLines function, there is one important change. Being a struct, DigitalRain is not mutable, and therefore cannot be used to store the lines. Making it a class solves the problem. Also, to make it thread-safe, mark it with @MainActor.

@main
@MainActor
class DigitalRain {
    var lines: [Int: [Line]] = [:]
}

Creating and Updating the Lines

The updateLines is where lines are created and updated as the timer ticks. This is a long function, so we’ll split it into smaller parts and explain bit by bit.

// 1
let size = Terminal.size

func updateLines() {
    // 2
    for column in 0..<size.width {
        var thisColumnLines = lines[column, default: []]

        // 3
        let shouldAddNewLine = if let minLine = thisColumnLines.minLine, minLine.minY < 0 {
            false
        } else {
            Int.random(in: 1...40) % 40 == 0
        }

        // more code to come
    }
}

Explaining each block:

  1. Get the size of the terminal window and store it in a property of the DigitalRain class
  2. Iterate over each column of the window, and access the array of lines for this column, defaulting to an empty array.
  3. To determine if a new line needs to be added to the column, we’ll use two conditions: when there is a line, and its last character is still to appear, no new line is needed. Otherwise, a new line is added with a 1/40 probability, which ensures enough randomness.

Next, bump the maxY property of existing lines, and remove lines whose duration has expired:

thisColumnLines.enumerated().forEach { index, line in
    var copy = line
    copy.maxY += 1
    thisColumnLines[index] = copy
}

thisColumnLines.removeAll(where: .shouldBeRemoved)

To finalize this function, add a new line if needed, and update the dictionary with the updated (and possibly new) lines:

if shouldAddNewLine {
    let line = createLine(height: Int.random(in: 5..<10))
    thisColumnLines.append(line)
}

lines[column] = thisColumnLines

The createLine function in its turn creates a new line with random characters and duration:

func createLine(height: Int) -> Line {
    Line(
        letters: Character.randomArray(length: height),
        duration: TimeInterval.random(in: 1...5),
        appearedAt: Date(),
        maxY: 0
    )
}

The Character.randomArray static function is an extension helper method, that creates an array of random characters. The map method allows a clean implementation:

private let availableCharacters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

extension Character {
    static func randomArray(length: Int) -> [Character] {
        (0..<length).map { _ in
            availableCharacters.randomElement()!
        }
    }
}

The heaviest part of the program is behind us. Now that the lines are created and updated, it’s time to render them on screen!

Rendering the Lines

Drawing the lines is not that complicated, especially if you’ve already read the previous Terminal Utilities post, which explains animation techniques in the terminal. To summarize in a short sentence, the idea is to print characters, erase them, and repeat the process. When that changes fast enough, the animation effect is achieved.

To know how many characters to erase after each cycle, store that number in a property, and then use it in the render function:

var drawnLength = 0

func render() {
    // 1
    Terminal.eraseChars(drawnLength)

    // 2
    var result = ""
    for row in 0..<size.height {
        for column in 0..<size.width {

            // 3
            let thisColumnLines = lines[column] ?? []
            guard let line = thisColumnLines.line(at: row) else {
                // 4
                result.append(" ")
                continue
            }

            // more code to come
        }
    }
}

Here’s what the beginning of the render function does:

  1. Erase the previous characters printed on screen, if any.
  2. Declare the variable that will store the result of the function, and iterate over each row and column based on the window size
  3. Get the array of lines for the current column, and then, based on the current row, get the overlapping line, if any. The first line that has minY less than the current row, and maxY greater than the current row, is the overlapping line.
  4. If there is no overlapping line, add an empty space character to the result.

After guarding against the case that there is no overlapping line, we need to find the character to print in the given row, and append it to the result:

        let indexInArray = line.maxY - row - 1
        let char = line.letters[indexInArray]
        result.append(char)
    }
}

print(result, terminator: "")
drawnLength = result.count

After the resulting string is generated, print it on screen, and update the drawnLength property with its length. If you open Terminal now and run the program with swift run, you should see the following:

Advanced Features and Polish

As you can notice, the lines have different heights, they are moving and disappearing at random moments, and the characters are always different. All this is expected.

But there are some things that is still missing:

  • The first character should be white, and the rest should be green
  • Towards the moment the line disappears, it should fade out

To achieve these effects, we’ll use the ColorizeSwift package - and import it in the top of the file.

Using Colors

Determining the first character is easy, as we already calculated the index of the current character in the previous code block. With that, we check if the index is 0 and set the color accordingly. Use the foregroundColor method to set the color of the character:

let color: TerminalColor = if indexInArray == 0 {
    .white
} else {
    .green
}

var asString = String(char)
asString = asString.foregroundColor(color)
result.append(asString)

Running the program now results in the following:

Fading Out

The other half of the style is to fade out the line as it progresses. We’ll want to to that in two steps: First, once it reaches 25% of its duration, the line should start to fade out from the half end. Then, when reaching 65% of its duration, the line should start to fade out from the other half. We add this logic to these two properties in a Line extension:

var shouldDimHalfEnd: Bool {
    timeSinceAppearance >= duration * 0.25
}

var shouldDim: Bool {
    timeSinceAppearance >= duration * 0.65
}

Then, back to the render function, we can use these properties to dim the line completely, or only from the half end:

let isEndHalf = indexInArray > (line.letters.count / 2)

if (isEndHalf && line.shouldDimHalfEnd) || line.shouldDim {
    asString = asString.dim()
}

And with the coloring and dimming in place, it’s time to run Digital Rain one last time!

🥁 🥁 🥁

Notice how the lines start to fade out from their half end, then become completely dimmed as they reach the end of their duration, and then disappear.

With that, another post in the Let’s Build series reaches its end!

Explore Further

If you made it this far, congrats for reading this long post!

You can find the final code on GitHub.

If you have suggestions for new posts in the Let’s Build series, want to see a specific topic covered, or have any other feedback, don’t hesitate in pinging us on X or Mastodon.

See you at the next post. Have a good one!
Swift, Xcode, the Swift Package Manager, iOS, macOS, watchOS and Mac are trademarks of Apple Inc., registered in the U.S. and other countries.

© Swift Toolkit, 2024-2025