Game: Stranger Wings II

Stranger Wings Part II #

In Part I of Stranger Wings we set up a game world and commands letting the player move around. Now we’ll need to add some more data to define the items and objects in the game, and commands for interacting with them.

Items #

Items in Stranger Wings will expand on the ideas from Infinite Treasure II. We’ll have a table that contains information about the kinds of items in the game, and an inventory to store items that the player has picked up.

The items we need are: pants, cooked wings, frozen chicken, and a crowbar.

Item Data #

In Infinite Treasure II the loot data was a simple array of strings, but for Stranger Wings we’ll use another table of tables that can contain both the names of items and their descriptions.

items = { 
  pants = {
    description = "Your pants. How did you manage to lose these in the first place?"
  },
  wings = {
    description = "Freshly cooked Stranger Wings fried chicken wings."
  },
  chicken = {
    description = "A bag of frozen Stranger Wings chicken wings, ready to be cooked."
  },
  crowbar = {
    description = "It's a crowbar, for opening things."
  },
}

And we’ll start off with the same empty array for the player’s inventory.

inventory = {}

You might notice that I’ve used single words for each of the items, “chicken” instead of “frozen chicken” and so on.

This is a bit of a cheat to make parsing player input easier. Handling player commands gets a little more complicated if the items can contain spaces, so I’m dodging the issue for now by using single words. You can describe this to your players as a “technical limitation of the engine”, nobody can blame you for that!

Items In The World #

The player can aquire items in this game in two different ways. They can find them lying around in the enviroment, and simply pick them up. And second, they can use an item on an object, which will remove that item from their inventory, and give them a different item in return.

We’ll deal with picking up items first, and then handle the second case when we implement objects.

First we’ll need to place the items in the world, so we’ll add an array of items to the locations in the world that will have items.

Add the crowbar to the East Basement location:

  east_basement = {
    description = "You are in the Stranger Wings' basement.",
    directions = { 
      west = "west_basement",
      up = "kitchen",
    },
    items = { "crowbar" },
  },

And add the chicken to the walk in freezer:

  freezer = {
    description = "You stand in a walk-in freezer, it is very cold. " .. 
      "One might even say freezing.",
    directions = { 
      north = "kitchen",
    },
    items = { "chicken" },
  },

Note that these strings eg. "crowbar" need to exactly match the names of the items in the items table, since these will be used to look up the descriptions from that table.

Finding Items #

Now there are items in the world, but they aren’t doing much until the player can find and interact with them.

To let the player discover items in the world, we’ll list the items in the current location when the player uses the LOOK command.

function command_look()
  local location = world_locations[player_location]
  print(location.description)
  if location.items ~= nil and #location.items > 0 then
    print("You see the following items:")
    for i,item_name in ipairs(location.items) do
      print(i, item_name)
    end
  end
end

First we check to make sure that the items value in the current location table is not equal to nil, and also that it contains more than zero items. Remember that the # operator will tell us the length of the array location.items.

Then we loop over the array using the ipairs() function provided by Lua.

ipairs() is similar to the the pairs() function we’ve used previously, but with a few important differences:

  1. ipairs() only works on arrays
  2. ipairs() will ensure that the table entries are returned in numerical order. pairs() can return they key value pairs in any order.
Arrays vs. Maps

Remember that arrays look like this:

loot_data = { "coins", "old boots", "sandwiches", "racoons" }

Each entry is listed in order, and there are no key names.

Whereas map style tables look like this:

directions = { 
  east = "kitchen",
  down = "west_basement",
},

Each entry has a key name which is assigned to a value.

pairs() will work on both types of table, but does not guarantee that the items are returned in any particular order.

ipairs() will only work on array type tables, but will guarantee that the items are returned in order.

This is all well and good, but the output is a bit technical looking:

Enter command: look
You are in the Stranger Wings' basement.
You see the following items:
1       crowbar

It would be nicer to print out the name and description of each item. We already have the name, and we can look up the item from the global items table, and print the description value that it contains:

function command_look()
  local location = world_locations[player_location]
  print(location.description)
  if location.items ~= nil and #location.items > 0 then
    print("You see the following items:")
    for i,item_name in ipairs(location.items) do
      local item = items[item_name]
      print(string.format("  %s: %s", item_name, item.description))
    end
  end
end

And now our item listing is more descriptive and nicely formatted:

Enter command: look
You are in the Stranger Wings' basement.
You see the following items:
  crowbar: It's a crowbar, for opening things.

Picking Up Items #

In order to pick up items from the environment we’ll implement the TAKE command. If the player types take crowbar, when we’ll check to see if there is a crowbar in the room, and if there is, add it to our inventory, and remove it from the room’s list of items.

Let’s start by adding the new command to main_loop():

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)
    elseif cmd == "take" then
      local what = split_command[2]
      command_take(what)
    end
  end
end

Now we can write the new function command_take(), but we’ll do it in a couple of steps.

First of all, we’ll need to do some error checking, and give the player some appropriate feedback:

