[Tutorial] How to make an accurate speedrun timer!

Introduction

When making a game, most devs don’t care about speedrunners, which is kinda annoying sometimes.

If your game is a platformer, an obby or just a story game, having a timer for speedruns is really good since it allows for way more accurate timing.

An example of good usage of in-game speedrun timer is in Robot 64 and Try To Die, making it convenient for speedrunners to run those games.

So, today i am going to teach how to make an accurate speedrun timer.


Tutorial

To start off we are going to make a module and place it in ReplicatedStorage.
image

Now we can start writing the script.
The first step is making some variables that will make the timer work:
image


Variable explanations:

  • RunService: Uh, it’s roblox’s run service, what did you expect?
  • StartTime: When the timer was started
  • PausedTime: When the timer was last paused
  • Accumulated: The time accumulated from all pauses
  • Running: Is the timer running?
  • Paused: Is the timer paused?
  • Signal: The preferred signal used to update the timer label
  • Connection: The connection made for updating the timer
  • Label: The label used to show the timer

Now that we have our variables, we can make the formatting function.
We are going to start by calculating the minutes, seconds and milliseconds:
image

And then, we can put them together in a string:


How string.format works here

Basically, it will replace the patterns with the numbers passed
The first %02i will be replaced by the minutes with 2 or more digits. Examples: 01, 04, 20, 76, 127.
The second %02i will be replaced by the seconds with 2 or more digits. Examples: 05, 09, 60.
The %03i will be replaced by the milliseconds with 3 or more digits. Examples: 001, 052, 934.


Now we can make the updating function.
First we check if the timer is running and if there is a label to update, if not then the function is not executed.
image
(I didn’t add a warn saying “Timer not running.” because this function will be executed a lot)

Now we will get the time passed since the timer started and set the label’s text to the formatted version of it.
Everything we need are in the variables, so we what we do is:
image

Now that all the local functions are done, we can start making the module functions.
The first module function we are going to make is the SetLabel function which allows to change the label from any client script.

This function is pretty simple, all it does is this:
image

Now, lets make the Start timer function.
First, we need to check if the timer is already running, we only want this function to be executed if the timer is not running.
image

Now we are going to setup the variables
image

And that’s everything for the Start function, now lets make the Pause function.
This function will only run if the timer is running, so let’s write that
image

After that, we have check if the timer is already paused or not so we can change the variables accordingly
image


How the “if else” of the Pause function works

It is divided by two parts, one that is executed if the timer is paused and another that is executed if the timer is not paused.

If the timer is paused, we check if the PausedTime variable exists, if it does we can add the time passed since that variable was set to the Accumulated variable, which will be subtracted from the total time since the timer started when updating the label. Then, set the Connection variable to a new signal connection so the label updates.

If the timer is not paused, we set the PausedTime to the current os.clock() and then check if a signal connection exists, if it does then we disconnect it and set the Connection variable to nil.


Now the last thing for the Pause function to be done is toggling the Paused variable.
image

Our last module function is the Stop function, which will be stopping the timer.
Again, we will be check if the timer is running, we can’t stop it if it’s not running
image

Now we just need to set all the variables to default:
image

And with that, the module is done!


Results

Full module code

--!nocheck
local RunService = game:GetService("RunService")

local StartTime: number?
local PausedTime: number?
local Accumulated: number = 0

local Running: boolean = false
local Paused: boolean = false

local Signal: RBXScriptSignal = RunService.Heartbeat
local Connection: RBXScriptConnection?

local Label: TextLabel?

local Timer = {}

local function Format(Time: number): string
	local Minutes = Time / 60 % 60
	local Seconds = Time % 60
	local Milliseconds = Seconds * 1000 % 1000
	
	return string.format("%02i:%02i.%03i", Minutes, Seconds, Milliseconds)
end

local function Update()
	if not Running or not Label then
		return
	end
	
	Label.Text = Format(os.clock() - StartTime - Accumulated)
end

function Timer:SetLabel(NewLabel: TextLabel)
	Label = NewLabel
end

function Timer:Start()
	if Running then
		warn("Timer is already running.")
		return
	end
	
	StartTime = os.clock()
	PausedTime = nil
	Accumulated = 0
	Connection = Signal:Connect(Update)
	Running = true
	Paused = false
end

function Timer:Pause()
	if not Running then
		warn("Timer is not running.")
		return
	end
	
	if Paused then
		if PausedTime then
			Accumulated += os.clock() - PausedTime
			PausedTime = nil
		end
		
		Connection = Signal:Connect(Update)
	else
		PausedTime = os.clock()
		
		if Connection then
			Connection:Disconnect()
			Connection = nil
		end
	end
	
	Paused = not Paused
end

function Timer:Stop()
	if not Running then
		warn("Timer not running.")
		return
	end
	
	Running = false
	Paused = false
	
	StartTime = nil
	PausedTime = nil
	Accumulated = 0
	
	if Connection then
		Connection:Disconnect()
		Connection = nil
	end
end

return Timer

The showcase was recorded using this code:

Showcase


Thank you for reading! - Pedro

6 Likes

Loved that you’d made it modular, makes it easy to expand on and use. Though If you really need a more precise use case, then I’d do os.clock (iirc, tick is deprecated?)

3 Likes

Hey, thanks for your reply!
I didn’t know tick was deprecated, I’ve changed the post to use os.clock instead.

3 Likes