7.1.1 Digital Stopwatch Tutorial

Round timers, powerup timers, cooldown timers, countdown timers, reminders. All sorts of timers are used in game development.

And while there are many ways to implement a timer, one of the simplest that does not rely on any additional requirements is a stopwatch. It just needs a start, stop, and reset function and a variable to track the total elapsed time. Then each frame, the delta time since the last update is added to that time.

We can take this farther by outputting the time to a display that shows the time in minutes, seconds, and milliseconds in an array of 7 segment displays.


But first, let’s talk about 7 segment displays.

From the hardware world, a 7 segment display is an electrical component consisting of seven LED elements arranged so that when certain combinations of those LEDs are turned on, they resemble a number. Most displays also contain an eighth LED to represent the decimal point.

We can simulate such a display in Roblox.

There is not an LED object and lights in Roblox don’t work the way they do in real life, but we can simulate an LED turning on by changing a part to a bright color and its material to neon to give the illusion that it is glowing. To turn it off, set it to plastic and a dark color.

By mapping the numbers 0-9 to some combination of on/off elements, we can create a function that loops through and sets or unsets each segment.

Then if we arrange 6 of these displays side by side, the physical stopwatch begins to take shape.


At the module level, the stopwatch requires a start, stop, and reset function which can be invoked from a ClickDetector or a GUI or some other source of our choosing.

A local update function will be connected to the Heartbeat event and run every frame while the timer is running.

For timekeeping, the module just needs to keep track of one variable: the counter for the cumulative time in seconds. The deltatime passed to the update function is added to this value to continuously increment the time.

Minutes can be calculated from that by dividing it by 60 and taking the floor of that value.

Taking the modulo 60 of the running time value returns the number of seconds adjusted to the minute time.

Lua
print(65 / 60) --1.083
print(65 % 60) --5

In the update function, the display will also be updated with the new time.

The Stop function will disconnect the update function from the Heartbeat event only but it does not reset the time counter.

If the Reset function is called, it invokes the Stop function and also resets the timer and display to 0.


First, let’s build the display. Grab this model and place it into the workspace.

Duplicate five more and place them side by side. Then rename each model D1, D2, D3, etc. to correspond with their position on the display, from milliseconds to minutes.

Group them together into another model and rename it ‘Display.’


For the script, we’ll start with the display controller.

This module will keep track of all the digits and be responsible for setting the segment patterns for each number.

In order to do that, there needs to be a table that maps each number to a pattern.

Lua
local model = game.Workspace.Display

local digits = {}
local numberMapping = {}

numberMapping[0] = {['A'] = true, ['B'] = true, ['C'] = true, ['D'] = true, ['E'] = true, ['F'] = true, ['G'] = false}
numberMapping[1] = {['A'] = false, ['B'] = true, ['C'] = true, ['D'] = false, ['E'] = false, ['F'] = false, ['G'] = false}
numberMapping[2] = {['A'] = true, ['B'] = true, ['C'] = false, ['D'] = true, ['E'] = true, ['F'] = false, ['G'] = true}
numberMapping[3] = {['A'] = true, ['B'] = true, ['C'] = true, ['D'] = true, ['E'] = false, ['F'] = false, ['G'] = true}
numberMapping[4] = {['A'] = false, ['B'] = true, ['C'] = true, ['D'] = false, ['E'] = false, ['F'] = true, ['G'] = true}
numberMapping[5] = {['A'] = true, ['B'] = false, ['C'] = true, ['D'] = true, ['E'] = false, ['F'] = true, ['G'] = true}
numberMapping[6] = {['A'] = true, ['B'] = false, ['C'] = true, ['D'] = true, ['E'] = true, ['F'] = true, ['G'] = true}
numberMapping[7] = {['A'] = true, ['B'] = true, ['C'] = true, ['D'] = false, ['E'] = false, ['F'] = false, ['G'] = false}
numberMapping[8] = {['A'] = true, ['B'] = true, ['C'] = true, ['D'] = true, ['E'] = true, ['F'] = true, ['G'] = true}
numberMapping[9] = {['A'] = true, ['B'] = true, ['C'] = true, ['D'] = false, ['E'] = false, ['F'] = true, ['G'] = true}

