4C Blogging Widget
Open source widget for https://4c.rocks
Posted on September 19, 2021
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.
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?
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.
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:
Here's a reminder of the data model from earlier in the series.
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.
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.
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
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() : {}
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
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
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)
}
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)
}
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
}
Then we keep just the last 20 events
data.eventTimes = times.slice(-20)
}
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
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()
}
Finally we end the transaction and store the updated score.
await scoreRef.set(data)
}
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
}
)
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."
)
}
Firstly it ensures you can't award more than 50 points:
points = Math.min(points, 50)
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)
}
}
)
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.
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
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")
}
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)
}
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
Next we increment some reporting counters
data.responseCount = (data.responseCount || 0) + 1
await db
.collection("counts")
.doc(articleId)
.set({ responseCount: data.responseCount }, { merge: true })
And finally we complete the transaction and store the data back in the response.
await responseRef.set(data)
return null
}
)
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 })
})
We just update a field in the scores with the time we showed the user their achievements.
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!
Posted on September 19, 2021
Sign up to receive the latest update from our blog.