Skip to main content

What you’ll learn

We’re going to build a simple leaderboard module from scratch. Users will be able to authenticate, submit leaderboard scores, and retrieve the top scores. This will teach you how to build a simple leaderboard module from scratch & all related Open Game Backend concepts including:
  • Create a module
  • Setup your database
  • Write scripts
  • Write a test
Make sure you’ve installed Open Game Backend as described here.

Prerequisites


Step 1: Create project

Create a new directory. Open a terminal in this directory. Run the following command:
opengb init
This will create a backend.json file in your directory with some default settings.

Step 2: Create module

In the same terminal, run:
opengb create module my_leaderboard
See documentation on the module.json config here.
This will create a directory modules/my_leaderboard and updated backend.json to include the module. The important files we’ll look at are modules/my_leaderboard/db/schema.prisma, modules/my_leaderboard/scripts/, and modules/my_leaderboard/tests/.

Step 3: Write database schema

Edit your modules/my_leaderboard/db/schema.prisma file to look like this:
modules/my_leaderboard/db/schema.prisma
datasource db {
    provider = "postgresql"
    url      = env("DATABASE_URL")
}

model Scores {
    id        String   @id @default(uuid()) @db.Uuid
    createdAt DateTime @default(now()) @db.Timestamp
    userId    String   @db.Uuid
    score     Int

    @@index([score])
}
  • model Scores creates a new table that will store all of the scores on the leadboard
  • id is the unique identifier for each score.
    • String is the type of the column
    • @id indicates that this is the primary key of the table
    • @default(uuid()) indicates that this column will be automatically set to a UUID when a new row is created
    • @db.Uuid indicates that this is stored as UUID type. UUIDs are universally unique (unlike integers) and take a small amount of storage (unlike strings).
  • createdAt is the time the score was created
    • DateTime is the type of the column
    • @default(now()) indicates that this column will be automatically set to the current time when a new row is created
    • @db.Timestamp indicates that this is stored as a timestamp type. This is a good way to store dates and times in a database.
  • userId is the user who submitted the score
    • String is the type of the column
    • @db.Uuid indicates that this is stored as UUID type.
  • score is the score itself
    • Int is the type of the column
  • @@index([score]) creates an index on the score column.
    • This means the database will keep track of the scores in order so we can query them efficiently.
For more information about writng database schemas, see Prisma’s documentation on data modelling.
Open Game Backend is powered by PostgreSQL. Learn about why we chose PostgreSQL here.

Step 4: Write submit_score script

We’re going to create a submit_score script that will:
  • Throttle requests
  • Validate user token
  • Insert score
  • Query score rank
1

Create script

In the same terminal, run:
opengb create script my_leaderboard submit_score
This will create a new file modules/my_leaderboard/scripts/submit_score.ts:
import { ScriptContext } from "../module.gen.ts";

export interface Request {}

export interface Response {}

export async function run(ctx: ScriptContext, req: Request): Promise<Response> {
// TODO: Implement code for my_leaderboard/submit_score
throw new Error("Unimplemented");
}
This command also udpated modules/my_leaderboard/module.json to include the new script:
{
	"scripts": {
		"submit_score": {}
	},
	"errors": {}
}
  • import { ScriptContext } from "../module.gen.ts"; imports the ScriptContext.
    • The module.gen.ts file is generated by Open Game Backend to provide a type-safe interface for the database (see schema.prisma), calling other modules, and reading the module config.
  • export interface Request {} and export interface Response {} define the input and output of the script.
    • Open Game Backend will automatically generate strict schema validation for these types. This reduces unintentional errors and potential exploits/crashes.
  • export async function run(...) is the main function of the script.
    • This is where you write the code that will be executed when the script is called.
2

Add dependencies & make public

Update the modules/my_leaderboard/module.json file to look like this:
modules/my_leaderboard/module.json
{
	"dependencies": {
		"users": {},
		"rate_limit": {}
	},
	"scripts": {
		"submit_score": null
	},
	"public": true,
	"errors": {}
}
  • Added user and rate_limit as dependencies. This will allow us to authenticate the user and rate limit calls to the script.
  • Set public: true for the submit_score script so that anyone can call it.
