Making A Simple Serverless Gamification API With Firebase Functions

miketalbot

Mike Talbot ⭐

Posted on September 19, 2021

Making A Simple Serverless Gamification API With Firebase Functions

TLDR;

I'm building a widget to help content authors grow their audience by asking them questions! Using quizzes and polls (for now) that are embedded in a post an author can reinforce learning through exercises and quizzes or sample opinion in a poll.

To make the widget more fun it has the basics of a gamification system with achievements and points so that the reader feels a reward for their interactions. This article discusses the API calls and method for doing this.

The Widget

You can try out the interactive widget below, you should get some badges and points for taking the quiz. It's just a bit of trivia fun this time - can you get 5/5?

Enter below!

Gamification

The idea of gamification is to reward people for performing actions that you would like them to do. Rewards could be anything from virtual points and badges to real world prizes (though you need some pretty good security for that - I know, I've done it!)

In our simple widget we are just going to give people points and award badges for various actions that they complete. Some badges can be awarded more than once (for example: "Read New Article") while others can only be earned once (for example: "Voted in 'XYZ' Poll" or "Got a quiz question correct").

Gamification is a way of saying thanks for interacting, and it can be very powerful user engagement tool as a part of a network of interactions, or just a little fun like here.

Both readers and content authors receive points and badges to keep everyone in the mix.

Requirements

  • Award points for actions
  • Award achievements for actions when awarding points
  • Award unique achievements and points
  • Acknowledge that we've told the user about their new achievements

This last is so we can display a "Snackbar" to show the achievement, and only show it once.

We also need to deal with the API for storing unique user responses:

  • Store a unique response per user, if they respond again (like retaking a quiz, replace it)

Data Model

Here's a reminder of the data model from earlier in the series.

Data Model

Implementation

I've chosen to implement the API as a Google Firebase Function API. I am going to be reimplementing it as a Cloud Run version as I'm told that this will cost less, that'll come at the end of the series.

The Code

(internal) awardPoints

Let's start with a generic function to award points. This function has to try to stop someone cheating and writing a bot to keep submitting scores. It's not trying very hard to be honest! If you really wanted secure scores, you'd require at least a user login rather than an anonymous user, but for this use case I don't think many people would bother just for a bit of fun, so we will have to provide some kind of rudimentary cheat detection.

Score Points

We will call awardPoints from other functions and provide an API for it in a moment.

First the signature has us pass a user id, a number of points, an optional achievement and a function that can award bonus achievements and points (for example if this is the first time something happened)

async function awardPoints(
    userUid,
    points = 1,
    achievement,
    bonus = () => [0]
) {
    if (!userUid) return
Enter fullscreen mode Exit fullscreen mode

Next we make sure you can't take points away, then we get a reference to the user's scores

    points = Math.max(0, points)
    const scoreRef = db.collection("scores").doc(userUid)
    const snap = await scoreRef.get()
    const data = snap.exists ? snap.data() : {}
Enter fullscreen mode Exit fullscreen mode

To prevent cheating we are going to be able to set a cool off date, if this is set and it's after now then we don't do any more:

    if ((data.coolOff || Date.now()) > Date.now()) return
Enter fullscreen mode Exit fullscreen mode

Next to help with cheat prevention, we keep a record of the times that scoring events occurred, and we use an average of the last events to decide if we will allow this score to proceed:

    const times = (data.eventTimes = data.eventTimes || [])
    times.push(Date.now())
    if (times.length > 10) {
        let total = 0
        for (let i = 1; i < times.length; i++) {
            total += times[i] - times[i - 1]
        }
        const average = total / times.length
Enter fullscreen mode Exit fullscreen mode

Having calculated the average time of the last few calls we first make a decision about them happening frequently, and if they do, we increment an error count and use it to decide on a cool off period:

        if (average < 5000) {
            data.errorCount = (data.errorCount || 0) + 1
            if (data.errorCount > 20) {
                data.coolOff = Date.now() + 1000 * 60 * 60
            }
        } else {
            // Reduce errors if no problem
            data.errorCount = Math.max(0, (data.errorCount || 0) - 1)
        }
Enter fullscreen mode Exit fullscreen mode

Next if we are going really fast - we set a five minute cool off.

        if (average < 500) {
            data.coolOff = Math.max(data.coolOff, Date.now() + 1000 * 60 * 5)
        }
Enter fullscreen mode Exit fullscreen mode

If we have an average over the last 10-20 scores of less than a second, we don't allow this score

        if (average < 1000) {
            return
        }
Enter fullscreen mode Exit fullscreen mode

Then we keep just the last 20 events

        data.eventTimes = times.slice(-20)
    }
Enter fullscreen mode Exit fullscreen mode

Next up we configure for achievements and increment the score, calling our bonus function to see if there is an additional score or achievement:

    data.achievements = data.achievements || {}
    const [extra = 0, extraAchievement] = bonus(data, points, achievement) || []
    data.score = (data.score || 0) + points + extra
Enter fullscreen mode Exit fullscreen mode

Now if we have an achievement, we store the date on which it was earned

    if (achievement) {
        data.achievements[achievement] = Date.now()
        await incrementTag(`__event_${achievement}`, "count")
    }
    if (extraAchievement) {
        data.achievements[extraAchievement] = Date.now()
    }
Enter fullscreen mode Exit fullscreen mode

Finally we end the transaction and store the updated score.

    await scoreRef.set(data)
}

Enter fullscreen mode Exit fullscreen mode

awardPoints

The api version of award points is provided for plugin writers so that they can award what extra scores.

It follows the rules of AppCheck and also ensures you can't award more than 20 points:


exports.awardPoints = functions.https.onCall(
    async ({ points = 1, achievement, articleId }, context) => {
        points = Math.max(0, Math.min(points, 20))
        if (context.app === undefined) {
            throw new functions.https.HttpsError(
                "failed-precondition",
                "The function must be called from an App Check verified app."
            )
        }
        if (!context.auth.uid) return

        await awardPoints(context.auth.uid, points, achievement)

        return null
    }
)
Enter fullscreen mode Exit fullscreen mode

addAchievement

This API function allows a plugin developer to add an achievement for the current user. It takes the article id for reporting purposes, some points to award if the achievement is new, and the name of the achievement.

exports.addAchievement = functions.https.onCall(
    async ({ points = 10, achievement, articleId }, context) => {
        if (context.app === undefined) {
            throw new functions.https.HttpsError(
                "failed-precondition",
                "The function must be called from an App Check verified app."
            )
        }
Enter fullscreen mode Exit fullscreen mode

Firstly it ensures you can't award more than 50 points:

        points = Math.min(points, 50)
Enter fullscreen mode Exit fullscreen mode

The it gets the score reference and checks if the achievement already exists, if it doesn't it adds it.

        if (!achievement) return
        const userUid = context.auth.uid
        const scoreRef = db.collection("scores").doc(userUid)
        const snap = await scoreRef.get()
        const data = snap.exists ? snap.data() : {}
        data.achievements = data.achievements || {}
        if (!data.achievements[achievement]) {
            await awardPoints(userUid, points)
            data.achievements[achievement] = Date.now()
            await scoreRef.set(data)
        }
    }
)
Enter fullscreen mode Exit fullscreen mode

respondUnique

Finally we want to be able to decorate the response table with the unique information for the current user, this is how we store poll and quiz results. Each user has their own section of the "response" that contains the data they supplied.

Response Diagram

The function takes an articleId, a type supplied by the developer and an object or value to store called response.


exports.respondUnique = functions.https.onCall(
    async ({ articleId, type = "general", response }, context) => {
        if (context.app === undefined) {
            throw new functions.https.HttpsError(
                "failed-precondition",
                "The function must be called from an App Check verified app."
            )
        }
        if (!context.auth.uid) return null
Enter fullscreen mode Exit fullscreen mode

If there is a response then we award points to both the article author and the user.

        const article =
            (await db.collection("articles").doc(articleId).get()).data() || {}
        if (response) {
            await awardPoints(context.auth.uid, 100, "Interacted With Article")
            await awardPoints(article.author, 20, "Gained an interaction")
        }
Enter fullscreen mode Exit fullscreen mode

Next we get a record from the "responses" collection and record the type of response we are making in it.

        const responseRef = db.collection("responses").doc(articleId)
        const doc = await responseRef.get()
        const data = doc.exists ? doc.data() : {}
        data.types = data.types || []
        if (!data.types.includes(type)) {
            data.types.push(type)
        }
Enter fullscreen mode Exit fullscreen mode

Next we get the block of data that represents this type of response (for instance the Quiz uses "Quiz" for a type). This block will then contain one response for each unique user.

        const responseCollections = (data.responses = data.responses || {})
        const responses = (responseCollections[type] =
            responseCollections[type] || {})
        responses[context.auth.uid] = response
Enter fullscreen mode Exit fullscreen mode

Next we increment some reporting counters

        data.responseCount = (data.responseCount || 0) + 1
        await db
            .collection("counts")
            .doc(articleId)
            .set({ responseCount: data.responseCount }, { merge: true })
Enter fullscreen mode Exit fullscreen mode

And finally we complete the transaction and store the data back in the response.

        await responseRef.set(data)
        return null
    }
)

Enter fullscreen mode Exit fullscreen mode

acknowledge

We need to record the last date on which we told a user about their achievements, so we don't keep repeating ourselves:


exports.acknowledge = functions.https.onCall(async ({ time }, context) => {
    if (context.app === undefined) {
        throw new functions.https.HttpsError(
            "failed-precondition",
            "The function must be called from an App Check verified app."
        )
    }
    const scoreRef = db.collection("scores").doc(context.auth.uid)
    scoreRef.set({ acknowledged: time }, { merge: true })
})

Enter fullscreen mode Exit fullscreen mode

We just update a field in the scores with the time we showed the user their achievements.

Conclusion

In this article we've looked at an API for gamification and response tracking using serverless functions. In future instalments we'll see how to use these functions to make the front end of the Widget and the plugins like Quiz and Poll.

Thanks for reading!






💖 💪 🙅 🚩
miketalbot
Mike Talbot ⭐

Posted on September 19, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related