æon

Coroutine-based story scripts in Lua

This idea is inspired by a post from Chevy Ray a while ago, describing a similar technique in C#.

Imagine you're working on a narrative game in Lua: let's say you're making an RPG or an adventure game, and you've chosen LöVE as your engine (good pick). That means the 'core' of your game – drawing, input, character movement, and so on – is gonna be written in Lua, but for a number of reasons it's helpful to keep a layer of separation between that code and 'story' code: the scripts that implement dialogue trees, bespoke object interactions, and cutscenes.

A lot of games achieve this type of separation using a domain-specific language like Ink or Yarn, which is great if there are writers on the team who already know those languages. But DSLs take some additional work to integrate, and have limitations that might or might not fit well with your project.

In a lot of cases it would be easier to work directly in Lua, as long as we can keep the code readable and maintain that division between 'story' and 'game' scripts. Fortunately, Lua has two useful facilities we can employ of here: one is coroutines, and the other is environments (sometimes called 'function environments'. Together, these let describe scenes expressively in Lua – almost like a DSL – without having to embed another interpreter.

Here's an example of what one of our dialogue scripts might look like, followed by the code that makes it work:

function archivist()
    image "archivist_neutral"

    text "The archivist regards you with suspicion as you approach."

    text [["I spoke to your associate earlier and told him everything I know. There isn't anything further I can do."]]

    option("Associate?", "archivist_associate")
    if flags.seenBody then
        option("Sorry to ask again, but do you know if anyone else has been in here today? The body shows signs of having been disturbed.", "archivist_body")
    end
    option("Thank you for your assistance.", "archives")
    menu()
end

function archivist_associate()
    image "archivist_frown"
    text [["Yes, the other templar. He spent a very long time looking at the body. Something about it seemed to displease him."]]
    text [[She thinks for a moment. "Aside from the obvious."]]

    option([["Of course. Just to be clear: can you tell me what this...other templar looked like? There are several of us working on the case, and I'll need to compare our findings."]], "archivist_description")
    option("I see. Thank you, that's all I need for now.", "archives")
    menu()
end

function archivist_description()
    --etc
end

This describes a small part of a character interaction, with each conversation branch modelled as a function. The system only has to expose a handful of capabilities – the text, image, option, and menu functions – to allow for a branching story with as much structural complexity as we want. Additionally, we have all of Lua available for implementing more complex logic or adding new capabilities; for example, if the system exposes require to scripts, then it's easy to split the story up across multiple files.

Here's the code we need to make this work on the engine side:

--[[
External functions that need to be defined to make use of this (how these are implemented will depend on your environment):
    - showText(text) --display some text
    - showImage(name) --display an image
    - displayOption(text, i, target) --display a selectable option

    Additionally, you will need to call run() from somewhere and provide a facility for handling user input.
]]

local env = {}
local options = {}
local co = nil
local storyFile = "story.lua"
local entryPoint = "start"

function loadChunk(path, env) --This loads a story script and sets up the environment
    local chunk
    if love then --If we're running in LöVE
        chunk = setfenv(love.filesystem.load(path), env)
    elseif setfenv then --If we're running in another Lua 5.1 environment (eg LuaJIT)
        chunk = setfenv(loadfile(path), env)
    else --If we're running in a more recent version of Lua
        chunk = loadfile(path, path, env)
    end
    chunk()
end

--This function should be called at initialisation time
function run(storyFile) --storyFile is a string containing the name of a lua script
    env = {
        text = text,
        image = image,
        jump = jump,
        option = option,
        menu = menu,
        flags = {},
    }
    setmetatable(env, {__index=_G}) --This is an optional step which gives env read-access to everything in the global scope. It makes things more convenient; if you want more control over what the script is allowed to do you can add functions to env explicitly
    loadChunk(storyFile, env) --The script will be able to access anything in env as a global, and any globals declared in the script will be added to env

    jump(entryPoint)
end

--This advances to the next 'page' of the story
function nextPage()
    if not co then
        return
    end
    local status, result = coroutine.resume(co)
    if not status then
        error(result)
    end

    co = result
end

--This jumps to a different story branch. If no branch with the specified name is available then the story ends
function jump(target)
    if not env[target] then return end
    co = coroutine.create(env[target])
    nextPage()
end

function image(name)
    --Show an image on the screen, replacing any previous image
    --This function doesn't yield, so you can switch the image and the display a new page of text at the same time
    showImage(name)
end

--This displays a new page (line) of text. At the end it calls coroutine.yield to wait for input from the player
function text(text)
    clearOptions()
    showText(text)
    coroutine.yield(co)
end

--This inserts an option into the option list. 'text' is the text that gets displayed to the player (an action or a line of dialogue), and 'target' is the name of the function that the story will jump to when the player chooses the option.
--We don't display the option on the screen until the story script invokes menu()
function option(text, target)
    table.insert(options, {text=text, target=target})
end

function clearOptions() --This clears the internal 'options' list (any options displayed on the screen will have to be cleared separately)
    options = {}
end

--This displays all the options in the 'options' list on the screen, then yields to wait for user input
function menu()
    --Clear any text/options from the screen
    local i = 1
    for _,option in ipairs(options) do
        --Display a clickable option on the screen, using the option's 'text' field
        --'target' should be the name of a function defined in our story script
        --When the option is selected it should call jump(target)
        displayOption(option.text, i, option.target)
        i = i+1
    end
    coroutine.yield(co)
end

Okay, that was a lot! Hopefully it should be clear how all this fits together. A single coroutine called co is used to run, pause, and resume the story script, under the control of this module. Selectable actions or dialogue options can be displayed on the screen, and picking one will jump to a new branch of the story, which creates a new coroutine to replace the existing one. A function environment is used to 'sandbox' the story script by exposing a limited set of capabilities to it, but it's also easy to give the scripts full access to the calling environment if required, using setmetatable.