Adventure.lua

Lua Text Adventure Engine

View the Project on GitHub shawndumas/adventure.lua

Important: If you haven't played one of the example adventures yet go ahead and mess around with xample land now. If you don't have Lua installed yet and you are on windows what you want is Lua for Windows. For the mac, install Homebrew, and then do "brew install lua" from the terminal. For linux... well, you probably already know what to do if you are rocking a linux distro.

State + Event --> State ==> Invocation

The main dispatch table for any adventure program will look something like this:

locations = makeFSM({
    { 'room00', 'examine', 'room00', fruitless_examination },
    { 'room00', 'north',   'room01', room00_north_room01 },
    { 'room01', 'examine', 'room01', room01_examine_room01 },
    { 'room01', 'north',   'room02', room01_north_room02 },
    { 'room01', 'south',   'room00', room01_south_room00 },
    { 'room02', 'examine', 'room02', fruitless_examination },
    { 'room02', 'south',   'room01', room02_south_room01 },
    -- default starting area
    { 'start',  'begin',   'room00', start_begin_room00 }
})

The four fields in each of the rows are:

  1. the initial state (the location the player started in)

  2. an event (the command the player chooses)

  3. the new state (the location the player will end up at)

  4. the function that gets invoked

Or stated another way:

[initial state] + [an event] --> [new state] ==> [invocation]

During the main loop the player is repeatedly asked to choose a command. If a player were in the state [room00], ie. the location 'room00', and chose the event [north], ie. the command North, then the dispatch row that matches will have its corresponding function invoked.

The matching dispatch row would look like this:

{ 'room00', 'north', 'room01', room00_north_room01 }

That function will be invoked with two parameters; the event [north], which was the command chosen by the player, and the new state [room01], which is the player's new location.

Let's see what that particular function might look like:

local function room00_north_room01 (event, state)
    return function ()
        gbl.description = "the description of room01..."
        gbl.options = {
            s = 'Go South; back to room00 (whence we came)'
        }
        return state
    end
end

(You may have noticed that the function returns an anonymous function. And that the inner function returns the state that the location creation function passes in. If that's confusing don't worry about it for now. Especially if all you want to do is make new adventures. All we are concerned with now is that inner function.)

The inner function does three things:

  1. the setting of the property gbl.description to a description of the now current location

  2. the setting of the property gbl.options with new commands that are now available to the player

  3. returning the state which is now the location called [room01]

Remember, the process is:

[initial state] + [an event] --> [new state] ==> [invocation]

In doing it this way one can do things other than just moving from location to location. For example look at the rows that include the event [examine]:

{ 'room00', 'examine', 'room00', fruitless_examination },
{ 'room01', 'examine', 'room01', room01_examine_room01 },
{ 'room02', 'examine', 'room02', fruitless_examination },

The player starts in a location, chooses the event [examine] and they end up in the same location. Let's see what that particular function might look like:

