Creating A Text Adventure Game In Godot

Godot is a great game engine and I've been looking for projects to help me expand my knowledge of the platform. After fiddling with drawing shapes and getting used to the interface I decided to create a text adventure game.

Text adventures used to be really popular in the early days of games, before graphical adventures were possible on the hardware available. I can remember playing a couple of adventure games in the late 80s and even playing some simple multi user dungeons (MUDs) in the 90s so text adventure games do have a hint of nostalgia for me.

I chose to create a text adventure since most of the code should be pretty simple. There's no collision detection or path finding going on, just a text parser and some output.

In this article I will go through the steps required to create a (very simple) text adventure game that allows for navigation around a number of rooms and interacting with locks and keys. As there might be a lot of code in the examples in this article I will abbreviate some of them with a "..." to show that some of the code is snipped out.

If you want to see all of the code (or even run the game) then I have uploaded it to a GitHub repository.

Creating The Control Interface

The first thing we need to do is add all of the elements needed for the game to function. We basically need a text area that the user can type into and a output display to show what's happening in the game. 

To get the interface for the game I added the following nodes to the game scene.

  • Control - The main container for the game.
    • Background - A Background container that fills the game application window.
      • ColorRect - A simple rectangle that provides a background colour to the game.
      • MarginContainer - This container provides a wrapper and a margin around the inner elements of the game.
        • Rows - A container to give structure to the game elements and ensure they appear as rows.
          • GameText - A RichTextLabel that is used to update the output text.
          • PanelContainer - A container for the label and line edit elements.
            • Label - This contains the text ">" and acts as a cursor for the text.
            • LineEdit - The text field for the user to enter their commands.

When you're finished you should have something that looks like this.

The components list of the Godot text adventure game.

The main theme was added to elements like the ColorRect and the Label within the PannelContainer. I wanted to go for a green screen effect here where the text would appear light green and a dark green background, mimicking the feel of old monochromatic CRT screens. The Label element being used to hold the ">" character that acts as a "type here" indication. For the LineEdit component I also turned on the caret blinking feature so add to the indication that the user can type in this spot.

When we run the game now we should see the following interface being created. Feel free to play around with the look and feel of the interface to get the effect you want.

The initial Godot text adventure, with no game code written yet.

Now that we have an interface, let's add some interaction to it.

Adding Interaction

To listen to the user submitting some text we need to add the text_submitted signal to the LineEdit node. This signal will be emitted by the node when the user hits enter on the keyboard and gives us a neat way of reacting to "commands" being entered into the interface.

You can add a signal to the LineEdit node by clicking on it in the scene window and then selecting "Node" from the interface panel on the right of the screen. This will show you a list of signals that the node can emit.

The Godot text adventure line edit component emit setup.

Double click on the "text_submitted" signal and click "Connect" to add the signal to the node and create the needed code in the LineEdit class.

We get a blank function to start with that has a single parameter of the text that was submitted. We will add more to this function later but for now we just need to add a clause that will do nothing if the user enters no text. If the user does enter some text then we clear out the LineEdit node and do something with the entered text.

extends LineEdit

func _on_text_submitted(new_text):
	if (new_text.is_empty()):
		return

	# clear the text of the text area.
	self.set_text('')

	# do something with the "new_text" variable and process the output.

At this point we also add a _read() function that is called when this node enters the scene tree for the first time. This function just calls the grab_focus() function on the current node, which essentially puts the cursor into the LineEdit node, ready for the user to start entering commands.

extends LineEdit

# Called when the node enters the scene tree for the first time.
func _ready():
	self.grab_focus()

func _on_text_submitted(new_text):
	if (new_text.is_empty()):
		return

	# clear the text of the text area.
	self.set_text('')

	# do something with the "new_text" variable and process the output.

That's it for interactions, so let's move onto doing something with the user's input.

Processing Input

Fundamental to to getting this game to work is the process of converting whatever it is that the user entered into some sort of meaningful command. What we don't want to happen here is for the user to enter some text and cause the game to error or do something unexpected, so we need a stringent set of commands that we need to match to accept the command.

