Making a Speedrun Timer: Chapter 1
Kev the Dev
Posted on March 30, 2023
Making the Timer ⏱️
In the previous post, we explained what we're making (a speedrun timer), why we're making it, and how to setup the project. Now we're ready to get to the good stuff! To start, we'll want to get the basic core functionality of our app working.
The Stopwatch Component 🏗️
A "speedrun timer" is essentially just a stopwatch with speedrun-related functionality built on top of it, so we can begin by creating a simple stopwatch component with a composable:
// Stopwatch.vue
<script setup>
import { useStopwatch } from '../composables/stopwatch';
const {
timerTxt,
onTimerStart,
onTimerStop,
onTimerReset
} = useStopwatch();
</script>
<template>
<div>
<p>{{ timerTxt }}</p>
<button @mousedown="onTimerStart">Start</button>
<button @mousedown="onTimerStop">Stop</button>
<button @mousedown="onTimerReset">Reset</button>
</div>
</template>
<style scoped>
button {
margin: 0 5px;
}
</style>
As you can see, there's nothing fancy here, just 3 buttons with event functions and some text to display our timer.
We'll use it directly in App.vue
for now:
// App.vue
<script setup>
import Stopwatch from './components/Stopwatch.vue';
</script>
<template>
<div>
<stopwatch></stopwatch>
</div>
</template>
The Stopwatch Composable 📝
Now let's build our stopwatch composable. We can begin by declaring some necessary variables. I've commented on their uses in the code below:
let [milliseconds, seconds, minutes, hours] = [0, 0, 0, 0]; // state of the values used for the stopwatch display
let timer; // keeps track of an active timer
let paused = false; // keeps track of paused state
let prevTime; // the last time the timer was updated
const timerTxt = ref(getTimeFormatString()); // the timer display text value
onTimerStart() 🏃
Next, we'll want to create our onTimerStart
function that will be triggered on a start button press (we'll likely move this to a keypress later, but we'll keep the buttons for now):
function onTimerStart() {
if (timer) {
return;
}
paused = false;
prevTime = new Date();
timer = setInterval(incrementTimer, 10);
}
Breaking this down, first we'll want to detect if the timer is already running. If it is, we can short circuit the function and do nothing. Next we ensure our timer isn't in a paused state, update the time our timer was last ran, and start the timer by calling setInterval
and telling it to run our function incrementTimer
every 10 milliseconds.
Non-Event Functions 🤓
Speaking of the incrementTimer
function, let's go ahead and define that function and the other functions it will reference:
function incrementTimer() {
if (paused) {
return;
}
const elapsedMilliseconds = ((new Date()) - prevTime);
setTimeValues(elapsedMilliseconds);
timerTxt.value = getTimeFormatString();
prevTime = new Date();
}
function setTimeValues(elapsedMilliseconds) {
milliseconds += elapsedMilliseconds;
if (milliseconds >= 1000) {
seconds += (milliseconds - (milliseconds % 1000)) / 1000;
milliseconds = milliseconds % 1000;
if (seconds >= 60) {
minutes += (seconds - (seconds % 60)) / 60;
seconds = seconds % 60;
if (minutes >= 60) {
hours += (minutes - (minutes % 60)) / 60;
minutes = minutes % 60;
}
}
}
}
function getTimeFormatString() {
const h = hours.toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false
});
const min = minutes.toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false
});
const s = seconds.toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false
});
const ms = milliseconds.toLocaleString('en-US', {
minimumIntegerDigits: 3,
useGrouping: false
});
return `${h}:${min}:${s}.${ms}`;
}
We'll examine these functions and their usage below:
incrementTimer() ➕
This code was designed to be called at any given moment and increment the timer display based on how much time has elapsed from the previous call to the current one. We can't guarantee that incrementTimer
will be called every 10 milliseconds as setInterval
merely places incrementTimer
into a queue after 10 seconds. The actual execution time of the function can easily vary, making it very apparent why we should measure the time between one call to the next instead!
setTimeValues(elapsedMilliseconds) ⌚
This function is designed to take in any amount of elapsed time and increment the time values appropriately. We could have assumed a consistent time frame between each execution (e.g. 1 second/1000 milliseconds), but that would not have been a reliable solution given the single-threaded nature of JavaScript and the potential for the thread to hang, sleep, or freeze. Therefore, we need our timer to pick up where it left off. To do this, I needed the algorithm to account for any amount of time passed (even if it took 5 minutes from one call to the next) and take in that parameter and increment it onto the timer values. Which is why you see code like this: seconds += (milliseconds - (milliseconds % 1000)) / 1000;
and milliseconds = milliseconds % 1000;
For example, if our function was called 5001 milliseconds from the last call, then we know we should be adding 5 seconds and 1 millisecond to our time values:
seconds += (5001 - (5001 % 1000)) / 1000; //This equals 5
milliseconds = 5001 % 1000; //This equals 1
getTimeFormatString() 📝
This code just formats the timer values into the time display format that we want.
onTimerStop() 🛑
Moving on to the onTimerStop
function, things get less complicated. We need to set paused = true;
, stop the timer, and finally clear the timer.
function onTimerStop() {
paused = true;
clearInterval(timer);
timer = null;
}
onTimerReset() 🔃
Last but not least, we'll want to reset our timer. This code is very similar to onTimerStop
, but we also want to reset our timer values to their default values after stopping and clearing the timer.
function onTimerReset() {
paused = true;
clearInterval(timer);
[milliseconds, seconds, minutes, hours] = [0, 0, 0, 0];
paused = false;
timerTxt.value = getTimeFormatString();
timer = null;
}
Full Composable 💯
That'll be everything we need for now so here's the full composable code:
// stopwatch.js
import { ref } from "vue";
export function useStopwatch() {
let [milliseconds, seconds, minutes, hours] = [0, 0, 0, 0];
let timer;
let paused = false;
let prevTime;
const timerTxt = ref(getTimeFormatString());
function setTimeValues(elapsedMilliseconds) {
milliseconds += elapsedMilliseconds;
if (milliseconds >= 1000) {
seconds += (milliseconds - (milliseconds % 1000)) / 1000;
milliseconds = milliseconds % 1000;
if (seconds >= 60) {
minutes += (seconds - (seconds % 60)) / 60;
seconds = seconds % 60;
if (minutes >= 60) {
hours += (minutes - (minutes % 60)) / 60;
minutes = minutes % 60;
}
}
}
}
function getTimeFormatString() {
const h = hours.toLocaleString("en-US", {
minimumIntegerDigits: 2,
useGrouping: false,
});
const min = minutes.toLocaleString("en-US", {
minimumIntegerDigits: 2,
useGrouping: false,
});
const s = seconds.toLocaleString("en-US", {
minimumIntegerDigits: 2,
useGrouping: false,
});
const ms = milliseconds.toLocaleString("en-US", {
minimumIntegerDigits: 3,
useGrouping: false,
});
return `${h}:${min}:${s}.${ms}`;
}
function incrementTimer() {
if (paused) {
return;
}
const elapsedMilliseconds = new Date() - prevTime;
setTimeValues(elapsedMilliseconds);
timerTxt.value = getTimeFormatString();
prevTime = new Date();
}
function onTimerStart() {
if (timer) {
return;
}
paused = false;
prevTime = new Date();
timer = setInterval(incrementTimer, 10);
}
function onTimerStop() {
paused = true;
clearInterval(timer);
timer = null;
}
function onTimerReset() {
paused = true;
clearInterval(timer);
[milliseconds, seconds, minutes, hours] = [0, 0, 0, 0];
paused = false;
timerTxt.value = getTimeFormatString();
timer = null;
}
return {
timerTxt,
onTimerStart,
onTimerStop,
onTimerReset,
};
}
Seeing the Code in Action! 🎬
Now to run our code and see it in action! I've put the code for Chapter 1 inside of a CodeSandbox so we can visualize what our code looks like while it's running:
Conclusion 💭
Ta-da! We now have a basic stopwatch timer working. In Chapter 2, we'll enhance the timer a bit and abstract the logic to make the code more portable.
Here's the GitHub repo for this project. If you haven't already, be sure to give me a follow on here and other socials to see updates to this series and more!
Posted on March 30, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.