function command_take(item_name)
  if item_name == nil then
    print("What do you want to take?")
    return
  end

  local location_info = world_locations[player_location]

  if location_info.items == nil or #location_info.items == 0 then
    print("There's nothing there to take.")
    return
  end

  -- we're not finished yet
end

If they just type take without specifying what to take, then item_name will be nil, in this case, we print out a message asking them what they want to take, and then return from the function, so the rest of the function won’t be processed.

Next we look up the location_info table for the current location, and check if the items table in that location is nil (which it will be for some of the locations), or if it has no items in it, which will be the case once we’ve picked up all of the items in that location. In either case, we can just tell the user, there are no items available to be taken.

If you run the game now, you should receive appropriate messages when attempting to take nothing, or take something when there is nothing present:

Welcome to Stranger Wings


Enter command: take
What do you want to take?

Enter command: take crowbar
There's nothing there to take.

Finding Things In Arrays #

Now to actually handle taking the items from the location. Our first step will be to check and see if the item the user has requested is in the items array of the current location. How should we do that?

If this table was a map, we could check location_info.items[item_name] to see if that key was present and had a value. But it’s not a map, it’s an array.

In this case, we need to search the array to find the index of the item we’re looking for. Lua doesn’t provide a build in function for one, but it’s easy to make one:

function find_index_of(t, value)
  for i, v in ipairs(t) do
    if v == value then
      return i
    end
  end
end

We loop through the array using ipairs(), and check if the value at each index matches the one we’re looking for. If it does, we return the index, and if nothing is found, the function exists without returning anything, the caller will receive nil as the return value.

Now we can implement the rest of command_take():

function command_take(item_name)
  if item_name == nil then
    print("What do you want to take?")
    return
  end

  local location_info = world_locations[player_location]

  if location_info.items == nil or #location_info.items == 0 then
    print("There's nothing there to take.")
    return
  end

  local item_index = find_index_of(location_info.items, item_name)

  if item_index == nil then
    print(string.format("There is no %s here.", item_name))
    return
  end

  table.remove(location_info.items, item_index)
  table.insert(inventory, item_name)

  print(string.format("You take the %s", item_name))
end

We call find_index_of() to search the location_info.items array for the value in item_name. If the return value is nil, it isn’t in the array, and we print an appropriate message.

If we do find the index of the requested item, we remove it from the location’s items table using the build in function table.remove() and pass in the index of the item to remove.

Then we append the item_name value into our inventory array, calling the built in function table.insert().

It’s also possible to pass a position into table.insert() to specify the location you want to insert the value at, eg. table.insert(inventory, 1, item_name) would insert the value at the beginning of the array, and move any other items down to make room. But calling it without specifying a position will append the value to the end.

Listing Our Inventory #

While we’re at it, let’s also add an INVENTORY command, to let the player look at the items they are currently carrying.

Add the inventory command to the main_loop():

-- ...

    elseif cmd == "take" then
      local what = split_command[2]
      command_take(what)
    elseif cmd == "inventory" then
      command_inventory()
    end

-- ...

And implement command_inventory():

function command_inventory()
  print(string.format("You are carrying %d items", #inventory))
  for i, item_name in ipairs(inventory) do
    local item = items[item_name]
    print(string.format("  %s: %s", item_name, item.description))
  end
end

Since we’re using inventory as an array, we can use the # operator to print how many items it contains.

Then, similar to the LOOK command, we loop over the items in the player’s inventory, and print the name and description of each item, by looking up the description in the global items table.

Now we can walk around the world, look for and pick up items, and list the items in our inventory:

Welcome to Stranger Wings


Enter command: go down
You go down

Enter command: go east
You go east

Enter command: look
You are in the Stranger Wings' basement.
You see the following items:
  crowbar: It's a crowbar, for opening things.

Enter command: take crowbar
You take the crowbar

Enter command: look
You are in the Stranger Wings' basement.

Enter command: inventory
You are carrying 1 items
  crowbar: It's a crowbar, for opening things.

Enter command: go up
You go up

Enter command: go south
You go south

Enter command: look
You stand in a walk-in freezer, it is very cold. One might even say freezing.
You see the following items:
  chicken: A bag of frozen Stranger Wings chicken wings, ready to be cooked.

Enter command: take chicken
You take the chicken

Enter command: inventory
You are carrying 2 items
  crowbar: It's a crowbar, for opening things.
  chicken: A bag of frozen Stranger Wings chicken wings, ready to be cooked.

Don’t Repeat Yourself #

In fact, the loop to print out the items in command_inventory() is nearly identical to the code in command_look(), the only difference is the name of the array that we’re printing out. This is an opportunity to create a helper function that prints an item list, and we can call that function from both places.

We can create a function called print_items() which takes an array as a parameter:

function print_items(item_array)
  for i, item_name in ipairs(item_array) do
    local item = items[item_name]
    print(string.format("  %s: %s", item_name, item.description))
  end
end

And now we can simplify both command_inventory() and command_look() to call print_items() to do the actual listing, passing in which array we want to list:

function command_inventory()
  print(string.format("You are carrying %d items", #inventory))
  print_items(inventory)
end
function command_look()
  local location = world_locations[player_location]
  print(location.description)
  if location.items ~= nil and #location.items > 0 then
    print("You see the following items:")
    print_items(location.items)
  end
end
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",
    },
    items = { "crowbar" },
  },
  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",
    },
    items = { "chicken" },
  },
}

