æon

Draconis task resolution

This is the core task resolution mechanism of Draconis, a TRPG design I started work on in early 2024. It uses a standard French deck of 52 playing cards for each player, and one for the GM.

PCs have four main attributes, currently called Force, Mind, Heart, and Guts; these are rated on a 1-4 scale. They also have a number of skills, rated similarly.

To do things, a player declares their character's action, and the GM decides a difficulty level (examples for various skills will probably appear in the book whenever there's a book) – usually this will be around 2-5, sometimes higher for very unusual or difficult tasks. The GM then draws that number of cards from their deck, places them face-down on the table, and names the attribute and the skill the player should use to overcome the task.

The player, having seen the number of cards, can withdraw if they decide against the course of action. Assuming they commit, they draw a number of cards equal to their character's score in the relevant attribute, and the same for the named skill, if they have it. They can then choose how many cards to play against the GM's hand; not every task warrants maximum effort, and it's not advisable to exhaust yourself on trivial things.

Once the player decides on the cards they want to use, they place them down on the table face-up, and the GM flips over their own cards. The totals are then compared: aces are worth 1, and face cards (jack, queen, king) are worth 10. If the player gets an equal or higher total, their character succeeds at the task: otherwise, they do not. GMs are encouraged to interpret narrow or wide differences in the card totals as indicative of wild successes, catastrophic failures, last-second near-mishaps, and so on.

The player then places all the cards they used in the task on their personal discard pile, and any cards they chose not to use on the bottom of their deck (in any order). The GM places the cards they used on their own discard pile and describes the result of the player's actions, whether successful or not.

Eventually the player's main deck will run low; if it ever runs out entirely their character becomes exhausted; they flip over a final card and can use only that to perform tasks until they get a chance to rest fully. When resting, the player shuffles their discard pile back into their deck ready to start again the next game day.

Folia

In collaboration with with Holly Boson I've released a Lua-based framework for making simple narrative games, called Folia. Folia is based around the coroutine technique discussed in this earlier post.

Leiff

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.

Picopon

I invented a puzzle game by mistake. You can play it here: Picopon

This was supposed to be a clone of Tetris Attack/Panel de Pon, but I misremembered the rules of that game. In Panel de Pon, the player has to assemble straight lines, at least three blocks long, of the same colour; by contrast, Picopon allows any arbitrarily-shaped monochromatic cluster to score points. To me, that's more fun because it allows for bigger chains and combos. Okay, play it!

Picopon screenshot