Game: Stranger Wings #
Stranger Wings is a text adventure game along the lines of Colossal Cave Adventure (1976) or Zork (1977).
The player’s character will start the game by waking up in the alley behind the Stranger Wings chicken wings restaurant where they work. The player enters commands to explore and move around the environment, pick up items, and use things on other things.
Design #
Stranger Wings is a bit more involved than the games we’ve developed so far, so it will pay to plan out the design of the game first before we start coding. Once we have a high level design, we can drill down into some details to determine what features exactly need to be implemented.
High Level #
The player character (PC) begins the game by waking up without any pants in the alleyway behind the Stranger Wings restaurant where they work. The PC has presumably had a pretty bad day so far to find themselves in this situation and would like nothing more than to hop onto their bicycle and head home, if only their pants weren’t missing.
The player must explore the environment to find the PC’s pants so they can head home. Unfortunately, the PC’s pants are being guarded by Bitey, the restaurant owner’s “cat”. Bitey loves chicken wings though, so the player needs to cook up a quick batch of wings for Bitey in exchange for their pants. Now properly clothed, the player can finally leave the restaurant without dying of embarassment.
Environment #
The Stranger Wings environment consists of several locations, and each location has directions that the player can choose to travel in order to move to a different location.
- Alley
- down into the basement
- West Basement
- east into the east basement
- East Basement
- west into the west basement
- up into the kitchen
- Kitchen
- down into the west basement
- east into the storage room
- south into the walk in freezer
- north into the dining room
- Storage Room
- west into the kitchen
- Walk In Freezer
- north into the kitchen
- Dining Room
- north onto the street
- south into the kitchen
Note that there is a connection from the Alley down into the basement, but there is no connection from the basement back up to the alley. After dropping down from the alley into the basement, they player won’t be able to return to the alley, but they are otherwise free to roam through all of the other rooms.
To win the game, all they need to do is walk out the front door of the restaurant, but in order to do that, they’re going to need to find their pants first.
Items #
There are several items the player can pick up and use, scattered through the environment. The most important item is of course the protagonist’s pants, which allow the player to exit the restaurant safely.
Let’s work backwards from the end to build the puzzle…
- Bitey the cat will gaurd the pants. Bitey demands cooked chicken wings, so if the player offers Bitey wings, the player will receive the pants in exchange.
- In order to obtain the cooked wings, the player will have to find the frozen chicken wings in the freezer, and then use them on the deep fryer in the kitchen to cook them.
- The freezer will be frozen shut, so they’ll need a crowbar to open the freezer.
- The crowbar can be found on the floor in the basement.
We have our full list of items: pants, cooked wings, frozen chicken, crowbar.
Objects #
You may have noticed that we’ve quietly introduced a new concept, which I will call objects. An object is a thing in the environment that can’t be picked up, but it can have items used on it to produce some result. The two objects are the deep fryer, and Bitey the cat.
To keep things simple, in both cases these objects will convert one specific item into another specific item. The deep fryer will accept raw chicken and return cooked chicken wings, Bitey will accept cooked chicek wings, and give pants back in exchange.
Keep in mind that the term object here is somewhat arbitrary, I chose this word to mean this kind of thing in this particular game. What constitutes a “game object” will vary from game to game, and engine to engine. Isn’t an item an object? Is a door an object? Is the player an object? Maybe! For this game though, I’ll use it only for the special category of things which include Bity and the deep fryer.
Actions #
Now that we know what’s going into the game world, what can the player actually DO? What actions can they take in order to progress through the game?
At a minimum we’ll need to be able to:
- LOOK around the environment
- GO to a new location
- TAKE items from the environment
- USE items on objects in the environment
So we’ll need to implement the commands LOOK, GO, TAKE, and USE.
Putting It All Together #
We can build up these systems step by step:
- We’ll start by handling multiple word commands
- We’ll construct the environment that the player can move around
- Next we’ll add items the user can pick up
- And then objects which those items can be used on
- Finally we’ll add conditions and requirements that the player must fulfill in order to perform certain actions, turning the game into a puzzle for the player to solve.
Player Commands #
We’ll start off as usual with a basic game loop saved as stranger-wings.lua
function main_loop()
print("Welcome to Stranger Wings\n")
game_over = false
while not game_over do
io.write("\nEnter command: ")
player_command = string.lower(io.read())
if player_command == "quit" then
game_over = true
end
end
end
main_loop()
So far we’ve only handled commands that consist of a single word, like loot
, or quit
.
But what if we want the player to be able to specify more information, such as which direction they want to go? One way of handling this would be to create a long list of possible combinations of things the player might enter, eg. go north
, go east
, go south
, go west
and so on.
As long as there are a reasonably limited number of combinations, this might work, but things can quickly get out of hand if for example you want to support commands like use sandwich on racoon
… you aren’t going to have a good time creating a list of every possible combination of items in your game.
A much more efficient and flexible way of dealing with this situation is to split up the words of the command, and handle each one according to it’s purpose. We’re going to make a few assumptions to make this easier on ourselves…
First of all, we’ll assume that each word is separated by space (
) characters.
And second we’ll assume that the first word is always a verb, such as go
, look
, take
. For each verb we’ll write a separate function that knows how to perform that action. Then each function can look at the remaining words in the command and decide what to do with them. The function for go
is going to look at the second word, check which direction the player wants to move (north
, east
, south
, west
), and update the game accordingly.
Splitting up strings like this to determine what each part means is a simple form of parsing, which is programming task that comes up all the time. This is what the Command Prompt is doing when you enter commands, what the Lua interpreter is doing when it runs a Lua script, or what your web browser does to read URLs or display HTML as a web page.
Splitting Strings #
In order to operate on each word in the string separately, we’re going to split the string, starting with the original single string that the player enters (go north
), and ending up with an array contiaining each word on it’s own {"go", "north"}
.
Once we have the words separated into the array, it’s a simple matter to access each individual word, if our array is named command
then command[1]
will give us the first word "go"
, and command[2]
will give us the second word "north"
.
Lua doesn’t have a built in function to split strings like this, so we’ll need to write (or steal) our own. There are quite a few possible ways to do this, and it is a surprisingly nuanced problem.
For now, I’ll provide an implementation that should work for our current purposes:
function split_words(str)
local t = {}
local function helper(word)
table.insert(t, word)
end
string.gsub(str, "%S+", helper)
return t
end
The local
keyword is new, and will be covered in the next section.
How does split_words
actually work?
It isn’t necessary that you understand the details of split_words
right now, but if you’re curious…
string.gsub
is a Lua provided function intended to substitute characters within a string, using a pattern to express what series of characters to match, and in this case, using a helper function to provide the replacement for each series. (Programming in Lua, Pattern-Matching Functions and Patterns )
Basically, string.gsub
will find each word in the string matching the pattern %S+
(which means, “one or more characters in a row that are not spaces”), and call our function helper
for each match found. Our function helper
takes this opportunity to add each words into an array, t
.
Since all we want is the list of words, helper
doesn’t return any value to replace each match with, leaving the original string untouched.
When string.gsub
finishes, each of the words in the string have been passed to helper
which has in turn added them into the array t
. We return t
as the final result.
Let’s test this out by printing out the size and contents of the array that we get back:
function split_words(str)
local t = {}
local function helper(word)
table.insert(t, word)
end
string.gsub(str, "%S+", helper)
return t
end
function main_loop()
print("Welcome to Stranger Wings\n")
local game_over = false
while not game_over do
io.write("\nEnter command: ")
local player_command = string.lower(io.read())
local split_command = split_words(player_command)
print("word count:", #split_command)
for i=1,#split_command do
print ( "\"" ..split_command[i] .. "\"" )
end
local cmd = split_command[1]
if cmd == "quit" then
game_over = true
end
end
end
main_loop()
Running this and entering a variety of test commands should give results like the following:
Welcome to Stranger Wings
Enter command: hello world
word count: 2
"hello"
"world"
Enter command:
word count: 0
Enter command: go north
word count: 2
"go"
"north"
Enter command: my spoon is too big!
word count: 5
"my"
"spoon"
"is"
"too"
"big!"
Enter command: oooOOOO NO!@#$
word count: 2
"ooooooo"
"no!@#$"
Now we’re ready to start handling some more complicated commands.
Local vs Global Scope #
An important topic that I haven’t managed to cover yet is local variables. You may have noticed that split_words
contains the keyword local
in two places, marking the table t
and the function helper
as being local.
function split_words(str)
local t = {}
local function helper(word)
table.insert(t, word)
end
string.gsub(str, "%S+", helper)
return t
end
Whether a variable is local or global is referred to as it’s scope. Scope determines where a variable can be accessed from.
A variable that has global scope can be accessed and used anywhere in the program.
Whereas a variable that has local scope can only be accessed in the local block of code, which can be one of a few different cases depending on where they are declared:
local
variables outside of a function are local to the file that contains them. We’ve only used single file programs so far, but more complex projects will be built of multiple.lua
files, and file local variables can only be seen or used inside the same file that declares them.local
variables declared inside of a function, can only be accessed within that same function.local
variables declared inside of a loop such asfor
orwhile
, or anif
/else
block, can only be used within that same block.
Variables you declare have global scope by default, unless you explicitly make them local
, so up until now, nearly all of the variables we’ve used have been global.
Why does this matter?
While there are performance and technical implications of scope, the big thing that matters for us at this point is organization and avoiding bugs.
As your programs increase in size, it becomes inevitable that you’ll end up wanting to use the same variable name inside multiple different functions. By declaring these variables as local
, you can ensure that each of them is protected from accidental interference from other functions.
Here’s an example of how this can play out…
Imagine a world where our split function bad_split
, doesn’t use local
, and our main_loop
also uses a global variable named t
that keeps track of how many times to repeat back the command that the player enters when using the repeat
command.
|
|
Saving this program as bad-global.lua
, running it and using the repeat
command results in an error:
Welcome to Bad Global
Enter command: repeat me
..\bin\lua53: bad-global.lua:27: 'for' limit must be a number
stack traceback:
bad-global.lua:27: in function 'main_loop'
bad-global.lua:35: in main chunk
[C]: in ?
On line 13 we set t
to 5, we want to repeat the player’s command back 5 times.
Then on line 27, we tried to use a for
loop, from 1 to t
(which should be 5)… but instead we get an error about t
not being a number… even though we just set it to 5 earlier.
Since t
is a global variable, when we called bad_split
on line 20, the bad_split
overwrote the global variable t
with a table, and our number 5 was lost. By the time we execute line 27, t
has been changed and no longer stores the value 5 like we wanted.
One way of fixing this issue would be to simply rename one of the variables so they have different names and don’t overlap. You could try and make sure that you never use the same variable name twice in the same program but, trust me, this isn’t a reliable solution once programs become more than a few dozen lines long, and is hopeless if you begin using code provided by other people, or on multi-person teams.
Instead, we can resolve the bug by make each t
variable local
local t = {}
local t = 5
And if we try again, the bug has been fixed:
Welcome to Bad Global
Enter command: repeat me
repeat me
repeat me
repeat me
repeat me
repeat me
Now each function has it’s own separate variables named t
that won’t interfere with each other.
For the very small programs we’ve made so far, there hasn’t been much risk of running into these kinds of problem, but going forward, I’ll be using local
variables as standard best practice.
Some variables that are truely global to the entire game, such as the world map data, or the player’s location or score, will continue to be global, but it still pays to be careful about how these variables will be named and used.
The Environment #
We’re building a game with multiple locations that the player can move between. To accomplish this, we need to set up 3 things:
- Data that defines the locations of the world, and how they connect to each other
- A variable to track where the player currently is
- Commands that allow the player to examine the environment, and move from one location to another.
The World Map #
So far we’ve used simple array-type tables, and stored counts of things in our inventory in earlier games. Now we’re going to step it up a notch and use tables to store more tables!
Similar to how putting files and folders inside other folders works on your hard drive, putting tables inside tables is a convenient way to collect and organize the data that describes our game world.
Our world map will consist of a world_locations
table, inside which there will be a table for each location accessable by a simple name such as "alley"
or "kitchen"
. Each location table will contain information about that location, such as the narrative description that we will show to players, and a list of which directions the player can travel.
Let’s start off with a simple world with only two locations. Then we can implement the commands to move between them and look around. Once we have that working, it will be a simple matter to add in all of the rest of the locations.
The main list of locations will start off like a simple table:
world_locations = {}
And then we can add a new table for each location, into the world_locations
table. We could do it this way:
world_locations = {}
world_locations.alley = {}
world_locations.kitchen = {}
This can get a bit repetative depending on how much data you’re declaring. Another way of setting up this same structure is to nest the location tables inside the world_locations
table when we declare it, instead of adding them after the fact…
world_locations = {
alley = {},
kitchen = {},
}
This will have exactly the same result as the previous example, but it’s less repetative and you can also hopefully begin to see the structure of the data a bit better, as alley
and kitchen
are contained inside the brackets {}
of the world_locations
table.
When we declare tables this way, we put a list of key = value statements inside the {}
, separated by commas. This can also be a source of bugs, as it’s easy to miss a comma between different elements of the table, or to misplace the final }
, so you’ll want to be particularly attentive when creating tables this way.
Putting a comma after the final element is optional, but I often do just so that I don’t need to remember to add it later when I add another entry to the list.
Another thing to note here is that Lua doesn’t care much about the spacing or separation onto separate lines. The following code does the same thing:
world_locations = { alley = {}, kitchen = {} }
When you’re only declaring small tables, this is a nice compact way of writing them, but since we plan on adding quite a bit of information to this table, it makes more sense to have things separated out into different lines with indentation to make it more obvious to ourselves where tables start and end.
Now that we have our two location tables, we can add additional information inside each of them. We’ll start off with the narrative description:
world_locations = {
alley = {
description = "You stand in the alley beside Stranger Wings chicken restaurant.",
},
kitchen = {
description = "You are in the Stranger Wings restaurant's kitchen.",
},
}
And our final piece of data for now, inside each location table we’ll add a new table that contains a list of directions the player can move, and which location they’ll end up in.
world_locations = {
alley = {
description = "You stand in the alley beside Stranger Wings chicken restaurant.",
directions = {
east = "kitchen",
},
},
kitchen = {
description = "You are in the Stranger Wings' restaurant kitchen.",
directions = {
west = "alley",
},
},
}
If you’re in the alley
and move east
you’ll end up in the kitchen
, conversely if you’re in the kitchen
and move west
you’ll end up in the alley
.
This is all of the data we’ll need to implement our first few commands, look
and go
.
We’ll be removing the direct connection between the alley and the kitchen later, to direct the player down into the basement. This is just to make testing easier.
Player Location #
Before we implement the commands, let’s make sure we know which location we’re presently in. The game will start with the player in the alley, so we’ll just create a new variable to represent this:
player_location = "alley"
Easy!
Now that we have the player’s location stored in a variable, we can access the correct table for whatever their location happens to be with world_locations[player_location]
Just make sure that the value of player_location
matches one of the keys in world_locations
. If it doesn’t match any of the keys in the table, we’ll get a nil
result, and probably crash as soon as we try to use it.
Why does
"alley"
have quotes here and not when we createdalley
table inside theworld_locations
table?As mentioned back in Infinite Treasure II, Lua has some convenience syntax to make working with tables easier on the eyes. When declaring table keys as we did in the
world_locations
table, we’re allowed to leave out the[""]
and Lua will treat the keyalley
as a string.We also have two options when accessing table keys,
world_locations.alley
andworld_locations["alley"]
will both access the same location table using the given key. But we can only use the simplified version if there are no spaces or other punctuation…world_locations["in a dark pit"]
would work because the key is a single string, butworld_locations.in a dark pit
wouldn’t work, as Lua doesn’t know where the key ends and the rest of the code resumes.In other situations there is no simplified shortcut, we need to be explicit that the thing we’re typing is a string and use the quoted version,
"alley"
.
Looking Around #
Now to add our first command look
, which will display the description of the player’s current location.
The function to handle the look
command is simple, we just access the location table based on the player’s current location, and then print out the description value:
function command_look()
local location = world_locations[player_location]
print(location.description)
end
Then all we need to do is call command_look
when the player enters the look
command. We can take out the debugging information we printed out earlier as well (or leave it in, your choice!)
function main_loop()
print("Welcome to Stranger Wings\n")
local game_over = false
while not game_over do
io.write("\nEnter command: ")
local player_command = string.lower(io.read())
local split_command = split_words(player_command)
local cmd = split_command[1]
if cmd == "quit" then
game_over = true
elseif cmd == "look" then
command_look()
end
end
end
Putting everything together and running the game, we can now use the look
command to see where we are.
Welcome to Stranger Wings
Enter command: look
You stand in the alley beside Stranger Wings chicken restaurant.
Enter command: look around
You stand in the alley beside Stranger Wings chicken restaurant.
Enter command: go north
Enter command: look over there!
You stand in the alley beside Stranger Wings chicken restaurant.
Enter command: hey look
As long as the first word in the command is look
, our look function will run. This can be a benefit in cases where the player types something comfortable like look around
, but it also means they can type any old nonsense after the look command and it will be ignored by the game.
We’ll revisit this situation later as we’ll want to let the player specify an object they want to look at.
Code So Far
world_locations = {
alley = {
description = "You stand in the alley beside Stranger Wings chicken restaurant.",
directions = {
east = "kitchen",
},
},
kitchen = {
description = "You are in the Stranger Wings' restaurant kitchen.",
directions = {
west = "alley",
},
},
}
player_location = "alley"
function split_words(str)
local t = {}
local function helper(word)
table.insert(t, word)
end
string.gsub(str, "%S+", helper)
return t
end
function command_look()
local location = world_locations[player_location]
print(location.description)
end
function main_loop()
print("Welcome to Stranger Wings\n")
local game_over = false
while not game_over do
io.write("\nEnter command: ")
local player_command = string.lower(io.read())
local split_command = split_words(player_command)
local cmd = split_command[1]
if cmd == "quit" then
game_over = true
elseif cmd == "look" then
command_look()
end
end
end
main_loop()
Moving Between Locations #
Moving is a bit more involved than just looking around, as we need to check where the player is asking to go, check that their request is a valid option, and update the game state to reflect the player’s new location.
First we’ll set up a function to handle the go
command:
function command_go(direction)
local location_info = world_locations[player_location]
local destination = location_info.directions[direction]
if destination ~= nil then
player_location = destination
print(string.format("You go %s", direction))
else
print(string.format("You can not go %s", direction))
end
end
Similar to looking around, we first use the global player_location
variable to look up our location table in the global world_locations
table, and store it in the local variable location_info
.
Note that command_go
takes a parameter, direction
which will contain the second word that the player entered. If the player enters go north
then the direction
parameter will be "north"
.
We use the direction
parameter as a key into the location_info.directions
table, and store the value in the local destination
variable.
If the player is currently in the alley, then the location_info
variable will refer to the alley
table, and the available directions will be east
, whereas if the player is currently in the kitchen, the available direction will be west
. The value that we get back tells us the new location the player will be in if they travel in that direction. Starting in the alley
and going east
, for example, will change the player’s location to kitchen
.
It’s possible the player enters a direction that isn’t contained in the table (eg. they type go away
), in which case we’ll get a nil
value since "away"
isn’t one of the entries in the directions
table, so we’ll need to check this first before using it.
If destination
is not nil
(destination ~= nil
) then we update the global player_location
value to this new location, and report to the player that they have successfully moved in the direction they requested.
But if destination
is nil
, we report back to the player that they can’t go in whatever direction they specified.
Now we just have to hook up our command_go
function in main_loop
.
For the go
command we also need to pass the second word in the split_command
array as the parameter to command_go
, so that we can tell where it is exactly that we’re trying to go.
function main_loop()
print("Welcome to Stranger Wings\n")
local game_over = false
while not game_over do
io.write("\nEnter command: ")
local player_command = string.lower(io.read())
local split_command = split_words(player_command)
local cmd = split_command[1]
if cmd == "quit" then
game_over = true
elseif cmd == "look" then
command_look()
elseif cmd == "go" then
local where = split_command[2]
command_go(where)
end
end
end
With this new command in place, we’re now able to move around the world to explore different locations:
Welcome to Stranger Wings
Enter command: look
You stand in the alley beside Stranger Wings chicken restaurant.
Enter command: go west
You can not go west
Enter command: go east
You go east
Enter command: look
You are in the Stranger Wings' restaurant kitchen.
Enter command: go east
You can not go east
Enter command: go west
You go west
Enter command: look
You stand in the alley beside Stranger Wings chicken restaurant.
Now that our go
and look
commands are working, it becomes a trivial matter to fill out the rest of the world map. We can just add new entries to the world_locations
table, and make sure that their directions
tables correctly list the possible directions they can travel, and where they will end up:
world_locations = {
alley = {
description = "You stand in the alley beside Stranger Wings chicken restaurant.",
directions = {
east = "kitchen",
down = "west_basement",
},
},
kitchen = {
description = "You are in the Stranger Wings' restaurant kitchen.",
directions = {
north = "dining",
east = "storage",
south = "freezer",
west = "alley",
down = "east_basement",
},
},
west_basement = {
description = "You are in the Stranger Wings' basement.",
directions = {
east = "east_basement",
},
},
east_basement = {
description = "You are in the Stranger Wings' basement.",
directions = {
west = "west_basement",
up = "kitchen",
},
},
dining = {
description = "You stand among the tables and chairs of the Stranger Wings' dining area.",
directions = {
south = "kitchen",
},
},
storage = {
description = "You are in a storage room surrounded by shelves.",
directions = {
west = "kitchen",
},
},
freezer = {
description = "You stand in a walk-in freezer, it is very cold. " ..
"One might even say freezing.",
directions = {
north = "kitchen",
},
},
}
Code So Far
world_locations = {
alley = {
description = "You stand in the alley beside Stranger Wings chicken restaurant.",
directions = {
east = "kitchen",
down = "west_basement",
},
},
kitchen = {
description = "You are in the Stranger Wings' restaurant kitchen.",
directions = {
north = "dining",
east = "storage",
south = "freezer",
west = "alley",
down = "east_basement",
},
},
west_basement = {
description = "You are in the Stranger Wings' basement.",
directions = {
east = "east_basement",
},
},
east_basement = {
description = "You are in the Stranger Wings' basement.",
directions = {
west = "west_basement",
up = "kitchen",
},
},
dining = {
description = "You stand among the tables and chairs of the Stranger Wings' dining area.",
directions = {
south = "kitchen",
},
},
storage = {
description = "You are in a storage room surrounded by shelves.",
directions = {
west = "kitchen",
},
},
freezer = {
description = "You stand in a walk-in freezer, it is very cold. " ..
"One might even say freezing.",
directions = {
north = "kitchen",
},
},
}
player_location = "alley"
function split_words(str)
local t = {}
local function helper(word)
table.insert(t, word)
end
string.gsub(str, "%S+", helper)
return t
end
function command_look()
local location = world_locations[player_location]
print(location.description)
end
function command_go(direction)
local location_info = world_locations[player_location]
local destination = location_info.directions[direction]
if destination ~= nil then
player_location = destination
print(string.format("You go %s", direction))
else
print(string.format("You can not go %s", direction))
end
end
function main_loop()
print("Welcome to Stranger Wings\n")
local game_over = false
while not game_over do
io.write("\nEnter command: ")
local player_command = string.lower(io.read())
local split_command = split_words(player_command)
local cmd = split_command[1]
if cmd == "quit" then
game_over = true
elseif cmd == "look" then
command_look()
elseif cmd == "go" then
local where = split_command[2]
command_go(where)
end
end
end
main_loop()
On To Part II #
This section is getting quite long, so we’ll finish the game in Part II, where we will implement items, objects, and the rules that control where the player can go based on what items they’ve collected.
Next - Stranger Wings II