items = { 
  pants = {
    description = "Your pants. How did you manage to lose these in the first place?"
  },
  wings = {
    description = "Delicious Stranger Wings fried chicken wings."
  },
  chicken = {
    description = "A bag of frozen Stranger Wings chicken wings, ready to be cooked."
  },
  crowbar = {
    description = "It's a crowbar, for opening things."
  },
}

player_location = "alley"
inventory = {}

function split_words(str)
  local t = {}
  local function helper(word)
    table.insert(t, word)
  end
  string.gsub(str, "%S+", helper)
  return t
end

function find_index_of(t, value)
  for i, v in ipairs(t) do
    if v == value then
      return i
    end
  end
end

function print_items(item_array)
  for i, item_name in ipairs(item_array) do
    local item = items[item_name]
    print(string.format("  %s: %s", item_name, item.description))
  end
end

function command_look()
  local location = world_locations[player_location]
  print(location.description)
  if location.items ~= nil and #location.items > 0 then
    print("You see the following items:")
    print_items(location.items)
  end
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 command_take(item_name)
  if item_name == nil then
    print("What do you want to take?")
    return
  end

  local location_info = world_locations[player_location]

  if location_info.items == nil or #location_info.items == 0 then
    print("There's nothing there to take.")
    return
  end

  local item_index = find_index_of(location_info.items, item_name)

  if item_index == nil then
    print(string.format("There is no %s here.", item_name))
    return
  end

  table.remove(location_info.items, item_index)
  table.insert(inventory, item_name)

  print(string.format("You take the %s", item_name))
end