function room01_examine_room01 (event, state)
    return function ()
        if not detectinventoryitem('someitem') then
            print(wrap('\n\nYou discover some item.')
            insertinventoryitem('someitem')
        else
            print('\nYour examination is fruitless.\n')
        end
        entertocontinue()
        return state
    end
end

This function starts out by looking in the player's inventory. If the player hasn't already found the item 'someitem' it prints a message indicating that that item was found and then performs an insertion of that item into the player's inventory. If the player already had that item then the function prints a message indicating that nothing was found. It then returns the same state, the location [room01], so that the player will remain in the location in which the event [examine] was chosen as a command by the player.

Remember, we used the same dispatch table to process the two events [north] and [examine], two very different events, in order to move the player [north] and to process the command [examine]. This flexibility allows the programmer to create new events at will. And not just those already listed. Take, for example, an infinite location. Here, first, the dispatch table rows:

{ 'meadow', 'south', 'meadow', meadow_south_meadow },
{ 'meadow', 'west',  'meadow', meadow_west_meadow },

And now the corresponding functions:

local function neverendingmeadow (event, state)
    return function ()
        gbl.description = "Flies; little tiny flies everywhere."
        gbl.options = {
            n = 'Go North; to the clearing',
            s = 'Go South; continuing in to the meadow',
            w = 'Go West; continuing in to the meadow'
        }
        if not detectinventoryitem('the_small_fly') then
            insertcommand('c', 'catch')
            gbl.options.c = 'Catch one; if you can'
        end
        return state
    end
end

local function meadow_south_meadow (event, state)
    return neverendingmeadow(event, state)
end

local function meadow_west_meadow (event, state)
    return neverendingmeadow(event, state)
end

Notice that the player will never leave the [meadow] by going [south] or [west]. Notice also that we inserted a new event into the table of possible commands (if an inventory item was not detected, that is). If the new event [catch], an ad hoc command, is chosen in the state [meadow], a location, the adventure engine will index the dispatch looking for a row that looks like this:

{ 'meadow', 'catch', 'meadow', meadow_catch_meadow },

And invoke its corresponding function that might look like this:

local function meadow_catch_meadow (event, state)
    return function ()
        gbl.description = "The flies are fast and wary but you finally catch one."
        gbl.options = {
            n = 'Go North; to the clearing',
            s = 'Go South; continuing in to the meadow',
            w = 'Go West; continuing in to the meadow'
        }
        insertinventoryitem('the_small_fly')
        return state
    end
end

One could create any number of ad hoc events and states in this way. (Just be sure to delete the ad hoc event when the player leaves the associated state; like so.)

deletecommand('c')

So, as you see, having the dispatch table designed in the following manner...

[initial state] + [an event] --> [new state] ==> [invocation]

... affords amazing flexibility with a very simple to program mechanic.

Verb + Noun + Predicate + Noun ==> Invocation

The interaction of one item in the player's inventory against another item is called an action. Actions work by first creating a function to be invoked upon the player successfully applying one item to another using the verb(s) and predicate(s) you insert in the actions table.

Consider the following example:

local function makeaconundrum ()
    return function (t)
        return function ()
            local r = stringifyaction(t)
            r = r .. '\n\n[You have (somehow) gotten the square peg into the round hole. (Good job!)]'
            deleteinventoryitem({
                'the_proverbial_square_peg',
                'the_inevitable_round_hole',
            })
            insertinventoryitem('a_conundrum')
            return r
        end
    end
end

(Again you'll noticed that the function returns a function that returns another function. If that's confusing don't worry about it for now. Especially if all you want to do is make new adventures. All we are concerned with now are the two inner functions.) The first inner function is passed a table (here called 't') that consistent of the verb, the first noun, the predicate, and the second noun that were chosen by the user. This table is accessible to the innermost function and, in this example, is simply passed to the function called 'stringifyaction' and the return value is captured by 'r'.

The function 'stringifyaction' turns the table in to a sentence much like the following:

You put the proverbial square peg in the inevitable round hole.

(Notice that the underscores are replaced with spaces.) That's just the verb, the first noun, the predicate, and the second noun selected by the player.

After the capture, the innermost function appends the success message, deletes some inventory items, and then adds an inventory item. The innermost function finally returns a string (here called 'r'). The value of 'r' is, in this case:

You put the proverbial square peg in the inevitable round hole.

[You have (somehow) gotten the square peg into the round hole. (Good job!)]

In order for the player to eventually cause the invocation of that function they must complete an inventory action that you create. Let's look at one that uses the discussed function now:

insertaction(
    actions,
    {
        verbs = {
            'drop',
            'push',
            'put',
        },
        nouns = {
            first = { 'the_proverbial_square_peg' },
            second = { 'the_inevitable_round_hole' }
        },
        predicates = {
            'in',
        }
    },
    makeaconundrum()
)

What's going on here is the insertion of an action in to the table of actions available to the player (assuming they have the specified items in their inventory of course). The first parameter is the table that the action will be inserted in to. The second is a table of tables that declare which verb-noun-predicate-noun choices will trigger the invocation of the third parameter, a function. In this case the player could pick any of the following:

drop -- the_proverbial_square_peg -- in -- the_inevitable_round_hole
push -- the_proverbial_square_peg -- in -- the_inevitable_round_hole
put  -- the_proverbial_square_peg -- in -- the_inevitable_round_hole

Either of the nouns' tables can have more than one item. The end result is that the action can start with either noun and then end with the other. See the following:

nouns = {
    first = {
        'the_rock',
        'the_stone'
    },
    second = {
        'the_rock',
        'the_stone'
    }

In the previous instance if the player started with the rock then the second list provided to the player will not contain the rock but will contain the stone. Conversely, if the player started with the stone then the stone would be excluded and the rock would be listed in the second list of nouns. As in:

hit -- the_rock  -- on -- the_stone
hit -- the_stone -- on -- the_rock

Or, like the following nouns table, where the noun in the first table can be applied successfully to any of the nouns in the second table:

nouns = {
    first = { 'the_small_fly' },
    second = {
        'the_fishing_rod',
        'the_hook_shaped_bone',
        'the_hook_and_vine'
    }
}

Incidentally here are the tables representing all possible verbs and predicates:

local allverbs = {
    'drop',
    'hit',
    'pull',
    'push',
    'put',
    'throw',
    'touch',
    'use',
}

local allpredicates = {
    'after',
    'apart_from',
    'at',
    'before',
    'in',
    'on',
    'over',
    'to',
    'with',
    'under',
}

Settings, settings, and settings

The first set of settings are a table called game used by the adventure engine to determine certain default values. The following is an example game table (from xampleLand.lua)

game = {
  done = false,
  stop = false,
  filename = 'xampleLand.save.txt',
  defaultname = 'Friend',
  introtext = wrap("\nWelcome {name}, This is an example adventure. Not much fun as a game though, sorry.")
}

The sections are;

  1. done: Has the player died or won.
  2. stop: end the main loop exiting the game.
  3. filename: the name of the save-file for this adventure.
  4. defaultname: What the player's name will be if they opt not to supply one.
  5. introtext: The text that gets display initially. (Notice the use of the helper function wrap in the above example.)

Rounding out a tour of a minimal adventure is the invocation of the go function. Two tables need to be passed to the go function. The first is a table containing the settings for the adventure proper. The following is an example settings table (from xampleLand.lua):

local settings = {
  name = nil,
  roomswithenemies = {
    'room01',
    'room02'
  },
  commands = {
    n = 'north',
    s = 'south',
    e = 'east',
    w = 'west',
    x = 'examine'
  },
  enemytypes = {
    'tiny_drone',
    'small_drone',
    'drone',
    'large_drone'
  },
  inventory = {
    'the_inevitable_round_hole'
  },
  conditions = {
    timesinroom00 = 1
  }
}

(Note: This table, with a few additional values, is the table that is used to create a save file.)

The sections are;

  1. name: The name that the player choose when prompted.
  2. roomswithenemies: What rooms can an enemy be in. (Note: enemies will be in one of these rooms each turn.)
  3. commands: for command translation from single letter to full command.
  4. enemytypes: The names for the various enemies, from weakest to strongest (only four unless you make cfg.enemy.maxhp > 4).
  5. inventory: The hero's items, stick items that you want the player to have at game start.
  6. conditions: For recording author configurable states, events, and conditions.

The second table is for the fighting sub-engine. The following is an example fight settings table (from xampleLand.lua):

local fightsettings = {
  hero = {
    hitmin = 3,
    hitmax = 5,
    tohit = 5
  },
  enemy = {
    hitmin = 2,
    hitmax = 7,
    mintohit = 4,
    maxtohit = 5,
    minhp = 1,
    maxhp = 4,
    hitmod = 3
  }
}

The sections are;

  1. hero: Settings for the player...
    1. hitmin: Where the player start out at. (As in, heroattack = math.random(cfg.hero.hitmin, cfg.hero.hitmax).)
    2. hitmax: The maximum amount of bad-ass-ness the player can be raised to. (As in, heroattack = math.random(cfg.hero.hitmin, cfg.hero.hitmax).)
    3. tohit : What the hero has to beat to hit the enemy. (As in, heroattack > cfg.hero.tohit.)
  2. enemy: Settings for the player's enemies...
    1. hitmin: The first number passed to math.random when determining a hit. (As in, enemyattack = math.random(cfg.enemy.hitmin, cfg.enemy.hitmax).)
    2. hitmax: The second number passed to math.random when determining a hit. (As in, enemyattack = math.random(cfg.enemy.hitmin, cfg.enemy.hitmax).)
    3. mintohit: The min of what an enemy has to beat to hit the hero (the enemy is savage)
    4. maxtohit: The max of what an enemy has to beat to hit the hero (the enemy is menacing)
    5. minhp: The min hit points for an enemy (this matches the enemytypes).
    6. maxhp: The max hit points for an enemy (this matches the enemytypes).
    7. hitmod: The difference between the maxtohit and the two types of enemies (menacing, savage)

Check out this very small example adventure