Let's Build: Digital Rain (Matrix Code)
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?

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
Main File vs 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:
- Use Terminal Utilities to hide the cursor
- 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.
- Until the timer is cancelled, the program will keep running. Alternatively, instead of
await
ing the timer, accessing the main run loop and running it withRunLoop.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 windowappearedAt
is the date when the line was added to the screen, which is used along withduration
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 avar
and not alet
.
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.
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:
- Get the size of the terminal window and store it in a property of the
DigitalRain
class - Iterate over each column of the window, and access the array of lines for this column, defaulting to an empty array.
- 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:
- Erase the previous characters printed on screen, if any.
- Declare the variable that will store the result of the function, and iterate over each row and column based on the window size
- 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, andmaxY
greater than the current row, is the overlapping line. - 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!