for _, sevenSegment in pairs(model:GetChildren()) do
	local indexString = string.sub(sevenSegment.Name, #sevenSegment.Name)
	local index = tonumber(indexString)
	digits[index] = {currentNumber = -1}

	for _, part in pairs(sevenSegment:GetChildren()) do
		digits[index][part.Name] = part
	end
end

The indices 0-9 are mapped to their corresponding number pattern. Later on, a loop will run through each display’s elements and turn it “on” if its value is true or “off” if it is false.

Earlier we labeled each display based on its number position (D1, D2, D3, etc.). The for loop extracts the position number from the name and uses that as its index in the ‘digits’ table.

This table will hold a reference to the LED elements. It also tracks the current number being displayed.


Lua
local DisplayController = {}

function DisplayController:setDigit(position, number)
	if not digits[position]
		or digits[position]["currentNumber"] == number
		or not numberMapping[number]
	then
		return
	end

	for led, on in pairs(numberMapping[number]) do
		if on then
			digits[position][led].Material = Enum.Material.Neon
			digits[position][led].BrickColor = BrickColor.new("Really red")

		else
			digits[position][led].Material = Enum.Material.SmoothPlastic
			digits[position][led].Color = Color3.new(0.333333, 0, 0)
		end
	end

	digits[position]['currentNumber'] = number
end

return DisplayController

Here we define the function that will set the number for a display. The digit position and the number to display is required.

Earlier, we defined a variable to track the current number being displayed. If the number is unchanged, there is no reason to set the number again and the function just returns.

If an update is needed, we find the number pattern and then run a loop, setting or unsetting each segment for the specified digit position.


Next up, create the module for the stopwatch.

Lua
local RunService = game:GetService("RunService")

local displayController = require(game.ServerStorage.DisplayController)

local currentTime = 0
local connection = nil

local function updateDisplay(newTimeList)
	if not newTimeList then return end

	for i = 1, #newTimeList do
		displayController:setDigit(#newTimeList + 1 - i, newTimeList[i])
	end
end

local function getDigits(number)
	local digitList = {}
	for i = 2, 1, -1 do
		digitList[i] = number % 10
		number = math.floor(number / 10)
	end
	
	return table.unpack(digitList)
end

The display controller will be called from this module. After the time output has been calculated, the ‘updateDisplay’ function loops through the number list and sets each digit on the display.

‘getDigits’ is a helper function that splits a two-digit number into individual numbers.

This is done by taking the modulo 10 of the number, which outputs just the first digit. Then the number is divided by 10 to shift the second digit to the 1’s position and taking the modulo of that number.


Lua
local function runningUpdate(dt)
	currentTime = currentTime + dt
	
	local ms = math.floor(currentTime * 100) % 100
	local seconds = math.floor(currentTime) % 60
	local minutes = math.floor(currentTime / 60) % 60
	
	local ms1, ms2 = getDigits(ms)
	local s1, s2 = getDigits(seconds)
	local m1, m2 = getDigits(minutes)
	
	local timeList = {m1, m2, s1, s2, ms1, ms2}
	updateDisplay(timeList)
end

This is the update function that will be connected to the Heartbeat event. Every frame, the time is incremented with by deltatime.

For time calculations, multiplying the current time by 100 shifts the milliseconds to the 1’s and 10’s place. Then taking modulo of 100 returns just the two digit millisecond.

For the seconds time, we just take the modulo of 60.

And dividing the time by 60 returns the number of minutes. Taking the modulo once again limits this to 60 minutes if you should so wish to implement a counter that can display hours.


Lua
local Stopwatch = {}

function Stopwatch:start()
	if connection then return end
	
	connection = RunService.Heartbeat:Connect(runningUpdate)
end

function Stopwatch:stop()
	if not connection then return end
	
	connection:Disconnect()
	connection = nil
end

function Stopwatch:reset()
	Stopwatch:stop()
	
	currentTime = 0
	updateDisplay({0, 0, 0, 0, 0, 0})
end

Stopwatch:reset()

return Stopwatch

Only the start, stop, and reset functions need to be exposed. In the module, the start function connects to the event and stop disconnects the event if it is currently running.

The reset function calls the stop function and invokes the ‘updateDisplay’ method with a table of zeros to clear the display.


Finally, in the main script, I have a series of Parts+ClickDetectors that invoke the corresponding stopwatch function.

Lua
local stopWatchModule = require(game.ServerStorage.Stopwatch)

local startCD = game.Workspace.Start.ClickDetector
local stopCD = game.Workspace.Stop.ClickDetector
local resetCD = game.Workspace.Reset.ClickDetector

startCD.MouseClick:Connect(function()
	stopWatchModule:start()
end)

stopCD.MouseClick:Connect(function()
	stopWatchModule:stop()
end)

resetCD.MouseClick:Connect(function()
	stopWatchModule:reset()
end)

Lua
--/ServerStorage/DisplayController
local model = game.Workspace.Display

local digits = {}
local numberMapping = {}

numberMapping[0] = {['A'] = true, ['B'] = true, ['C'] = true, ['D'] = true, ['E'] = true, ['F'] = true, ['G'] = false}
numberMapping[1] = {['A'] = false, ['B'] = true, ['C'] = true, ['D'] = false, ['E'] = false, ['F'] = false, ['G'] = false}
numberMapping[2] = {['A'] = true, ['B'] = true, ['C'] = false, ['D'] = true, ['E'] = true, ['F'] = false, ['G'] = true}
numberMapping[3] = {['A'] = true, ['B'] = true, ['C'] = true, ['D'] = true, ['E'] = false, ['F'] = false, ['G'] = true}
numberMapping[4] = {['A'] = false, ['B'] = true, ['C'] = true, ['D'] = false, ['E'] = false, ['F'] = true, ['G'] = true}
numberMapping[5] = {['A'] = true, ['B'] = false, ['C'] = true, ['D'] = true, ['E'] = false, ['F'] = true, ['G'] = true}
numberMapping[6] = {['A'] = true, ['B'] = false, ['C'] = true, ['D'] = true, ['E'] = true, ['F'] = true, ['G'] = true}
numberMapping[7] = {['A'] = true, ['B'] = true, ['C'] = true, ['D'] = false, ['E'] = false, ['F'] = false, ['G'] = false}
numberMapping[8] = {['A'] = true, ['B'] = true, ['C'] = true, ['D'] = true, ['E'] = true, ['F'] = true, ['G'] = true}
numberMapping[9] = {['A'] = true, ['B'] = true, ['C'] = true, ['D'] = false, ['E'] = false, ['F'] = true, ['G'] = true}

for _, sevenSegment in pairs(model:GetChildren()) do
	local indexString = string.sub(sevenSegment.Name, #sevenSegment.Name)
	local index = tonumber(indexString)
	digits[index] = {currentNumber = -1}

	for _, part in pairs(sevenSegment:GetChildren()) do
		digits[index][part.Name] = part
	end
end

local DisplayController = {}

function DisplayController:setDigit(position, number)
	if not digits[position]
		or digits[position]["currentNumber"] == number
		or not numberMapping[number]
	then
		return
	end

	for led, on in pairs(numberMapping[number]) do
		if on then
			digits[position][led].Material = Enum.Material.Neon
			digits[position][led].BrickColor = BrickColor.new("Really red")

		else
			digits[position][led].Material = Enum.Material.SmoothPlastic
			digits[position][led].Color = Color3.new(0.333333, 0, 0)
		end
	end

	digits[position]['currentNumber'] = number
end

return DisplayController
Lua
--/ServerStorage/Stopwatch

local RunService = game:GetService("RunService")

local displayController = require(game.ServerStorage.DisplayController)

local currentTime = 0
local connection = nil

local function updateDisplay(newTimeList)
	if not newTimeList then return end

	for i = 1, #newTimeList do
		displayController:setDigit(#newTimeList + 1 - i, newTimeList[i])
	end
end

local function getDigits(number)
	local digitList = {}
	for i = 2, 1, -1 do
		digitList[i] = number % 10
		number = math.floor(number / 10)
	end
	
	return table.unpack(digitList)
end

local function runningUpdate(dt)
	currentTime = currentTime + dt
	
	local ms = math.floor(currentTime * 100) % 100
	local seconds = math.floor(currentTime) % 60
	local minutes = math.floor(currentTime / 60) % 60
	
	local ms1, ms2 = getDigits(ms)
	local s1, s2 = getDigits(seconds)
	local m1, m2 = getDigits(minutes)
	
	local timeList = {m1, m2, s1, s2, ms1, ms2}
	updateDisplay(timeList)
end

local Stopwatch = {}

function Stopwatch:start()
	if connection then return end
	
	connection = RunService.Heartbeat:Connect(runningUpdate)
end

function Stopwatch:stop()
	if not connection then return end
	
	connection:Disconnect()
	connection = nil
end

function Stopwatch:reset()
	Stopwatch:stop()
	
	currentTime = 0
	updateDisplay({0, 0, 0, 0, 0, 0})
end

Stopwatch:reset()

return Stopwatch
Lua
--/ServerScriptService/Script

local stopWatchModule = require(game.ServerStorage.Stopwatch)

local startCD = game.Workspace.Start.ClickDetector
local stopCD = game.Workspace.Stop.ClickDetector
local resetCD = game.Workspace.Reset.ClickDetector

startCD.MouseClick:Connect(function()
	stopWatchModule:start()
end)

stopCD.MouseClick:Connect(function()
	stopWatchModule:stop()
end)

resetCD.MouseClick:Connect(function()
	stopWatchModule:reset()
end)