To get this working we create a class called InstructionSet that contains all of the possible commands that the game can have. For the basic movement around the game we have north, south, east, and west, with a not found just in case we couldn't convert the text into an instruction.

class_name InstructionSet

const NOT_FOUND = "not_found"

const NORTH = "north"

const SOUTH = "south"

const EAST = "east"

const WEST = "west"

We then create another class called TextParser that will be used to convert the text the user entered into an instruction. This class has a function called parse() that takes the text we want to parse and returns an instruction. The parse function allows for some variation in the text to instruction interpretation by saying that both "north" and "go north" return the "NORTH" instruction.

class_name TextParser

var InstructionSet = load("res://src/InstructionSet.gd")

# Parse a given input string into an instruction.
func parse(text):
	match text:
		'go north':
			return InstructionSet.NORTH
		'north':
			return InstructionSet.NORTH
		'go south':
			return InstructionSet.SOUTH
		'south':
			return InstructionSet.SOUTH
		'go east':
			return InstructionSet.EAST
		'east':
			return InstructionSet.EAST
		'go west':
			return InstructionSet.WEST
		'west':
			return InstructionSet.WEST

	return InstructionSet.NOT_FOUND

func get_object():
	return object

By parsing the text in this way we create a complete disconnect between the text that the user entered and the instruction that gets produced as a result.

We don't do anything with the instructions in the TextParser class, that is the job of another class called GameDataProcessor. This class will load the room data into memory when the game starts and is used to convert the instructions entered into an action that will change the state of the application. Depending on the action that the user makes we can then render the room as a string and hand this text back to the user.

The rooms property of the class contains the data we loaded from the JSON file, and is essentially the game data. When the user enters a direction then we make sure that the user can move in that direction and add the change to the currentRoom property. If the currentRoom property is empty then we can assume that the user hasn't made any moves just yet and so we set the first room to be the room that the user starts out in.

The GameDataProcessor class at this point isn't very long, so I'll add it here in full.

class_name GameDataProcessor

var InstructionSet = load("res://src/InstructionSet.gd")

var rooms
var currentRoom = null

func _init():
	rooms = loadJsonData("res://data/game1.json")

# Load the game data from the json file.
func loadJsonData(fileName):
	var file = FileAccess.open(fileName, FileAccess.READ)
	var json_string = file.get_as_text()
	file.close()

	var json = JSON.new()
	var error = json.parse(json_string)
	if error == OK:
		var data_received = json.data
		if typeof(data_received) == TYPE_DICTIONARY:
			return data_received
		else:
			assert(false, "Unexpected data in JSON output")
	else:
		print("JSON Parse Error: ", json.get_error_message(), " in ", json_string, " at line ", json.get_error_line())
		assert(false, "JSON Parse Error")

func process_action(action):
	# If the current room is empty then start with the initial room.
	if currentRoom == null:
		currentRoom = 'room1'
		return render_room(rooms[currentRoom])

	# Is direction/action valid?
	if rooms[currentRoom]['exits'].has(action) == false:
		return 'I don\'t understand!' + "\n"

	# is a direction then change the state to the new room.
	if rooms[currentRoom]['exits'][action].has('destination') == true:
		currentRoom = rooms[currentRoom]['exits'][action]['destination']

	# return the text of the new room
	return render_room(rooms[currentRoom])

# Render a given room, including the exits.
func render_room(room):
	var renderedRoom = ''
	renderedRoom += room['intro'] + "\n"

	renderedRoom += "\nPossible exists are:\n"

	for exit in room['exits']:
		renderedRoom += "- " + room['exits'][exit]['description'] + "\n"

	return renderedRoom

The JSON file that contains the room data is pretty simple. It basically consists of a list of rooms, each of which contains an intro and some exits, which is used when we render the room information to the user. The exist data is also used to check if the user can go in a particular direction.

Here's an example of a JSON file containing two rooms that link to each other.

