Payload is now completely free and open-source.
If you like what we're doing, stop by GitHub and give us a star!
Payload
Blog Post
Can a headless CMS be used as the admin for managing a video game?
Can a headless CMS be used as the admin for managing a video game?

Game developers need a way to manage players, ban users and monetize their work with in-app purchases—is a CMS the right tool?

Each time I get a new computer I convince myself to install Unity, as I dream of building my indie game ideas. Since that will never happen, I decided to take the tools I know and apply it to something I wish I knew. The real inspiration for this post came from a recent conversation with an actual game developer. I wanted to demonstrate how Payload CMS could be used as the Admin tool and API for managing a video game.

With the right CMS built up, we can enable admin users to manage players, games and achievements as well as completing in-app purchases and handle authentication and access control. Since Payload CMS is headless, an online game can call the API to perform all the needed backend functionality.

Overview

All the code written for this guide can be found on Github. It only includes the admin portion of the project. As for now the 'game' is as made-up as that really cool kickstarter project you funded back in 2012.

The features for the admin panel of our fictional game are:

  • Manage player accounts
  • Record games with teams, players and scores
  • Manage and automatically award achievements based on player stats
  • Handle in-app purchases
  • Image uploads for achievements and purchases
  • Access controls for security

First, let's go over the collections at a high level:

Admins - Can log in to the admin UI and do most things
Players - User accounts that can also hold player stats and unlock achievements and make in-app purchases
Games - Contain teams and players along with scores, when saved player stats are updated
Achievements - Admins can manage achievements and set thresholds needed to earn them
In-App Purchases - The things players will be able to purchase
Player Purchases - Who, what and how much it cost
Images - File upload to store images for In-App purchases and Achievements

Payload UI player list

If you haven't developed with Payload CMS before then you'll be surprised by how little effort it takes to add and change fields in your app. Simply providing the config will build the admin UI as well as the APIs and manage the database.

Now the fields for our game collection are made up of an array of teams, with an array of players so that player scores are tracked independently of the team score. For the computer science nerds, that is what we call a two-dimensional array. We'll later use this scoring to assign experience points and achievements.

import { CollectionConfig } from 'payload/types'
import { isAdmin } from '../shared/access/isAdmin'
import { gamesAfterChangeHook } from './gamesAfterChangeHook'
export const games: CollectionConfig = {
slug: 'games',
hooks: {
afterChange: [ gamesAfterChangeHook ],
},
access: {
update: () => false,
delete: () => false,
create: isAdmin,
},
fields: [
{
name: 'teams',
type: 'array',
required: true,
fields: [
{
name: 'score',
type: 'number',
required: true,
},
{
name: 'players',
type: 'array',
fields: [
{
name: 'player',
type: 'relationship',
relationTo: 'players',
required: true,
},
{
name: 'score',
type: 'number',
required: true,
},
],
},
],
},
],
}

You could easily add more interesting stats beyond simple scores for other things happening in game and allow users to unlock more achievements. Adding them here as well as in the achievement collection as new type options would allow admins that ability.

The types that you see in the code are all generated based on the config using Payload's type generation feature. You may also notice in my code there are a few places I like to use Partial<{...}> which allows me to be a little less strict and get work done.

Authentication and banning players

Another feature that will get you going ultra-fast with Payload is authentication. By setting auth: true in the collection, we can take the defaults for how user authentication works for our players.

Postman login API request

Now that we have an authenticated a user, let's see how we can implement banning a player. It should come as no surprise that we have a field on the players to be managed by admins.

{
name: 'banned',
label: 'Ban Player',
type: 'checkbox',
admin: {
position: 'sidebar',
},
},

That gives the admin UI a checkbox in the sidebar to ban the player.

Admin UI ban player

It doesn't do anything yet. To make Payload refuse our banned players on login, we can add the after login hook to keep them out!

import { AfterLoginHook } from 'payload/dist/collections/config/types'
import { APIError } from 'payload/errors'
export const playerLoginHook: AfterLoginHook = async ({ doc }) => {
if (doc.banned) {
throw new APIError('You have been banned, goodbye', 403);
}
}

Payload has an APIError that is useful for returning errors with status codes which we can see in action when we try to login with a banned player.

Postman login request forbidden

API key

Another point about authentication is that in the admins collection, we've turned on useAPIKey. What that allows you to do is generate an API key that your game or any other third party could use to call the API with the full privileges of being an Admin. This is how our fictional game will call the API to store game scores and other actions.

Managing achievements

Before we get in to how achievements work, take a look at the admin experience for adding them and assigning the parameters with which they'll be awarded.

An admin can use this interface to create new in-game achievements complete with image uploads to be served on the web or the game interface.