3

Update request & response

Open modules/my_leaderboard/scripts/submit_score.ts and update Request and Response to look like this:
modules/my_leaderboard/scripts/submit_score.ts
export interface Request {
	userToken: string;
	score: number;
}

export interface Response {
	rank: number;
}
This will allow the user to authenticate using userToken and submit a score using score.
4

Throttle requests

At the top the run function in modules/my_leaderboard/scripts/submit_score.ts file, add code to throttle requests:
models/my_leaderboard/scripts/submit_score.ts
export async function run(ctx: ScriptContext, req: Request): Promise<Response> {
	await ctx.modules.rateLimit.throttlePublic({ requests: 1, period: 15 });
	// ...
}
This will rate limit the script to 1 request every 15 seconds. If the rate limit is exceeded, a rate_limit_exceeded error will be thrown. See the rate limit docs for more details.
5

Validate user token

Then authenticate the user:
models/my_leaderboard/scripts/submit_score.ts
export async function run(ctx: ScriptContext, req: Request): Promise<Response> {
	// ...
	const validate = await ctx.modules.users.authenticateUser({ userToken: req.userToken });
	///
}
This will authenticate the user and throw an error if the token is invalid. See the token docs for more details.
6

Insert score

Then insert the score in to the database:
models/my_leaderboard/scripts/submit_score.ts
export async function run(ctx: ScriptContext, req: Request): Promise<Response> {
	// ...
	await ctx.db.scores.create({
		data: {
			userId: validate.userId,
			score: req.score,
		},
	});
	// ...
}
This inserts the score into the database.id and createdAt will be automatically set by the database because of the @default annotations in the schema.
7

Query rank

Finally, query the score’s rank:
models/my_leaderboard/scripts/submit_score.ts
export async function run(ctx: ScriptContext, req: Request): Promise<Response> {
	// ...
	const rank = await ctx.db.scores.count({
		where: {
			score: { gt: req.score },
		},
	});

	return {
		rank: rank + 1,
	};
}
This will return the number of scores that are greater than (gt) the user’s score. This is the user’s rank on the leaderboard.We add 1 to the rank, since the 1st place will have 0 scores greater than it.
models/my_leaderboard/scripts/submit_score.ts
import { ScriptContext } from "../module.gen.ts";

export interface Request {
	userToken: string;
	score: number;
}

export interface Response {
	rank: number;
}

export async function run(
	ctx: ScriptContext,
	req: Request,
): Promise<Response> {
	await ctx.modules.rateLimit.throttlePublic({ requests: 1, period: 15 });
	const validate = await ctx.modules.users.authenticateUser({
		userToken: req.userToken,
	});

	await ctx.db.scores.create({
		data: {
			userId: validate.userId,
			score: req.score,
		},
	});

	const rank = await ctx.db.scores.count({
		where: {
			score: { gt: req.score },
		},
	});

	return {
		rank: rank + 1,
	};
}
For more information about querying databases, see Prisma’s documentation on querying data.

Step 5: Create get_top_scores script

1

Create script

In the same terminal, run:
opengb create script my_leaderboard get_top_scores
2

Make public

Open modules/my_leaderboard/module.json and update the get_top_scores script to be public:
modules/my_leaderboard/module.json
{
	"scripts": {
		"get_top_scores": null
	},
	"public": true
}
3

Update request & response

Open modules/my_leaderboard/scripts/get_top_scores.ts and update Request and Response to look like this:
modules/my_leaderboard/scripts/get_top_scores.ts
export interface Request {
	count: number;
}

export interface Response {
	scores: Score[];
}

export interface Score {
	userId: string;
	createdAt: string;
	score: number;
}
This will allow the user to request the top count scores and return an array of Score objects.
4

Throttle requests

At the top the run function in modules/my_leaderboard/scripts/get_top_scores.ts file, add code to throttle the requests:
models/my_leaderboard/scripts/get_top_scores.ts
export async function run(ctx: ScriptContext, req: Request): Promise<Response> {
	await ctx.modules.rateLimit.throttlePublic({});
	// ...
}
5

Query scores