function command_inventory()
  print(string.format("You are carrying %d items", #inventory))
  print_items(inventory)
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)
    elseif cmd == "take" then
      local what = split_command[2]
      command_take(what)
    elseif cmd == "inventory" then
      command_inventory()
    end
  end
end

main_loop()

Objects #

In this game an “object” will be some object in the environment that can’t be picked up, but can be interacted with by using an item on the object, and then receiving a new item in return.

Object Data #

We’ll set up two objects, the deep fryer (which accepts frozen chicken and returns fried wings), and Bitey the cat (who accepts fried wings and returns your pants). The fact that Bitey is presumably a living creature while the deep freezer is not, doesn’t really make any difference for the sake of how we’re going to handle them on the programming side.

We’ll define objects in a similar way to items, but in addition to having a description, we’ll also need to describe what items they will accept, and what items they will return in exchange. We can also include a message that gets printed to describe the action taking place during the exchange.

objects = {
  deepfryer = {
    description = "A deep fryer, familiar from your many days spent frying Stranger Wings " .. 
      "brand chicken wings.",
    accept_items = {
      chicken = {
        message = "You toss the frozen chicken into the fryer, " ..
          "and skilfully prepare a serving of Stranger Wings.",
        give_item = "wings",
      }
    }
  },
  bitey = {
    description = "The restaurant owner's cat Bitey. " ..
      "Bitey seems to have more eyes than is usually expected, and sits purring comfortably " .. 
        "on your pants.",
    accept_items = {
      wings = {
        message = "You offer some delicious Stranger Wings to Bitey. " ..
          "The cat happily accepts, and jumps off your pants to eat the wings. " .. 
          "You deftly grab your pants while Bitey is distracted.",
        give_item = "pants",
      }
    }
  }
}

We’ll also need to add tables to indicate which objects are present in which locations. The deep fryer goes in the kitchen location, and Bitey goes in the storage room:

world_locations = {
  -- ...
  kitchen = {
    description = "You are in the Stranger Wings' restaurant kitchen.",
    directions = { 
      north = "dining",
      east = "storage",
      south = "freezer",
      west = "alley",
      down = "east_basement",
    },
    objects = { "deepfryer" },
  },
  -- ...
  storage = {
    description = "You are in a storage room surrounded by shelves.",
    directions = { 
      west = "kitchen",
    },
    objects = { "bitey" },
  },
  -- ...
}

Here we added an objects array to each location, containing the list of objects present. Currently there’s only one, but using an array lets you easily add more later if you want to.

Finding Objects #

Like items, we’ll need a way for the player to discover what objects are in each location. And similar to items, we can include the description of the objects when the player uses the LOOK command.

We can define a new function to print out objects instead of items, by looking up the object description from the global objects table:

function print_objects(object_array)
  for i, object_name in ipairs(object_array) do
    local object = objects[object_name]
    print(string.format("  %s: %s", object_name, object.description))
  end
end

And then call our new print_objects() from command_look(), if there are any objects in the current location:

function command_look()
  local location = world_locations[player_location]
  print(location.description)
  if location.items ~= nil and #location.items > 0 then
    print("You see the following items:")
    print_items(location.items)
  end
  if location.objects ~= nil and #location.objects > 0 then
    print("You can see:")
    print_objects(location.objects)
  end
end

You should now be able to see the objects listed in their respective locations when looking around the environment:

Enter command: look
You are in the Stranger Wings' restaurant kitchen.
You can see:
  deepfryer: A deep fryer, familiar from your many days spent frying Stranger Wings brand chicken wings.

Enter command: go east
You go east

Enter command: look
You are in a storage room surrounded by shelves.
You can see:
  bitey: The restaurant owner's cat Bitey. Bitey seems to have more eyes than is usually expected, 
and sits purring comfortably on your pants.

Using Items On Objects #

Using things on other things is a core mechanic of adventure games.

To use an item with an object, we’ll add a command in the format USE X ON Y, where X is the name of the item, and Y is the name of the object, eg. use chicken on deepfryer. The “on” isn’t really necessary, but it does make the command sound more natural.

We can even easily support some variations, like put chicken in deepfryer, and give wings to bitey by checking for a few different verbs as the command, and then just ignoring the 3rd word entirely. You can go through the extra steps to verify that the 3rd word makes sense according to the command used, if you want to.

As it is, the player could type something like use chicken monkey deepfryer and it would still work, which may not be ideal, but that’s open to interpretation.

function main_loop()
  -- ...
    elseif cmd == "inventory" then
      command_inventory()
    elseif cmd == "use" or cmd == "give" or cmd == "put" then
      local item = split_command[2]
      -- ignore the 3rd word
      local object = split_command[4]
      command_use(item, object)
    end
  -- ...

This checks if cmd equals “use” or “give” or “put”, ignores whatever the 3rd word is, and passes the item and object names to the function command_use().

function command_use(item_name, object_name)
  if item_name == nil or object_name == nil then
    print("Use what on what?")
    return
  end

  local item_index = find_index_of(inventory, item_name)
  if item_index == nil then
    print(string.format("I don't have a %s.", item_name))
    return
  end

  local location_info = world_locations[player_location]
  if location_info.objects == nil or #location_info.objects == 0 then
    print("There's nothing to use that on here.")
    return
  end

  local object_index = find_index_of(location_info.objects, object_name)  
  if object_index == nil then
    print(string.format("There's no %s here.", object_name))
    return
  end

  local object_info = objects[object_name]
  local accept_info = object_info.accept_items[item_name]
  if accept_info == nil then
    print(string.format("I can't use %s on %s.", item_name, object_name))
    return
  end

  table.remove(inventory, item_index)
  table.insert(inventory, accept_info.give_item)

  print(accept_info.message)
end

The majority of the code in command_use() is to determine whether the user can actually perform the command they have requested.

  • If either item_name or object_name are nil then the user didn’t provide the item or object names.
  • If item_index is nil, the the given item name wasn’t found in our inventory.
  • If location_info.objects is nil, or has a length of zero, then there are no objects in the current location to interact with.
  • If object_index is nil, the given object name wasn’t found in the current location’s objects array.
  • If accept_info is nil, the object’s accept_items table doesn’t contain the given item, and so it shouldn’t accept it.

If we pass all of those checks, we should be able to perform the requested action. We can remove the used item that we found in out inventory by calling table.remove() and specifying its index which we found. Then we add the newly received item by calling table.insert() and specifying the value to append to the end of the array.

Note that there are at least two other variables that could potentially be nil: we didn’t check for either location_info or object_info being nil before using them.

player_location should only ever be set to a valid location name, so location_info should always be valid.

Similarly, location_info.objects should only list valid objects that exist in the objects table, and we checked object_index already to make sure that the specified object was indeed listed in the current location.

If either of these values ends up being nil that’s a bug and the game will crash! Maybe the missing data needs to be added, or there could be a typo leading to a mismatch.

At this point, you should be able to walk around the environment, pick up all of the items, and use them on the objects that require them in order to receive a different item in exchange:

Enter command: look
You stand in a walk-in freezer, it is very cold. One might even say freezing.
You see the following items:
  chicken: A bag of frozen Stranger Wings chicken wings, ready to be cooked.

Enter command: take chicken
You take the chicken

Enter command: go north
You go north

Enter command: look
You are in the Stranger Wings' restaurant kitchen.
You can see:
  deepfryer: A deep fryer, familiar from your many days spent frying Stranger Wings brand chicken wings.

Enter command: put chicken in deepfryer
You toss the frozen chicken into the fryer, and skilfully prepare a serving of Stranger Wings.

Enter command: inventory
You are carrying 1 items
  wings: Delicious Stranger Wings fried chicken wings.

Enter command: go east
You go east

Enter command: look
You are in a storage room surrounded by shelves.
You can see:
  bitey: The restaurant owner's cat Bitey. Bitey seems to have more eyes than is usually expected, and 
  sits purring comfortably on your pants.

Enter command: give wings to bitey
You offer some delicious Stranger Wings to Bitey. The cat happily accepts, and jumps off your pants to 
eat the wings. You deftly grab your pants while Bitey is distracted.

Enter command: inventory
You are carrying 1 items
  pants: Your pants. How did you manage to lose these in the first place?
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",
    },
    objects = { "deepfryer" },
  },
  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",
    },
    items = { "crowbar" },
  },
  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",
    },
    objects = { "bitey" },
  },
  freezer = {
    description = "You stand in a walk-in freezer, it is very cold. " .. 
      "One might even say freezing.",
    directions = { 
      north = "kitchen",
    },
    items = { "chicken" },
  },
}