Payload UI managing achievements

Awarding player achievements

I want to dive in to how our game takes game data, which we saw the structure for above. When a game is created we have an after-change hook which will do the heavy lifting of updating the player stats and awarding achievements. An admin can view a player with some experience in the Admin UI and see their stats and achievements.

Payload UI viewing achievements and stats of a player

Notice that the fields are all disabled since we have it set to read only, that is because we want hooks to manage this for us. Let's review the code for the hook that automates this process.

export const gamesAfterChangeHook: AfterChangeHook = async ({ doc, req }: Args) => {
const achievements = fetchAchievements(req.payload)
const playerData: { [id: string]: Player } = {}
const playerIDs: string[] = doc.teams.flatMap((team) => team.players)
.map((player) => (player.player as string))
const players = await fetchPlayers(req.payload, playerIDs)
const winners = getWinningTeamID(doc.teams)
// structure the player data by id to reduce looping
for (let player of players) {
playerData[player.id] = player
}
// loop over each team their players
doc.teams.forEach((team) => {
team.players.forEach(async (teamPlayer: { player: string, score: number }) => {
const player = playerData[teamPlayer.player as string]
updatePlayerAfterGame(req.payload, await achievements, player, teamPlayer, team, winners);
})
})
}

Each new game will call this code in the hook that updates players with new stats and achievements, all dynamically. Here is the function that to add up the stats, achievements and update the players.

/**
* Assign experience amounts for player outcomes
*/
const exp = {
winner: 500,
played: 100,
playerScore: 10,
teamScore: 5,
}
/**
* Update players with accumulated stats and achievements based on game outcomes
*/
export const updatePlayerAfterGame = (
payload: Payload,
achievements: Achievement[],
player: Player,
teamPlayer: Partial<{score: number, player: string}>,
team: Partial<{ score: number, id: string}>,
winners: string
) => {
const experience = player.stats.experience
+ (winners === team.id ? exp.winner : 0)
+ (team.score * exp.teamScore)
+ (teamPlayer.score * exp.playerScore)
+ exp.played
const stats = {
experience,
played: player.stats.played + 1,
wins: (winners === team.id ? 1 : 0) + player.stats.wins,
losses: (winners === team.id ? 0 : 1) + player.stats.losses,
}
// update player collection
const ignoreResult = payload.update({
collection: 'players',
id: player.id,
data: {
achievements: getPlayerAchievements(stats, achievements),
stats,
},
})
}

The last delicious detail is in how player achievements are filtered. As long as we have player stats that match the achievement.type it will just work.

/**
* get player achievements based on experience, wins and losses
*/
export const getPlayerAchievements = (playerStats: {[key: string]: number}, achievements: Achievement[]) => {
const playerAchievements = []
achievements.forEach((achievement) => {
if (playerStats[achievement.type] >= achievement.amount) {
playerAchievements.push(achievement.id)
}
})
return playerAchievements
}

In-App Purchases

From an admin perspective, the purchasable items look much the same as achievements except that they also have a price field.

The relationships for the purchases are slightly different from achievements in that it has been given its own collection instead of living in the player document. First, we can better segment our data as we won't always want to have it on the player. Second, it gives us a place to add more data like the amount paid and the Stripe charge id which we didn't need for achievements.

When a new player registers we are calling on Stripe to create a customer ID so that we're ready to handle any purchases in the game.

Then as a player purchase request comes in, it must have the token for the payment from stripe in order to complete the request.

import { CollectionBeforeChangeHook } from 'payload/types'
import { stripe } from '../../shared/stripe'
import { APIError } from 'payload/errors'
export const playerPurchaseHook: CollectionBeforeChangeHook = async ({ req, data }) => {
if (req.user.collection === 'admin') {
return
}
if (!req.body.stripeToken) {
throw new APIError('Could not complete transaction, missing payment', 400);
}
// get the amount from the purchase item
const result = await req.payload.find({
collection: 'purchases',
limit: 1,
where: {
id: {
equals: data.purchase,
},
},
})
const purchase = result.docs[0];
// `source` is obtained with Stripe.js;
// see https://stripe.com/docs/payments/accept-a-payment-charges#web-create-token
const charge = await stripe.charges.create({
amount: purchase.price,
currency: 'usd',
customer: req.user.customer as string,
capture: true,
source: req.body.stripeToken,
description: `In-App Purchase - ${purchase.name}`,
})
// set the charge id
data.charge = charge.id
}

Final thoughts

The code presented in this article and the accompanying repository was written in a matter of two days and shouldn't be considered production ready without testing.

Give us a shout if you liked this guide on Twitter @payloadcms or in our Discord!

If you want to hire me for game development you should absolutely talk to somebody else.