{
	"room1": {
		"intro" : "You are in a room with a fireplace with a roaring fire and a old oak table. It is otherwise empty.",
		"exits" : {
			"north" : {
				"description" : "Another room to the north.",
				"destination" : "room2"
			}
		}
	},
	"room2": {
		"intro" : "You are in a corridor with a door to the east",
		"exits" : {
			"south" : {
				"description" : "South, back the way you came.",
				"destination" : "room1"
			}
		}
	},
}

Having the JSON as the game data means that we have essentially created a re-programmable game engine. We can easily swap out the JSON file for something else and have an entirely new text adventure; without having to change a line of code.

Now that we have a mechanism for the user to move around the game we need to put this all together. This is done in the LineEdit class that we created earlier, which needs to be modified so that we can parse the game data and print the output.

The _ready() function (which is called as the class is generated) is used to instantiate the objects we need to process the game data. As we are certain that the player hasn't made a move yet we call the process_action() function on the GameDataProcessor object and pass an empty string, which causes it to set the currentRoom property of the GameDataProcessor object and render the first room output. Essentially, this starts the game.

As the user enters commands into the LineEdit node they trigger the _on_text_submitted() function, which uses the TextParser object (also created in the _ready() function) to process the input and send an instruction to the process_action of the GameDataProcessor object. The instruction is then appended to the text in the GameText node, which we discovered in the _ready() function and stored as a property of this class.

extends LineEdit

const TextParser = preload("res://src/TextParser.gd")
const GameDataProcessor = preload("res://src/GameDataProcessor.gd")

var gameText: RichTextLabel
var text_parser = null
var game_data_processor = null

# Called when the node enters the scene tree for the first time.
func _ready():
	gameText = get_parent().get_parent().get_node("GameText")
	text_parser = TextParser.new()
	game_data_processor = GameDataProcessor.new()
	gameText.append_text(game_data_processor.process_action('') + "\n")
	self.grab_focus()

func _on_text_submitted(new_text):
	if (new_text.is_empty()):
		return

	# clear the text of the text area.
	self.set_text('')

	# parse text
	var instruction = text_parser.parse(new_text)

	# send to game data
	var output_text = ''
	output_text += " > " + new_text + "\n\n"
	output_text += game_data_processor.process_action(instruction)
	output_text += "\n"

	# pass output to the game text area
	gameText.append_text(output_text)

With this complete we can technically run the game and navigate around the rooms as stored in the JSON file.

Let's take this a step further by adding in some extra commands.

Adding Items And Actions

We now have the actions to move around a small maze we can add additional actions to the InstructionSet class to expand what the user can do. Whilst moving around a maze is fine, we can make the game far more interesting by adding items that the user can pick up.

The actions of get, open, and close are added to the instruction set.

class_name InstructionSet

...

const GET = "get"

const OPEN = "open"

const CLOSE = "close"

The TextParser class then needs to be modified to understand the instructions passed to it. In order to give the player the ability to interact with objects we add an object property to the TextParser class. We then use a regular expression object to extract the object from the text data and add it to the object property. Using RegEx with named parameters means that we can extract the object by asking the RegEx object for the correctly matched string.

class_name TextParser

var InstructionSet = load("res://src/InstructionSet.gd")

var object = null

# Parse a given input string into an instruction.
func parse(text):
	match text:

...

	if text.begins_with('get '):
		var regex = RegEx.new()
		regex.compile("get\\s(?<object>.*(\\s.*)?)")
		var results = regex.search(text)
		object = results.get_string('object')
		return InstructionSet.GET

	if text.begins_with('open '):
		var regex = RegEx.new()
		regex.compile("open\\s(?<object>.*(\\s.*)?)")
		var results = regex.search(text)
		object = results.get_string('object')
		return InstructionSet.OPEN

	if text.begins_with('close '):
		var regex = RegEx.new()
		regex.compile("close\\s(?<object>.*(\\s.*)?)")
		var results = regex.search(text)
		object = results.get_string('object')
		return InstructionSet.CLOSE

	return InstructionSet.NOT_FOUND