items = { 
  pants = {
    description = "Your pants. How did you manage to lose these in the first place?"
  },
  wings = {
    description = "Delicious Stranger Wings fried chicken wings."
  },
  chicken = {
    description = "A bag of frozen Stranger Wings chicken wings, ready to be cooked."
  },
  crowbar = {
    description = "It's a crowbar, for opening things."
  },
}

objects = {
  deepfryer = {
    description = "A deep fryer, familiar from your many days spent frying Stranger Wings " .. 
      "brand chicken wings.",
    accept_items = {
      chicken = {
        message = "You toss the frozen chicken into the fryer, " ..
          "and skilfully prepare a serving of Stranger Wings.",
        give_item = "wings",
      }
    }
  },
  bitey = {
    description = "The restaurant owner's cat Bitey. " ..
      "Bitey seems to have more eyes than is usually expected, and sits purring comfortably " .. 
        "on your pants.",
    accept_items = {
      wings = {
        message = "You offer some delicious Stranger Wings to Bitey. " ..
          "The cat happily accepts, and jumps off your pants to eat the wings. " .. 
          "You deftly grab your pants while Bitey is distracted.",
        give_item = "pants",
      }
    }
  }
}

player_location = "alley"
inventory = {}

function split_words(str)
  local t = {}
  local function helper(word)
    table.insert(t, word)
  end
  string.gsub(str, "%S+", helper)
  return t
end

function find_index_of(t, value)
  for i, v in ipairs(t) do
    if v == value then
      return i
    end
  end
end

function print_items(item_array)
  for i, item_name in ipairs(item_array) do
    local item = items[item_name]
    print(string.format("  %s: %s", item_name, item.description))
  end
end

function print_objects(object_array)
  for i, object_name in ipairs(object_array) do
    local object = objects[object_name]
    print(string.format("  %s: %s", object_name, object.description))
  end
end

function command_look()
  local location = world_locations[player_location]
  print(location.description)
  if location.items ~= nil and #location.items > 0 then
    print("You see the following items:")
    print_items(location.items)
  end
  if location.objects ~= nil and #location.objects > 0 then
    print("You can see:")
    print_objects(location.objects)
  end
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 command_take(item_name)
  if item_name == nil then
    print("What do you want to take?")
    return
  end

  local location_info = world_locations[player_location]

  if location_info.items == nil or #location_info.items == 0 then
    print("There's nothing there to take.")
    return
  end

  local item_index = find_index_of(location_info.items, item_name)

  if item_index == nil then
    print(string.format("There is no %s here.", item_name))
    return
  end

  table.remove(location_info.items, item_index)
  table.insert(inventory, item_name)

  print(string.format("You take the %s", item_name))
end