Then query the top scores:
models/my_leaderboard/scripts/get_top_scores.ts
export async function run(ctx: ScriptContext, req: Request): Promise<Response> {
	// ...
	const rows = await ctx.db.scores.findMany({
		take: req.count,
		orderBy: { score: "desc" },
		select: { userId: true, createdAt: true, score: true },
	});
	// ...
}
  • take limits the number of scores returned to req.count.
  • orderBy orders the scores by score in descending order.
  • select limits the columns returned to userId, createdAt, and score.
6

Convert rows

Finally, convert the database rows in to Score objects:
models/my_leaderboard/scripts/get_top_scores.ts
export async function run(ctx: ScriptContext, req: Request): Promise<Response> {
	// ...
	const scores = [];
	for (const row of rows) {
		scores.push({
			userId: row.userId,
			createdAt: row.createdAt.toISOString(),
			score: row.score,
		});
	}

	return { scores };
}
This converts the database rows into the Score objects we defined in the Response.We use toISOString to convert the createdAt of type Date into a string.
models/my_leaderboard/scripts/get_top_scores.ts
import {  ScriptContext } from "../module.gen.ts";

export interface Request {
	count: number;
}

export interface Response {
	scores: Score[];
}

export interface Score {
	userId: string;
	createdAt: string;
	score: number;
}

export async function run(
	ctx: ScriptContext,
	req: Request,
): Promise<Response> {
	await ctx.modules.rateLimit.throttlePublic({});

	const rows = await ctx.db.scores.findMany({
		take: req.count,
		orderBy: { score: "desc" },
		select: { userId: true, createdAt: true, score: true },
	});

	const scores = [];
	for (const row of rows) {
		scores.push({
			userId: row.userId,
			createdAt: row.createdAt.toISOString(),
			score: row.score,
		});
	}

	return {
		scores: scores,
	};
}

Step 6: Start development server

1

Start development server

In the same terminal, run:
opengb dev
2

Migrate database

You will be prompted to apply your schema changes to the database. Name the migration init (this name doesn’t matter):
Migrate Database develop (my_leaderboard)
Prisma schema loaded from schema.prisma
Datasource "db": PostgreSQL database "my_leaderboard", schema "public" at "host.docker.internal:5432"

? Enter a name for the new migration: › init
3

Success

You’ve now written a full module from scratch. You can now generate an SDK to use in your game or publish it to a registry.

Step 7 (optional): Test module

Tests are helpful for validating your module works as expected before running in to the issue down the road. Testing is optional, but strongly encouraged.
All modules provided in the default registry are thoroughly tested.
1

Create test

opengb create test my_leaderboard e2e
e2e stands for “end to end” test. E2E tests simulate real-world scenarios involving multiple parts of a system. These tend to be comprehensive and catch the most bugs.
2

Write test

Update modules/my_leaderboard/tests/e2e.ts to look like this:
modules/my_leaderboard/tests/e2e.ts
import { test, TestContext } from "../module.gen.ts";
import { assertEquals } from "https://deno.land/[email protected]/assert/mod.ts";
import { faker } from "https://deno.land/x/[email protected]/mod.ts";

test("e2e", async (ctx: TestContext) => {
	// Create user & token to authenticate with
	const { user } = await ctx.modules.users.createUser({});
	const { token } = await ctx.modules.users.createUserToken({
		userId: user.id,
	});

	// Create some scores
	const scores = [];
	for (let i = 0; i < 10; i++) {
		const score = faker.random.number({ min: 0, max: 100 });
		await ctx.modules.myLeaderboard.submitScore({
			userToken: token.token,
			score: score,
		});
		scores.push(score);
	}

	// Get top scores
	scores.sort((a, b) => b - a);
	const topScores = await ctx.modules.myLeaderboard.getTopScores({ count: 5 });
	assertEquals(topScores.scores.length, 5);
	for (let i = 0; i < 5; i++) {
		assertEquals(topScores.scores[i].score, scores[i]);
	}
});
3

Run test

In the same terminal, run:
opengb test
You should see this output once complete:
...test logs...
----- output end -----
e2e ... ok (269ms)

ok | 1 passed | 0 failed (280ms)
I