func get_object():
	return object

This change means that when an instruction like "get key" is written the instruction "get" will be returned and the item "key" will be set into the object.

To make use of this we need to change the process_action() call in the LineEdit class to pass the instruction and the object found in the last instruction.

	# send to game data
	var output_text = ''
	output_text += " > " + new_text + "\n\n"
	output_text += game_data_processor.process_action(instruction, text_parser.get_object())
	output_text += "\n"

The process_action() function then needs to be altered to understand the new instructions.

Here is the addition of the 'get' and 'open' instructions being added to the function. We use some additional properties in the JSON game data to detect if the object is available and if the doors are locked.

class_name GameDataProcessor

...

var inventory = {}

...

func process_action(action, object = null):

...

	if action == InstructionSet.GET and object != null:
		for item in rooms[currentRoom]['items']:
			if rooms[currentRoom]['items'][item]['name'] == object:
				inventory[item] = rooms[currentRoom]['items'][item]
				return 'You get the ' + object;
		return 'There is no ' + object + "\n"

	if action == InstructionSet.OPEN and object != null:
		var direction = object.get_slice(' ', 0)
		var exit = object.get_slice(' ', 1)
		for item in rooms[currentRoom]['exits']:
			if item == direction:
				for inventoryItem in inventory:
					if rooms[currentRoom]['exits'][item]['key'] == inventoryItem:
						rooms[currentRoom]['exits'][item]['locked'] = false
						return 'You open the ' + direction + ' door'
			else:
				return 'What direction do you want to open?'
		return 'You do not have the key for this door'
...

	# return the text of the new room
	return render_room(rooms[currentRoom])

The new inventory property is added to keep hold of the inventory of the user.

Note that instead of returning the rendered room we instead return the outcome of the action that the user took. We are also doing something different here. As the information regarding the door is stored in the rooms array (as loaded from the JSON data) this means we can manipulate this data as required. So, when the user unlocks a door we change the locked state of the door in that room to be false, which essentially opens it.

Now we just need to add the extra data to the JSON array so that the user can play the game.

{
	"room1": {
		"intro" : "You are in a room with a fireplace with a roaring fire and a old oak table. It is otherwise empty.",
		"items" : {
			"key123" : {
				"description" : "You see a key on the oak table.",
				"type" : "key",
				"opens" : "north",
				"name" : "key"
			}
		},
		"exits" : {
			"north" : {
				"description" : "Another room to the north.",
				"destination" : "room2",
				"locked" : true,
				"key" : "key123"
			}
		}
	},
	"room2": {
		"intro" : "You are in a corridor with a door to the east",
		"exits" : {
			"south" : {
				"description" : "South, back the way you came.",
				"destination" : "room1"
			}
		}
	},
}

The user now has the ability to pick up some items and use them to open doors. The description of each item is configurable so that the description isn't always a "key", it also allows us to expand the game in the future to add other puzzle types to the game is we want to.

Extra Commands

With this basic structure intact we can now add some extra commands to assist the player as they are playing the game. Actions like "look" and "help" assist  the player by printing out text that should help them in their quest. We can also add features like "reset" to reset the game and "quit" to close it in the same way.

Again, to add an instruction we just need to add the instruction code to the InstructionSet class.

class_name InstructionSet

... 

const LOOK = "look"

const HELP = "help"

const RESET = "reset"

const QUIT = "quit"

And then give the TextParser the ability to extract the instruction from the information the user has passed. We allow a little bit of variation in the help instruction by also allowing the user to enter "help me".

class_name TextParser

var InstructionSet = load("res://src/InstructionSet.gd")

# Parse a given input string into an instruction.
func parse(text):
	match text:
...
		'look':
			return InstructionSet.LOOK
		'help':
			return InstructionSet.HELP
		'help me':
			return InstructionSet.HELP

		'reset':
			return InstructionSet.RESET
		'quit':
			return InstructionSet.QUIT
		'exit':
			return InstructionSet.QUIT

	return InstructionSet.NOT_FOUND