function command_inventory()
  print(string.format("You are carrying %d items", #inventory))
  print_items(inventory)
end

function command_use(item_name, object_name)
  if item_name == nil or object_name == nil then
    print("Use what on what?")
    return
  end

  local item_index = find_index_of(inventory, item_name)
  if item_index == nil then
    print(string.format("I don't have a %s.", item_name))
    return
  end

  local location_info = world_locations[player_location]
  if location_info.objects == nil or #location_info.objects == 0 then
    print("There's nothing to use that on here.")
    return
  end

  local object_index = find_index_of(location_info.objects, object_name)  
  if object_index == nil then
    print(string.format("There's no %s here.", object_name))
    return
  end

  local object_info = objects[object_name]
  local accept_info = object_info.accept_items[item_name]
  if accept_info == nil then
    print(string.format("I can't use %s on %s.", item_name, object_name))
    return
  end

  table.remove(inventory, item_index)
  table.insert(inventory, accept_info.give_item)

  print(accept_info.message)
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)
    elseif cmd == "take" then
      local what = split_command[2]
      command_take(what)
    elseif cmd == "inventory" then
      command_inventory()
    elseif cmd == "use" or cmd == "give" or cmd == "put" then
      local item = split_command[2]
      -- ignore the 3rd word
      local object = split_command[4]
      command_use(item, object)
    end
  end
end

main_loop()

Restrictions Make The Game #

To finally put the puzzle part into the game, we’re going to need to add some more restrictions on where you can go and when.

Blocked Directions #

To start with, let’s properly block off the alley, so that you can’t enter the restaurant just by going through the door (that’s boring!), and provide some useful story telling if the player tries to go north out onto the street, as well as a generally more descriptive description of the alley itself.

We’ll add a new direction of travel for north and replace the string value for east with a table containing the key blocked, if this key exists, we’ll print out the contents of the message instead of letting the player go that way.

world_locations = {
  alley = {
    description = "You stand in the alley beside Stranger Wings chicken restaurant. " ..
      "To the east is the back door of the restaurant, to the north is the street. " ..
      "There's also a disgusting dumpster, which you can't seem to see the bottom of...",
    directions = { 
      north = {
        blocked = "The ally opens onto the street, but you're not about " ..
          "to go out there without your pants. Wait, where did your pants go?",
      },
      east = {
        blocked = "The back door to the Stranger Wings restaurant stands firmly locked. " ..
          "You won't be getting in this way.",
      },
      down = "west_basement",
    },
  },
  -- ...

Then in command_go() we’ll need to check the type of the destination value that we look up in the directions table. If it’s a string like we’ve been using so far then we’ll allow the player to go to that location as before; but if it’s a table, we’ll look for more information inside the table to see what we should do:

function command_go(direction)
  local location_info = world_locations[player_location]
  local destination = location_info.directions[direction]

  local dest_type = type(destination)
  if dest_type == "string" then
    player_location = destination
    print(string.format("You go %s", direction))
  elseif dest_type == "table" then
    if destination.blocked ~= nil then
      print(destination.blocked)
    end
  else
    print(string.format("You can not go %s", direction))
  end
end

For now the only thing the table should contain is the key blocked, and if it’s set, then we print its value and don’t change the player’s location.

Welcome to Stranger Wings


Enter command: look
You stand in the alley beside Stranger Wings chicken restaurant. To the east is the back door of the 
restaurant, to the north is the street. There's also a disgusting dumpster, which you can't seem to 
see the bottom of...

Enter command: go north
The ally opens onto the street, but you're not about to go out there without your pants. Wait, where did 
your pants go?

Enter command: go east
The back door to the Stranger Wings restaurant stands firmly locked. You won't be getting in this way.

Enter command: go down
You go down

This is all well and good for permanently blocked directions, but we’ll also want the player to travel in certain directions only if they have found a required item that allows them to do so.

Doors And Keys #

Ah, that classic game mechanic, finding keys for doors. It might be a literal key, or it might be a more general requirement of some kind, a pass code written on piece of paper, a magic crystal, or a crowbar.

We’ll implement key requirements in two places, one that requires the player to have the crowbar item in order to enter the walk-in freezer, and another that requires the player to have their pants in order to exit the restaurant, thus winning the game!

Instead of using the blocked key, we’ll add a new key called require to the south table in the kitchen, which will specify the item that needs to be in the player’s inventory before they can go in this direction.

If they don’t have the item, we’ll print the contents of the require_fail message, and if they do have the item, we’ll print the contents of the require_pass message, and then take them to the location specified by location.

  --...
  kitchen = {
    description = "You are in the Stranger Wings' restaurant kitchen.",
    directions = { 
      north = "dining",
      east = "storage",
      south = {
        require = "crowbar",
        require_fail = "The freezer door is frozen shut, it won't budge! " ..
          "If only you had something to pry it open with...",
        require_pass = "You use the crowbar to crack the ice around the freezer door " ..
          "and pry it open.",
        location = "freezer",
      },
      down = "east_basement",
    },
    objects = { "deepfryer" },
  },
  --...

Then modifying command_go() to handle the new data:

function command_go(direction)
  local location_info = world_locations[player_location]
  local destination = location_info.directions[direction]

  local dest_type = type(destination)
  if dest_type == "string" then
    player_location = destination
    print(string.format("You go %s", direction))
  elseif dest_type == "table" then
    if destination.blocked ~= nil then
      print(destination.blocked)
    elseif destination.require ~= nil then
      local item_index = find_index_of(inventory, destination.require)
      if item_index ~= nil then
        player_location = destination.location
        print(destination.require_pass)
      else
        print(destination.require_fail)
      end
    end
  else
    print(string.format("You can not go %s", direction))
  end
end

Now the player needs to pick up the crowbar before they can get into the freezer:

Enter command: go south
The freezer door is frozen shut, it won't budge! If only you had something to pry it open with...

Enter command: go down
You go down

Enter command: take crowbar
You take the crowbar

Enter command: go up
You go up

Enter command: go south
You use the crowbar to crack the ice around the freezer door and pry it open.

Enter command: look
You stand in a walk-in freezer, it is very cold. One might even say freezing.
You see the following items:
  chicken: A bag of frozen Stranger Wings chicken wings, ready to be cooked.

Winning #

At long last, the final piece of the puzzle, the player needs to have their pants in order to leave through the front door of the restaurant.

We’ll add the requirements to the dining location, and add a new key win to indicate that successfully going in this direction ends the game.

  -- ...
  dining = {
    description = "You stand among the tables and chairs of the Stranger Wings' dining area.",
    directions = { 
      north = {
        require = "pants",
        require_fail = "The front door opens out onto the street, but there's no way " ..
          "you're going out there without your pants! How embarrassing!",
        require_pass = "You've finally done it. Pants in hand, you confidently stride " ..
          "out the front door of the restaurant, hop on your bicycle, and ride " ..
          "off into the sunset.\n\nThe End.",
        win = true,
      },
      south = "kitchen",
    },
  },
  -- ...

All we need to do is check for the new win value in command_go():

function command_go(direction)
  local location_info = world_locations[player_location]
  local destination = location_info.directions[direction]

  local dest_type = type(destination)
  if dest_type == "string" then
    player_location = destination
    print(string.format("You go %s", direction))
  elseif dest_type == "table" then
    if destination.blocked ~= nil then
      print(destination.blocked)
    elseif destination.require ~= nil then
      local item_index = find_index_of(inventory, destination.require)
      if item_index ~= nil then
        print(destination.require_pass)
        if destination.win then
          game_over = true
        else
          player_location = destination.location
        end
      else
        print(destination.require_fail)
      end
    end
  else
    print(string.format("You can not go %s", direction))
  end
end

If destination.win is set, we set game_over to true so that our main_loop() will exit, instead of taking the player to a new location.

There’s one small problem though, currently game_over is a local variable inside of main_loop(), which means setting it inside of command_go() is actually setting a different, global variable with the same name, which isn’t the one that main_loop() is going to check.

To remedy this, find the line:

  local game_over = false

… in main_loop() and cut it, then paste it at the top of the file along with player_location and inventory so that this same game_over variable can be accessed from anywhere in the same file.

local game_over = false
local player_location = "alley"
local inventory = {}

Now when command_go() sets game_over to true, it’s accessing the same variable that is being used in main_loop(), which will cause main_loop() to exit.

Enter command: give wings to bitey
You offer some delicious Stranger Wings to Bitey. The cat happily accepts, and jumps off your pants to 
eat the wings. You deftly grab your pants while Bitey is distracted.

Enter command: inventory
You are carrying 2 items
  crowbar: It's a crowbar, for opening things.
  pants: Your pants. How did you manage to lose these in the first place?

Enter command: go west
You go west

Enter command: go north
You go north

Enter command: look
You stand among the tables and chairs of the Stranger Wings' dining area.

Enter command: go north
You've finally done it. Pants in hand, you confidently stride out the front door of the restaurant, hop 
on your bicycle, and ride off into the sunset.

The End.
Code So Far
world_locations = {
  alley = {
    description = "You stand in the alley beside Stranger Wings chicken restaurant. " ..
      "To the east is the back door of the restaurant, to the north is the street. " ..
      "There's also a disgusting dumpster, which you can't seem to see the bottom of...",
    directions = { 
      north = {
        blocked = "The ally opens onto the street, but you're not about " ..
          "to go out there without your pants. Wait, where did your pants go?",
      },
      east = {
        blocked = "The back door to the Stranger Wings restaurant stands firmly locked. " ..
          "You won't be getting in this way.",
      },
      down = "west_basement",
    },
  },
  kitchen = {
    description = "You are in the Stranger Wings' restaurant kitchen.",
    directions = { 
      north = "dining",
      east = "storage",
      south = {
        require = "crowbar",
        require_fail = "The freezer door is frozen shut, it won't budge! " ..
          "If only you had something to pry it open with...",
        require_pass = "You use the crowbar to crack the ice around the freezer door " ..
          "and pry it open.",
        location = "freezer",
      },
      down = "east_basement",
    },
    objects = { "deepfryer" },
  },
  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",
    },
    items = { "crowbar" },
  },
  dining = {
    description = "You stand among the tables and chairs of the Stranger Wings' dining area.",
    directions = { 
      north = {
        require = "pants",
        require_fail = "The front door opens out onto the street, but there's no way " ..
          "you're going out there without your pants! How embarassing!",
        require_pass = "You've finally done it. Pants in hand, you confidently stride " ..
          "out the front door of the restaurant, hop on your bicycle, and ride " ..
          "off into the sunset.\n\nThe End.",
        win = true,
      },
      south = "kitchen",
    },
  },
  storage = {
    description = "You are in a storage room surrounded by shelves.",
    directions = { 
      west = "kitchen",
    },
    objects = { "bitey" },
  },
  freezer = {
    description = "You stand in a walk-in freezer, it is very cold. " .. 
      "One might even say freezing.",
    directions = { 
      north = "kitchen",
    },
    items = { "chicken" },
  },
}

