- Matrix Rain Simulation
- Design of the Program
- RainDrop Class
- RainColumn Class
- RainSheet Class
- Matrix Rain Program
- Simulation Configuration
Version | Date | Comments |
---|---|---|
0.1 | 21/05/2024 | Initial version |
Matrix Rain Simulation
This is a literate program to simulate the famous Matrix Rain simulation. It is a straightforward implementation written in Love2d. The rain visualization is implemented using classes which represent the objects displayed on the screen.
The source code for this program is on github at https://github.com/abhishekmishra/explorations/tree/main/matrix-rain.
Demo
Here is a demo of the final program output. Note that it is in low resolution to reduce the file size.
Screenshot
A screenshot of the final program on windows shows how one frame looks like.
Literate Programming using litpd
This program is written using my own literate programming tool named litpd. litpd
is a command-line tool that takes a markdown document in pandoc format, and creates two outputs. The first output is a human readable document in a format like html/pdf. The second output is the source code files for building and running the program.
Building and Running the Program
See the Makefile
in the current directory to see how to build and run the program.
Design of the Program
- The Matrix Rain program tries to simulate each of the objects in the scene as classes.
- Each individual character dropping down the scene is modelled as a
RainDrop
class. - Each column of falling
RainDrops
is modelled as aRainColumn
class. - Finally the whole rain is modelled as a
RainSheet
. - The following class diagram shows the relationships of the three classes.
- The
main.lua
program instantiates theRainSheet
and calls theupdate
anddraw
functions of the object at appropriate times. - The
RainSheet
in turn callsupdate
anddraw
on eachRainColumn
which in turn does the same for eachRainDrop
. - Various input parameters to the
RainSheet
decide the number of columns, number of drops per column and also the maximum speed of the rain.
In the subsequent sections we implement the classes first and then we use the classes in the main.lua
program.
RainDrop Class
- The
RainDrop
class represents the smallest unit/atom of the program. It represents one falling letter on the screen. - Each
RainDrop
is constructed with a configuration, some initialization parameters. - The
update(dt)
anddraw()
methods correspond to the lifecycle methods of love2d and are supposed to be called every frame. They update the state of the instance and draw the instance respectively. - There are a couple of utility methods
resetPosition
andsetAlphabet
provided to help reuse the instance as another drop after the current one has gone past the screen. - The
inFrame
method helps figure out if theRainDrop
is beyond the screen dimenstions.
The program uses the middleclass
library for implementing classes.
file: raindrop.lua
local Class = require 'middleclass'
local utf8 = require("utf8")
@<hsvrgb@>
-- fonts for the raindrop
local NORMAL_FONT
local GLOW_FONT
local GREEN_HSV = {1/3, 1, 1}
local RainDrop = Class('RainDrop')
@<raindropconstructor@>
@<raindropupdate@>
@<raindropdraw@>
@<raindropinframe@>
@<raindropresetposition@>
@<raindropsetalphabet@>
return RainDrop
HSV to RGB Utility
The colours used do draw the RainDrop
are stored in HSV and are only converted to RGB when drawing. This way the value part of the colour can be changed on every update to give a flickering look to the letter and the scene as a whole.
The following utility method from the love2d.org wiki site helps convert from HSV values to RGB values.
id: hsvrgb
--- copied from https://love2d.org/wiki/HSV_color
-- Converts HSV to RGB. (input and output range: 0 - 1)
local function HSV(h, s, v)
if s <= 0 then return v,v,v end
h = h*6
local c = v*s
local x = (1-math.abs((h%%2)-1))*c
local m,r,g,b = (v-c), 0, 0, 0
if h < 1 then
r, g, b = c, x, 0
elseif h < 2 then
r, g, b = x, c, 0
elseif h < 3 then
r, g, b = 0, c, x
elseif h < 4 then
r, g, b = 0, x, c
elseif h < 5 then
r, g, b = x, 0, c
else
r, g, b = c, 0, x
end
return r+m, g+m, b+m
end
RainDrop Constructor
- The
RainDrop
is constructed with some initialization parameters. These parameters specify:- A position (
config.x
,config.y
) - The dimensions of the drop (
config.w
,config.h
) - The speed of the drop (
config.vx
,config.vy
). Thevx
is always 0 in the current program as the rain only drops down and does not move horizontally. - The optional
config.color
can set the colour of the text to the specified HSV value.
- A position (
- The constructor generates a random alphabet from a few supported codepoint ranges in unicode and also loads the appropriate fonts.
- A love2d text object representing the alphabet to be drawn is created and stored to avoid creating it in every draw call.
id: raindropconstructor
function RainDrop:initialize(config)
self.config = config
self.x = config.x
self.y = config.y
self.w = config.w
self.h = config.h
self.vx = config.vx
self.vy = config.vy
self.color = config.color or GREEN_HSV
self.glowColor = self.color
local lang = math.random(1, 3)
if lang == 1 then
self.alphabet = utf8.char(utf8.codepoint('अ') + math.random(0, 50))
elseif lang == 2 then
self.alphabet = utf8.char(utf8.codepoint('a') + math.random(0, 25))
elseif lang == 3 then
self.alphabet = utf8.char(utf8.codepoint('ಅ') + math.random(0, 30))
end
if not NORMAL_FONT then
NORMAL_FONT = {
love.graphics.newFont('NotoSans_Condensed-Regular.ttf', math.min(self.w, self.h)),
love.graphics.newFont('NotoSans_Condensed-Regular.ttf', math.min(self.w, self.h)),
love.graphics.newFont('NotoSansKannada-Regular.ttf', math.min(self.w, self.h))
}
GLOW_FONT = {
love.graphics.newFont('NotoSans_Condensed-Regular.ttf', 0.95 * math.min(self.w, self.h)),
love.graphics.newFont('NotoSans_Condensed-Regular.ttf', 0.95 * math.min(self.w, self.h)),
love.graphics.newFont('NotoSansKannada-Regular.ttf', 0.95 * math.min(self.w, self.h))
}
end
-- create love2d text for the alphabet
self.text = love.graphics.newText(NORMAL_FONT[lang], self.alphabet)
self.glowText = love.graphics.newText(GLOW_FONT[lang], self.alphabet)
self.timer = 0
end
RainDrop Update
- The
update(dt)
method updates the position of the drop. - In this method we also use inbuilt Simplex noise to change the Value part of the drawing colour thus giving a flickering look to the text.
- The
timer
used to get the time-based parameter for the noise lookup is also reset if it crosses a hard-coded threshold of 100 seconds.
id: raindropupdate
function RainDrop:update(dt)
self.x = self.x + (self.vx * dt)
self.y = self.y + (self.vy * dt)
local timeSlot = self.timer %% 17
local ySlot = self.y %% 50
local xSlot = self.x %% 100
self.color[3] = love.math.noise(ySlot, xSlot, timeSlot)
self.glowColor[3] = love.math.noise(ySlot, xSlot, timeSlot)
self.timer = self.timer + dt
if self.timer > 100 then
self.timer = 0
end
end
RainDrop Draw
- We simply use the colour, position and text of the drop to draw it.
id: raindropdraw
function RainDrop:draw()
local color_rgb = {HSV(unpack(self.color))}
color_rgb[4] = 1
local glowColor_rgb = {HSV(unpack(self.glowColor))}
glowColor_rgb[4] = 0.8
love.graphics.setColor(color_rgb)
love.graphics.draw(self.text, self.x + self.w/2 - self.text:getWidth()/2,
self.y + self.h/2 - self.text:getHeight()/2)
love.graphics.setColor(glowColor_rgb)
love.graphics.draw(self.glowText,
self.x + self.w/2 - self.glowText:getWidth()/2,
self.y + self.h/2 - self.glowText:getHeight()/2)
end
RainDrop In Frame?
- This method checks if the current postion of the drop is beyond the bounds of the canvas.
id: raindropinframe
function RainDrop:inFrame(cw, ch)
return self.x <= cw and self.y <= ch
end
RainDrop Reset Position
- Updates the position of the drop back to its initial settings.
id: raindropresetposition
function RainDrop:resetPosition(x, y)
self.x = self.config.x
self.y = self.config.y
end
RainDrop Set Alphabet
- Change the alphabet to the given alphabet.
- This method is not used at the moment.
id: raindropsetalphabet
function RainDrop:setAlphabet(alpha)
self.alphabet = alpha
end
RainColumn Class
- The
RainColumn
class contains a sequence or an array ofRainDrop
instances. - In the simulation it represents one column of display in the matrix rain simulation.
- The
RainColumn
is initialized with some configuration parameters including its location, size, velocity, and number of rows/drops. - The class has the standard lifecycle methods of
update(dt)
anddraw()
methods to be called at the appropriate time. - Some utility methods like
initDrops
,inFrame
andresetDrops
provide the ability to reuse the same instance once the entire column is past the screen. This helps us reduce the number of objects created by simply resetting the column to its original position.
file: raincolumn.lua
local Class = require 'middleclass'
local RainDrop = require 'raindrop'
local RainColumn = Class('RainColumn')
@<raincolumnconstructor@>
@<raincolumninitdrops@>
@<raincolumnresetdrops@>
@<raincolumnupdate@>
@<raincolumndraw@>
@<raincolumninframe@>
return RainColumn
RainColumn Constructor
- The constructor takes a
config
table which contains initialization parameters for the column of rain drops.- The location of the column on the x-axis is given by
config.x
. - The size of column is given by (
config.w
,config.h
). - The vertical velocity of the column is given by
config.vy
. The horizontal velocity is 0. - The number of drops in the column are given by
config.numRows
.
- The location of the column on the x-axis is given by
- After initializing the state variables, the constructor calls the utility method
initDrops
to initialize theRainDrop
instances.
id: raincolumnconstructor
function RainColumn:initialize(config)
self.x = config.x
self.w = config.w
self.h = config.h
self.vy = config.vy
self.numRows = config.numRows
self.rowHeight = self.h/self.numRows
self:initDrops()
end
RainColumn Initialize Drops
- This method creates an array of
RainDrop
instances of the same size. - The size of each
RainDrop
is equal to (self.w
,self.rowHeight
). WhererowHeight
isself.h/self.numRows
. - The drops are placed one after the other in a vertical line and each of them is given the velocity (
0
,self.vy
) thus making sure they all move at the same speed in tandem. - The bottom-most drop is given a colour white.
id: raincolumninitdrops
function RainColumn:initDrops()
self.numDrops = math.random(1, 2 * self.numRows)
local colHeight = self.numDrops * self.rowHeight
self.drops = {}
for i = 1, self.numDrops do
local dropConfig = {
x = self.x,
y = colHeight/2 - ((i - 1) * self.rowHeight),
w = self.w,
h = self.rowHeight,
vx = 0,
vy = self.vy
}
if i == 1 then
dropConfig.color = {0, 0, 1}
end
table.insert(self.drops,
RainDrop(dropConfig))
end
end
RainColumn Reset Drops
- This method iterates over all the drops in the array
self.drops
and resets their position by callingdrop:resetPosition
.
id: raincolumnresetdrops
function RainColumn:resetDrops()
for _, drop in ipairs(self.drops) do
drop:resetPosition()
end
end
RainColumn In Frame?
- This method checks if a sentinel drop in the column is past the frame by calling
inFrame
method on the drop. - Currently the sentinel is one-third of the way down the column.
id: raincolumninframe
function RainColumn:inFrame()
local outIndex = math.floor(self.numDrops/3)
if outIndex < 1 then
outIndex = 1
end
return self.drops[outIndex]:inFrame(love.graphics.getDimensions())
end
RainColumn Update
- The
update(dt)
method first checks if the column isinFrame
. If it is not in frame then it resets the drops, thereby resetting the column and reusing it for another run through the canvas. - After the check the method iterates over each drop and calls
update(dt)
on each of them individually.
id: raincolumnupdate
function RainColumn:update(dt)
if not self:inFrame() then
self:resetDrops()
end
for i, drop in ipairs(self.drops) do
drop:update(dt)
end
end
RainColumn Draw
- This method iterates over each drop and calls
draw()
for each of them.
id: raincolumndraw
function RainColumn:draw()
for i, drop in ipairs(self.drops) do
drop:draw()
end
end
RainSheet Class
- The
RainSheet
class as we discussed in the design represents the overall simulaton. - The
RainSheet
contains an array ofRainColumn
instances moving at different speeds. This is what creates the illusion of matrix rain. - Since the bulk of update and drawing takes place in the sub-ordinate classes
RainColumn
andRainDrop
, this class is fairly simple. The bulk of the logic of the class is in setting up the initial parameters and starting up the simulation. - The constructor sets up the simulation parametrs and creates an array of columns to fit the width of the canvas.
file: rainsheet.lua
local Class = require 'middleclass'
local RainColumn = require 'raincolumn'
local RainSheet = Class('RainSheet')
@<rainsheetconstructor@>
@<rainsheetupdate@>
@<rainsheetdraw@>
return RainSheet
RainSheet Constructor
- The
RainSheet
constructor accepts aconfig
table with its initialization parameters. This helps the class setup the simulation.- The number of columns is given by
config.numCols
. - The maximum number of rows possible in a rain column is given by
config.numRows
. - The maximum possible vertical velocity of a rain column is given by
config.maxVy
. - The size of the canvas is given by (
config.cw
,config.ch
).
- The number of columns is given by
- Once all the state is initialized, the array of columns are created with their postion one after the other on the x-axis. Each column is given a random vertical speed.
id: rainsheetconstructor
function RainSheet:initialize(config)
self.numCols = config.numCols
self.numRows = config.numRows
self.maxVy = config.maxVy
self.cw = config.cw
self.ch = config.ch
self.colWidth = self.cw/self.numCols
self.columns = {}
for i = 1, self.numCols do
local column = RainColumn({
x = (i - 1) * self.colWidth,
w = self.colWidth,
h = self.ch,
vy = math.random(self.maxVy/8, self.maxVy),
numRows = self.numRows
})
table.insert(self.columns, column)
end
end
RainSheet Update
- This method simply iterates over each
RainColumn
and calls theupdate(dt)
method on each instance.
id: rainsheetupdate
function RainSheet:update(dt)
for _, column in ipairs(self.columns) do
column:update(dt)
end
end
RainSheet Draw
- This method simply iterates over each
RainColumn
and calls thedraw()
method on each instance.
id: rainsheetdraw
function RainSheet:draw()
for _, column in ipairs(self.columns) do
column:draw()
end
end
Matrix Rain Program
- The main program in a love2d game is the
main.lua
file. - One must define love2d lifecycle functions to implement the simulation.
- In the
love.load()
function we initialize theRainSheet
with some configuration. - In
love.update(dt)
we update theRainSheet
instance stored insheet
and inlove.draw
we draw thesheet
. - There are a couple of shortcuts implemented:
ESC
to quit the programCtrl+f
to toggle the FPS of the simulation on the bottom right. By default the FPS display is turned off.
Module Imports
- The only import needed is the
RainSheet
class in the rainsheet.lua file.
id: requiredeps
local RainSheet = require 'rainsheet'
File Globals
- There are three sets of file global variables:
- (
cw
,ch
) store the size of the canvas. They are initialized inlove.load()
. fpsOn
is a boolean which indicates if the FPS of the simulation is to be shown.sheet
represents the instance of theRainSheet
class created in thelove.load()
function.
- (
id: fileglobals
local cw, ch
local fpsOn
local sheet
Initialization
- The bulk of the code in this file is in the
love.load()
function where we setup the initialization parameters for the simulation. - Based on a fixed number of rows and the given canvas size (as decided in the
conf.lua
) we set up the number of columns and a maximum rain speed. - We then create a single instance of the
RainSheet
class with these parameters and store it in thesheet
file-global variable.
id: loveload
function love.load()
cw, ch = love.graphics.getDimensions()
fpsOn = false
local numRows = 40
local numCols = cw / (ch/numRows)
local maxRainSpeed = (ch/numRows) * 20
-- create a font and set it as the active font
-- with the default face, but size is equal to cw/numCols
local font = love.graphics.newFont(cw/numCols)
love.graphics.setFont(font)
sheet = RainSheet({
numRows = numRows,
numCols = numCols,
maxVy = maxRainSpeed,
cw = cw,
ch = ch
})
end
Update the Simulation
- This function calls
sheet:update(dt)
.
id: loveupdate
function love.update(dt)
sheet:update(dt)
end
Draw the Simulation
- This method calls
sheet:draw()
. - It also checks the
fpsOn
variable to see if the FPS is to be displayed on the screen.
id: lovedraw
function love.draw()
sheet:draw()
-- draw fps
if fpsOn then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.print("FPS: "..tostring(love.timer.getFPS()), cw - 100, ch - 25)
end
end
Handle Keyboard Events
- The
love.keypressed(key)
lifecycle function is implemented to provide two shortcuts. - If the user presses
ESC
key then the application quits. - If the user presses
Ctrl+f
then the FPS display is toggled.
id: lovekeypressed
-- escape to exit
function love.keypressed(key)
if key == "escape" then
love.event.quit()
end
-- check for modifiers
CTRL_KEY = ""
SHIFT_KEY = ""
ALT_KEY = ""
if love.keyboard.isDown("lctrl") or love.keyboard.isDown("rctrl") then
CTRL_KEY = "CTRL"
end
if love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") then
SHIFT_KEY = "SHIFT"
end
if love.keyboard.isDown("lalt") or love.keyboard.isDown("ralt") then
ALT_KEY = "ALT"
end
if CTRL_KEY and key == "f" then
fpsOn = not fpsOn
end
end
file: main.lua
--- main.lua: Matrix Rain Simulation in LÖVE
-- date: 16/05/2024
-- author: Abhishek Mishra
@<requiredeps@>
@<fileglobals@>
@<loveload@>
@<loveupdate@>
@<lovedraw@>
@<lovekeypressed@>
Simulation Configuration
- In the
conf.lua
file we define some initialization parameters for the love2d simulation like size of the canvas, title of the window and we turn off some unused modules to make the simulation faster.
file: conf.lua
--- conf.lua: Config for the love2d game.
--
-- date: 4/3/2024
-- author: Abhishek Mishra
-- canvas size
local canvasWidth = 1024
local canvasHeight = 768
function love.conf(t)
-- set the window title
t.window.title = "Matrix Rain"
-- set the window size
t.window.width = canvasWidth
t.window.height = canvasHeight
-- disable unused modules for performance
t.modules.joystick = false
t.modules.physics = false
t.modules.touch = false
-- enable console
-- TODO: turning on console crashes Love2D on Windows,
-- so it's disabled for now
-- t.console = true
end