func get_object():
	return object

With that in place we just need to alter the process_action() function so that the instructions returned from the TextParser object actually perform actions.

The look command is pretty simple, we just need to re-render the current room again.

	# React to the look command.
	if action == InstructionSet.LOOK:
		return render_room(rooms[currentRoom])

The help command will print out the available commands to the user.

	# React to the help command.
	if action == InstructionSet.HELP:
		var helpText = ''
		helpText += 'Instructions:' + "\n"
		helpText += '- Use "look" around the room you are in.' + "\n"
		helpText += '- Use "north", "south", "east", "west" to move in that direction.' + "\n"
		helpText += '- Use "open" or "close" to interact with doors.' + "\n"
		helpText += '- Use "get <object>" pick up objects.' + "\n"
		helpText += '- Use "reset" to reset the game, or "exit" to quit.' + "\n"
		return helpText

The reset command will set the current room to null and reset the inventory property before recursively calling the process_action() function again. This has the effect of setting the users progress since the currentRoom property is where we keep their current room. In addition to this we also need to reset the room data to reset the state of any locked doors, which we do by re-loading the data from the JSON file.

	# React to the reset command.
	if action == InstructionSet.RESET:
		currentRoom = null
		inventory = {}
		rooms = loadJsonData("res://data/game1.json")
		return process_action(null)

Finally, the quit command just calls the quit() function on the game engine, which closes the game.

	# React to the quit command.
	if action == InstructionSet.QUIT:
		Engine.get_main_loop().quit()
		return 'Bye...'

That's about it. With those final changes in place we now have a fully working game that the user can exit out of if required.

The Finished Game

Here are some screenshots of the game in action, running through the debug player in Godot.

The initial condition of the game is pretty simple and shows the player that there's a key in the room that they might pick up.

The Godot text adventure running in it's completed version.

After a while, the player has unlocked the door and progressed through the rooms.

The Godot text adventure game, after being completed.

In order to keep things simple the game only consists of 3 rooms and a single key and door puzzle. Once the user has entered the stone bricked room there isn't an awful lot to do in the game but go back the way they came.

Conclusion

Writing this project was pretty fun. The GDScript language is quite intuitive to work with, and although I did have to look up things like classes, string matching, and array manipulation the documentation is excellent.

The game itself, although it works, is pretty simple and somewhat limited. I have only created three rooms and a single key and lock puzzle, but the potential is there to create something with a bit of a plot by just adding more things to the JSON file. As this is a game for education purposes I decided to keep the number of actions and the JSON file as simple as possible.

One big oversight is that the game doesn't have an end-state. Once you reach the third room there isn't much else to do. I added the ability to go back to the room you just came from, but I suppose there's nothing to stop you having a room with a winning (or game over) message in it by just not giving it any exists.

If you want to see all of the code (or even run the game) then I have uploaded it to a GitHub repository. Feel free to open any issues if you spot anything that needs correcting.

What I have created here isn't the only way to create a text adventure game. When looking up some information regarding text adventures I came across the concept of a "state model". This is where the current state of the world you create is held in memory (or at least some state flags are held in memory) and it is the state that changes as you move and interact with the world. Although I have started towards this with the current room and inventory properties, a much better way to do this is with a state object that I can update depending on the actions of the user. Doing this would also allow us to potentially serialise the data into a file and so create a save state system.

The game itself also features integration with a framework called GUT, or the Godot Unit Testing framework. I added this as a way of testing the text parsing side of the game, but the GUT engine can allow for so much more. To run the unit tests you just need to install the GUT plugin and run them from the Godot interface. I'm really happy I found this plugin as it allowed me to properly test the game engine without having to run the game and spend hours typing various things into the LineEdit node.

I did contemplate putting the game on itch.io for you to have a go at, but it seems frivolous to add such a simple game to that site. You can easily run this project in Godot so feel free to download Godot and give it a go.

More in this series

Add new comment

The content of this field is kept private and will not be shown publicly.
CAPTCHA
2 + 14 =
Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.