Skip to content
On this page

Developer Blog

Building AIVenture - An AI-Powered Game With Directus

Published October 3rd, 2023

Written By
Craig Harman
Craig Harman
Guest Author
With Thanks ToKevin Lewis

You are standing in an open field…

AIVenture harks back to the days of text-based adventure games such at Zork and Hitchhiker’s Guide to the Galaxy but with a modern AI twist. The game uses Directus Flows and user registration along with the directus-extension-ai-pack extension to create a low-code game engine offering players a unique story-telling experience.

AIVenture in-game screenshot

The underlying application is effectively a ChatGPT powered game engine. It uses Directus to control game logic, provide player registration, and track game state persistence. The front-end is a Nuxt application which receives player commands (in the form of text prompts) and sends them to a Directus Flow endpoint. Responses are generated by ChatGPT using the directus-extension-ai-pack extension.

The Game Engine

The AIVenture game engine is responsible for:

  1. Authentication and Authorization of players
  2. Providing initial context to ChatGPT (eg. “You are a text adventure game writer…”).
  3. Generating an introduction (eg. “You are standing in an open field…”).
  4. Responding to player prompts in a way compliant with any game rules and the initial context.
  5. Managing ChatGPT interactions including token counts and unexpected responses.
  6. Providing a satisfactory end game state.
  7. State management for games in progress.

In AIVenture, all of this logic is represented in Directus Flows. However, with the large number of operations required we need to plan our architecture carefully to avoid unnecessarily complicating our game engine flow or duplicating functionality provided by Directus.

Registering Players

So that AIVenture can store game progress across devices, individual users need to be identified.

As Directus already has user management and authentication baked in, AIVenture is able to harness the API to create users. When users sign up, they are assigned a custom “player” role that gives them permissions to reach a single Directus Flow game endpoint.

To avoid the game sign-up process being spammed with fake email addresses, we require that a player’s email address is validated. Although there isn’t specifically an email validation workflow available in Directus, there is a user invite procedure. Behind the scenes when a player signs up to AIVenture we are actually generating a user invite in Directus. This does not give access to the game until it has been accepted via a link in the player’s email - which is effectively the same as an email validation process.

The emails sent to players are customized by directly editing the liquid template files provided by Directus. Once a user accepts their invite, they are provided with an active Directus user account and can access the game.

Tracking OpenAI Tokens

AIVenture uses the directus-extension-ai-pack extension to interact with OpenAI’s Chat Completion API within Directus Flows. The extension requires minimal setup before allowing chat prompts to be sent to OpenAI and have responses returned for further processing in our flows.

OpenAI charges the API account owner for usage via tokens allocated by the length of your prompts and the resulting responses. In order to keep OpenAI bills under control, AIVenture allocates daily, per user, token limits on a player’s progress through the game as well as a hard limit that indicates the game needs to reach a conclusion.

The ChatGPT API returns a token count with each response, so we were able to access the ChatGPT usage data in our flow:

json
"usage": {
    "completion_tokens": 17,
    "prompt_tokens": 57,
    "total_tokens": 74
}
"usage": {
    "completion_tokens": 17,
    "prompt_tokens": 57,
    "total_tokens": 74
}

Each prompt/response combination is stored in a Directus collection and various query filters and aggregators are used to provide the game engine with the total number of tokens used by a player in a day and the total tokens used for the current game.

If the player reaches their daily limit, no more prompts can be sent to the API and the game will prompt them to wait until tomorrow before being able to progress.

Once the total game token limit is reached, the prompt sent to ChatGPT is modified such that the next responses will include a satisfying conclusion to the game currently in progress.

Managing Complex Flows

Creating a game engine in Directus Flows has the potential to become excessively large and unmaintainable very quickly without some strict architectural decisions.

We keep flows as small and functionally-isolated components. Think of these flows as you would Classes in your application. A 'main' flow triggers our sub-flows. This is a powerful and important architecture for our game engine design as it allows us to compartmentalize logic, keeping our main flow clean and readable and making testing easier.

Let’s see what this looks like in Directus. First, we set up a flow called “SubCommand”. Our one important configuration for this flow is to select “Another flow” as the trigger set up - this will allow us to trigger this sub flow from within our parent and receive the resulting data. All our flow logic for our subcommand can now be added, being sure to return the required data.

Selecting “Another Flow” as our Trigger

Next we want to add our main flow. This will be called from our frontend so should have a “Webhook” trigger - for ease of testing in the browser, let’s use the GET method. Then we add a new operation to our flow of type “Trigger Flow” and select our “SubCommand” flow from above. Our payload should include the data from the subcommand, ie. {{ $last }}.

Main parent flow triggering a sub-command

If we make a request to our main flow we will see a response from our subcommand:

json
{
  "parent": "Message generated in parent flow.",
  "subcommand": "This message is from subcommand"
}
{
  "parent": "Message generated in parent flow.",
  "subcommand": "This message is from subcommand"
}

In AIVenture, we use this sub-flow technique extensively wherever we feel we have a self-contained functional component such as interacting with ChatGPT API, calculating token usage or processing user input.

Branching Logic With Only Two Paths

While Directus Flows are powerful enough to develop an entire game engine with no code, it still does have some limitations that we need to be conscious of while planning our data flow. One such limitation is that flows only allow branching to and from one input and 2 outputs (success or failure). If we are not careful this can result in the need for duplicated operations within our flows.

Let’s take the example of creating our end game state. Ideally this would mean our game engine flow changes only slightly from this:

  1. Receive user prompt
  2. Combine user prompt with existing game/story
  3. Send to ChatGPT API
  4. Return response

To this:

  1. Receive user prompt
  2. Combine user prompt with existing game/story
  3. Add additional direction to ChatGPT prompt to finish the game
  4. Send to ChatGPT API
  5. Return response

That is, a conditional check that updates our flow data object and then continues on with the same operations.

Ideal branching flow logic

In reality though we can’t have our second operation rejoin the first flow and instead would need to continue two parallel flows:

Parallel flows for branching logic

As such, returning to our example above, we’d have to duplicate steps four and five for both code paths. Extrapolate this out to all conditional logic in the game engine and we’d end up with multiple repeated operations.

There are two ways to overcome this that can be used in combination. The first is good flow planning to avoid situations where this branching needs to occur. The second is to make use of the “Run Script” operation and have your branching logic in there.

Here’s an example:

Conditional flow using script operation

Our flow contains the 5 steps our game logic required from above, but it has been modified to incorporate the possibility of finishing the game. Instead of using a conditional operation we use a script operation that modifies the data which will later be passed to ChatGPT only if we have reached the game’s conclusion. Our script would look something like this:

ts
module.exports = async function(data) {
	if (data.game.endGame) {
		data.prompt = data.prompt + " End the game."
	}

	return data
}
module.exports = async function(data) {
	if (data.game.endGame) {
		data.prompt = data.prompt + " End the game."
	}

	return data
}

Summary

AIVenture is a modern text-based adventure game engine that combines AI technology to create an engaging and immersive gaming experience. The use of Directus Flows simplifies game logic management, and uses sparing amounts of Javascript to handle forking game logic. If you’d like to play a game or two visit the AIVenture website.

What do you think?

How helpful was this article?