items = { 
  pants = {
    description = "Your pants. How did you manage to lose these in the first place?"
  },
  wings = {
    description = "Delicious Stranger Wings fried chicken wings."
  },
  chicken = {
    description = "A bag of frozen Stranger Wings chicken wings, ready to be cooked."
  },
  crowbar = {
    description = "It's a crowbar, for opening things."
  },
}

objects = {
  deepfryer = {
    description = "A deep fryer, familiar from your many days spent frying Stranger Wings " .. 
      "brand chicken wings.",
    accept_items = {
      chicken = {
        message = "You toss the frozen chicken into the fryer, " ..
          "and skilfully prepare a serving of Stranger Wings.",
        give_item = "wings",
      }
    }
  },
  bitey = {
    description = "The restaurant owner's cat Bitey. " ..
      "Bitey seems to have more eyes than is usually expected, and sits purring comfortably " .. 
        "on your pants.",
    accept_items = {
      wings = {
        message = "You offer some delicious Stranger Wings to Bitey. " ..
          "The cat happily accepts, and jumps off your pants to eat the wings. " .. 
          "You deftly grab your pants while Bitey is distracted.",
        give_item = "pants",
      }
    }
  }
}

local game_over = false
local player_location = "alley"
local inventory = {}

function split_words(str)
  local t = {}
  local function helper(word)
    table.insert(t, word)
  end
  string.gsub(str, "%S+", helper)
  return t
