Skip to content

AI Dungeon Game

Module 2: Game API implementation

We are going to start by implementing our Game API. To do this, we need to create 4 API’s in total:

  1. createGame - this will create a new game instance.
  2. queryGames - this will return a paginated list of previously saved games.
  3. saveAction - this will save an action for a given game.
  4. queryActions - this will return a paginated list of all actions related to a game.

API Schema

To define our API inputs and outputs, let’s create our schema using Zod within the packages/game-api/schema/src project as follows:

import { z } from 'zod';
export const ActionSchema = z.object({
playerName: z.string(),
timestamp: z.string().datetime(),
role: z.enum(['assistant', 'user']),
content: z.string(),
});
export type IAction = z.TypeOf<typeof ActionSchema>;

You can also delete the ./procedures/echo.ts file given we will not be using it in this project.

Entity modelling

The ER diagram for our application is as follows:

dungeon-adventure-er.png

We are going to implement our Database in DynamoDB and will be using the ElectroDB DynamoDB client library to simplify things. To get started we need to first install electrodb by running the following command:

Terminal window
pnpm add -w electrodb @aws-sdk/client-dynamodb

Now let’s create the following files within our packages/game-api/backend/src/entities folder to define our ElectroDB entities as per the above ER Diagram:

import { Entity } from 'electrodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
export const createActionEntity = (client?: DynamoDBClient) =>
new Entity(
{
model: {
entity: 'Action',
version: '1',
service: 'game',
},
attributes: {
playerName: { type: 'string', required: true, readOnly: true },
timestamp: {
type: 'string',
required: true,
readOnly: true,
set: () => new Date().toISOString(),
default: () => new Date().toISOString(),
},
role: { type: 'string', required: true, readOnly: true },
content: { type: 'string', required: true, readOnly: true },
},
indexes: {
primary: {
pk: { field: 'pk', composite: ['playerName'] },
sk: { field: 'sk', composite: ['timestamp'] },
},
},
},
{ client, table: process.env.TABLE_NAME },
);

ElectroDB is very powerful and allows us to not only define our types, but can also provide defaults for certain values like the timestamps above. In addition, ElectroDB follows single-table design which is the best practice when using DynamoDB.

Adding the dynamoDB client to our tRPC context

Given we need access to the DynamoDB client in each of our procedures, we want to be able to create a single instance of the client which we can pass through via context. To do this, make the following changes within packages/game-api/backend/src:

middleware/dynamodb.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { initTRPC } from '@trpc/server';
export interface IDynamoDBContext {
dynamoDb?: DynamoDBClient;
}
export const createDynamoDBPlugin = () => {
const t = initTRPC.context<IDynamoDBContext>().create();
return t.procedure.use(async (opts) => {
const dynamoDb = new DynamoDBClient();
const response = await opts.next({
ctx: {
...opts.ctx,
dynamoDb,
},
});
return response;
});
};

This is a plugin that we instrument to create the DynamoDBClient and inject it into the context.

Defining our procedures

Now it’s time to implement the API methods. To do this, make the following changes within packages/game-api/backend/src/procedures:

import { createActionEntity } from '../entities/action.js';
import {
ActionSchema,
IAction,
QueryInputSchema,
createPaginatedQueryOutput,
} from ':dungeon-adventure/game-api-schema';
import { publicProcedure } from '../init.js';
import { z } from 'zod';
export const queryActions = publicProcedure
.input(QueryInputSchema.extend({ playerName: z.string() }))
.output(createPaginatedQueryOutput(ActionSchema))
.query(async ({ input, ctx }) => {
const actionEntity = createActionEntity(ctx.dynamoDb);
const result = await actionEntity.query
.primary({ playerName: input.playerName })
.go({ cursor: input.cursor, count: input.limit });
return {
items: result.data as IAction[],
cursor: result.cursor,
};
});

You can also delete the echo.ts file (from packages/game-api/backend/src/procedures) given we will not be using it in this project.

Router setup

Now that we have defined our procedures, let’s wire them into our API. To do this, update the following file as follows:

