Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to implement a distributed countdown timer in Firebase

I have an app where one user hosts a game, and then other users can vote on questions from the host. From the moment the host posts the question, the players have 20 seconds to vote.

How can I show a countdown timer on all player's screens and keep them synchronized with the host?

like image 959
Frank van Puffelen Avatar asked Mar 28 '21 16:03

Frank van Puffelen


1 Answers

Many developers get stuck on this problem because they try to synchronize the countdown itself across all users. This is hard to keep in sync, and error prone. There is a much simpler approach however, and I've used this in many projects.

All each client needs to show its countdown timer are three things of fairly static information:

  1. The time that the question was posted, which is when the timer starts.
  2. The amount of time they need to count from that moment.
  3. The relative offset of the client to the central timer.

We're going to use the server time of the database for the first value, the second value is just coming from the hosts code, and the relative offset is a value that Firebase provides for us.

The code samples below are written in JavaScript for the web, but the same approach (and quite similar code) and be applied in iOS, Android and most other Firebase SDKs that implement realtime listeners.


Let's first write the starting time, and interval to the database. Ignoring security rules and validation, this can be as simple as:

const database = firebase.database();
const ref = database.ref("countdown");
ref.set({
  startAt: ServerValue.TIMESTAMP,
  seconds: 20
});

When we execute the above code, it writes the current time to the database, and that this is a 20 second countdown. Since we're writing the time with ServerValue.TIMESTAMP, the database will write the time on the server, so there's no chance if it being affected by the local time (or offset) of the host.


Now let's see how the other user's read this data. As usual with Firebase, we'll use an on() listener, which means our code is actively listening for when the data gets written:

ref.on("value", (snapshot) => {
  ...
});

When this ref.on(... code executes, it immediately reads the current value from the database and runs the callback. But it then also keeps listening for changes to the database, and runs the code again when another write happens.

So let's assume we're getting called with a new data snapshot for a countdown that has just started. How can we show an accurate countdown timer on all screens?

We'll first get the values from the database with:

ref.on("value", (snapshot) => {
  const seconds = snapshot.val().seconds;
  const startAt = snapshot.val().startAt;
  ...
});

We also need to estimate how much time there is between our local client, and the time on the server. The Firebase SDK estimates this time when it first connects to the server, and we can read it from .info/serverTimeOffset in the client:

const serverTimeOffset = 0;
database.ref(".info/serverTimeOffset").on("value", (snapshot) => { serverTimeOffset = snapshot.val() });
ref.on("value", (snapshot) => {
  const seconds = snapshot.val().seconds;
  const startAt = snapshot.val().startAt;
  
});

In a well running system, the serverTimeOffset is a positive value indicating our latency to the server (in milliseconds). But it may also be a negative value, if our local clock has an offset. Either way, we can use this value to show a more accurate countdown timer.

Next up we'll start an interval timer, which gets calls every 100ms or so:

const serverTimeOffset = 0;
database.ref(".info/serverTimeOffset").on("value", (snapshot) => { serverTimeOffset = snapshot.val() });
ref.on("value", (snapshot) => {
  const seconds = snapshot.val().seconds;
  const startAt = snapshot.val().startAt;
  const interval = setInterval(() => {
    ...
  }, 100)
});

Then every timer our interval expires, we're going to calculate the time that is left:

const serverTimeOffset = 0;
database.ref(".info/serverTimeOffset").on("value", (snapshot) => { serverTimeOffset = snapshot.val() });
ref.on("value", (snapshot) => {
  const seconds = snapshot.val().seconds;
  const startAt = snapshot.val().startAt;
  const interval = setInterval(() => {
    const timeLeft = (seconds * 1000) - (Date.now() - startAt - serverTimeOffset);
    ...
  }, 100)
});

And then finally we log the remaining time, in a reasonable format and stop the timer if it has expired:

const serverTimeOffset = 0;
database.ref(".info/serverTimeOffset").on("value", (snapshot) => { serverTimeOffset = snapshot.val() });
ref.on("value", (snapshot) => {
  const seconds = snapshot.val().seconds;
  const startAt = snapshot.val().startAt;
  const interval = setInterval(() => {
    const timeLeft = (seconds * 1000) - (Date.now() - startAt - serverTimeOffset);
    if (timeLeft < 0) {
      clearInterval(interval);
      console.log("0.0 left)";
    }
    else {
      console.log(`${Math.floor(timeLeft/1000)}.${timeLeft % 1000}`);
    }
  }, 100)
});

There's definitely some cleanup left to do in the above code, for example when a new countdown starts while one is still in progress, but the overall approach works well and scales easily to thousands of users.

like image 158
Frank van Puffelen Avatar answered Oct 14 '22 01:10

Frank van Puffelen