end

function find_index_of(t, value)
  for i, v in ipairs(t) do
    if v == value then
      return i
    end
  end
end

function print_items(item_array)
  for i, item_name in ipairs(item_array) do
    local item = items[item_name]
    print(string.format("  %s: %s", item_name, item.description))
  end
end

function print_objects(object_array)
  for i, object_name in ipairs(object_array) do
    local object = objects[object_name]
    print(string.format("  %s: %s", object_name, object.description))
  end
end

function command_look()
  local location = world_locations[player_location]
  print(location.description)
  if location.items ~= nil and #location.items > 0 then
    print("You see the following items:")
    print_items(location.items)
  end
  if location.objects ~= nil and #location.objects > 0 then
    print("You can see:")
    print_objects(location.objects)
  end
end

function command_go(direction)
  local location_info = world_locations[player_location]
  local destination = location_info.directions[direction]

  local dest_type = type(destination)
  if dest_type == "string" then
    player_location = destination
    print(string.format("You go %s", direction))
  elseif dest_type == "table" then
    if destination.blocked ~= nil then
      print(destination.blocked)
    elseif destination.require ~= nil then
      local item_index = find_index_of(inventory, destination.require)
      if item_index ~= nil then
        print(destination.require_pass)
        if destination.win then
          game_over = true
        else
          player_location = destination.location
        end
      else
        print(destination.require_fail)
      end
    end
  else
    print(string.format("You can not go %s", direction))
  end
end

function command_take(item_name)
  if item_name == nil then
    print("What do you want to take?")
    return
  end

  local location_info = world_locations[player_location]

  if location_info.items == nil or #location_info.items == 0 then
    print("There's nothing there to take.")
    return
  end

  local item_index = find_index_of(location_info.items, item_name)

  if item_index == nil then
    print(string.format("There is no %s here.", item_name))
    return
  end

  table.remove(location_info.items, item_index)
  table.insert(inventory, item_name)

  print(string.format("You take the %s", item_name))
end

function command_inventory()
  print(string.format("You are carrying %d items", #inventory))
  print_items(inventory)
end

function command_use(item_name, object_name)
  if item_name == nil or object_name == nil then
    print("Use what on what?")
    return
  end

  local item_index = find_index_of(inventory, item_name)
  if item_index == nil then
    print(string.format("I don't have a %s.", item_name))
    return
  end

  local location_info = world_locations[player_location]
  if location_info.objects == nil or #location_info.objects == 0 then
    print("There's nothing to use that on here.")
    return
  end

  local object_index = find_index_of(location_info.objects, object_name)  
  if object_index == nil then
    print(string.format("There's no %s here.", object_name))
    return
  end

  local object_info = objects[object_name]
  local accept_info = object_info.accept_items[item_name]
  if accept_info == nil then
    print(string.format("I can't use %s on %s.", item_name, object_name))
    return
  end

  table.remove(inventory, item_index)
  table.insert(inventory, accept_info.give_item)

  print(accept_info.message)
end

function main_loop()
  print("Welcome to Stranger Wings\n")

  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)
    elseif cmd == "take" then
      local what = split_command[2]
      command_take(what)
    elseif cmd == "inventory" then
      command_inventory()
    elseif cmd == "use" or cmd == "give" or cmd == "put" then
      local item = split_command[2]
      -- ignore the 3rd word
      local object = split_command[4]
      command_use(item, object)
    end
  end
end

main_loop()

Epilogue #

This is as far as I’ll take the text adventure Stranger Wings but there are still a lot of things that could be improved, so by all means, improve them!

The rooms could use better descriptions, and it would be useful to list the possible directions a player can go automatically instead of having to write them into the descriptions manually.

And there are some issues that could use fixing, for example Bitey the cat will continue to be described as sitting on your pants, even after the player has successfully retrieved them. You could add a variable to track the state of whether Bitey has your pants or not, and display an appropriate message.

You could create an entirely different story by replacing the data in world_locations, items, and objects with your own creations without even changing any of the rest of the code.

If you’ve stuck it out this far, congratulations! From now on, we’ll be focusing on more “modern” games, the kind with pictures and sounds.

Next - Love2D