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.


Step 1: Create project

Create a new directory. Open a terminal in this directory.

Run the following command:

opengb init

Step 2: Create module

In the same terminal, run:

opengb create module my_leaderboard

See documentation on the module.yaml config here.


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])
}

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
2

Add dependencies & make public

Update the modules/my_leaderboard/module.yaml file to look like this:

modules/my_leaderboard/module.yaml
dependencies:
  users: {}
  rate_limit: {}
scripts:
  submit_score:
	public: true
errors: {}
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;
}
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 });
	// ...
}
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 });
	///
}
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,
		},
	});
	// ...
}
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,
	};
}

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.yaml and update the get_top_scores script to be public:

modules/my_leaderboard/module.yaml
scripts:
  get_top_scores:
	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;
}
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 },
	});
	// ...
}
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 };
}

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 "../_gen/test.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)