packages/game-api/backend/src/router.ts
import {
awsLambdaRequestHandler,
CreateAWSLambdaContextOptions,
} from '@trpc/server/adapters/aws-lambda';
import { echo } from './procedures/echo.js';
import { t } from './init.js';
import { APIGatewayProxyEventV2WithIAMAuthorizer } from 'aws-lambda';
import { queryActions } from './procedures/query-actions.js';
import { saveAction } from './procedures/save-action.js';
import { queryGames } from './procedures/query-games.js';
import { saveGame } from './procedures/save-game.js';
export const router = t.router;
export const appRouter = router({
echo,
actions: router({
query: queryActions,
save: saveAction,
}),
games: router({
query: queryGames,
save: saveGame,
}),
});
export const handler = awsLambdaRequestHandler({
router: appRouter,
createContext: (
ctx: CreateAWSLambdaContextOptions<APIGatewayProxyEventV2WithIAMAuthorizer>,
) => ctx,
});
export type AppRouter = typeof appRouter;

Infrastructure

The final step is to update our infrastructure to create the DynamoDB table and grant permissions to perform operations from the Game API. To do so, update the packages/infra/src as follows:

constructs/electrodb-table.ts
import { CfnOutput } from 'aws-cdk-lib';
import {
AttributeType,
BillingMode,
ProjectionType,
Table,
TableProps,
} from 'aws-cdk-lib/aws-dynamodb';
import { Construct } from 'constructs';
export type ElectrodbDynamoTableProps = Omit<
TableProps,
'partitionKey' | 'sortKey' | 'billingMode'
>;
export class ElectrodbDynamoTable extends Table {
constructor(scope: Construct, id: string, props?: ElectrodbDynamoTableProps) {
super(scope, id, {
partitionKey: {
name: 'pk',
type: AttributeType.STRING,
},
sortKey: {
name: 'sk',
type: AttributeType.STRING,
},
billingMode: BillingMode.PAY_PER_REQUEST,
...props,
});
this.addGlobalSecondaryIndex({
indexName: 'gsi1pk-gsi1sk-index',
partitionKey: {
name: 'gsi1pk',
type: AttributeType.STRING,
},
sortKey: {
name: 'gsi1sk',
type: AttributeType.STRING,
},
projectionType: ProjectionType.ALL,
});
new CfnOutput(this, 'TableName', { value: this.tableName });
}
}

Deployment and testing

First, lets build the codebase:

Terminal window
pnpm nx run-many --target build --all

Your application can now be deployed by running the following command:

Terminal window
pnpm nx run @dungeon-adventure/infra:deploy dungeon-adventure-infra-sandbox

Your first deployment will take around 8 minutes to complete. Subsequent deployments will take around 2 minutes.

You can also deploy all stacks at once. Click here for more details.

Once the deployment completes, you should see some outputs similar to the following (some values have been redacted):

Terminal window
dungeon-adventure-infra-sandbox
dungeon-adventure-infra-sandbox: deploying... [2/2]
dungeon-adventure-infra-sandbox
Deployment time: 354s
Outputs:
dungeon-adventure-infra-sandbox.ElectroDbTableTableNameXXX = dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY
dungeon-adventure-infra-sandbox.GameApiGameApiUrlXXX = https://xxx.region.amazonaws.com/
dungeon-adventure-infra-sandbox.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-infra-sandbox.StoryApiStoryApiUrlXXX = https://xxx.execute-api.region.amazonaws.com/
dungeon-adventure-infra-sandbox.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-infra-sandbox.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

We can test our API by either:

  • Starting a local instance of the tRPC backend and invoke the API’s using curl.
  • Calling the deployed API using sigv4 enabled curl

Start your local game-api server by running the following command:

Terminal window
TABLE_NAME=dungeon-adventure-infra-sandbox-ElectroDbTableXXX-YYY pnpm nx run @dungeon-adventure/game-api-backend:serve

Once your server is up and running, you can call it by running the following command:

Terminal window
curl -X GET http://localhost:2022/games.query\?input="\\{\\}"

If the command executes successfully, you should see a response as follows:

{"result":{"data":{"items":[],"cursor":null}}}

Congratulations. You have built and deployed your first API using tRPC! 🎉🎉🎉