Load screen for “The founders of [redacted]”

Savegames. I avoided them with the first three Ines games with the (barely defendable) excuse that they are short enough to be ok without them. Our latest point & click adventure game The founders of [redacted], however, is long enough that we needed to put together a puzzle dependency chart to keep track of things. I knew from the beginning that this time I would have to finally allow saving. This blog post documents how I did it.

As with many of the things that I’m doing, a huge shout-out to Ron Gilbert and his Thimbleweed Park development blog, which inspired my approach.

Saving and loading

The most important question to answer when designing a save/load system is “what data do we need to save?”. Following the Thimbleweed Park approach, I’m not trying to save minutiae such as the current frame of an animation, or the state of a coroutine. The data I need to recreate the state of the game is the following:

That’s it, we don’t need anything else. And you thought this was going to be difficult.

Here’s the code for saving the state, written in my scripting language Hurdy2. This is the actual function that the engine uses, I only cut out the part that saves the image data for the savegame screenshot.


 1 2 3 4 5 6 7 8 910111213141516171819202122232425262728293031323334353637383940414243444546474849505152

serialisation.save = method(slot) {
    -- Don't do anything if we are preventing saves. Technically the 
    -- function shouldn't ever be called in this case, but better be safe
    if state.savingPaused return

    var t = {}
    self.saveGames[slot] = t

    t.version = g.version

    t.state = {}
    for k, v in pairs(state)
        t.state[k] = v
    
    t.ines = {
        x = ines._position.x
        y = ines._position.y
    }
    -- Save player direction as string
    for k, v in pairs(engine.Character.direction) {
        if ines._direction == v {
            t.ines.direction = k
            break
        }
    }

    -- Save current room as string
    t.room = nil
    for k, room in pairs(g.rooms) {
        if engine.game.room == room {
            t.room = k
            break
        }
    }

    -- Save dialogue variables
    t.dialogues = {}
    for k, v in pairs(g.dialogues) {
        t.dialogues[k] = v._dulcimer -- this is the table that holds the dialogue state
    }

    t.inventory = {}
    for i, item in ipairs(inventory.bag) {
        t.inventory[i] = item.name
    }

    t.time = os.time()
    
    -- Serialise the table t using binser (https://github.com/bakpakin/binser)
    -- and write it to disk using love.filesystem
    return love.filesystem.write("saves/save" .. slot, binser.serialize(t))
}

And here is the function to load a savegame. Note that this assumes that all the existing save files have already been loaded by the engine.


 1 2 3 4 5 6 7 8 9101112131415161718192021222324252627282930313233343536373839404142434445464748495051

serialisation.load = method(slot) {
    var t = self.saveGames[slot]
    if !t return

    -- Kill all global threads and running dialogue
    thread_registry.threads = {}
    engine.signals.clear()
    engine.dialogues:_clear()
    inventory:close()
    inventory:stopFlash()
    talk:clear()

    -- make sure that any custom modification of the player animation/visibility is reset
    ines:flipReset()
    ines.hidden = false
    
    -- clear UI
    require("ui.dialogues"):clear()
    ereader.on = false

    -- Reset the counter keeping track of how many running blocking threads we have
    g.blocked = false
    resetBlockingCounter()

    -- first clear out the state table, then populate it from the save file
    for k, _ in pairs(state)
        state[k] = nil
    for k, v in pairs(t.state)
        state[k] = v

    -- Clear state that should not be saved (just in case)
    state.savingPaused = nil

    inventory:clear()
    for _, name in ipairs(t.inventory)
        inventory:add(name, false)

    for k, v in pairs(g.dialogues) {
        v._dulcimer = t.dialogues[k]
    }

    -- Reset audio (stops all music and sounds)
    audio:loadSavegame()

    if t.room {
        var v = engine.steelpan.Vec2(t.ines.x, t.ines.y)
        changeRoom(t.room, v, t.ines.direction)
    }

    g.gameStarted = true
}

Some things to note here:

To get an idea of what things look like, here is an example of what the data we are saving looks like.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{ 
    version = "0.1.4",
    state = { 
        act1 = true,
        lightsOn = true
    },
    inventory = { "manuscript", "ebook", "phone", "lighter" }, -- items at the start of the game, no spoilers here
    time = 1715642470,
    dialogues = { 
        lee = { 
            once_people = true,
        }
    },
    ines = { 
        y = 110,
        x = 155,
        direction = "S"
    },
    room = "atrium"
}

Re-entering the room

The save and load functions look surprisingly easy, don’t they? How come it took me three games before getting here!?

Well, the non-obvious thing here is that the game must be structured correctly for the load function to actually work. My previous games are not structured correctly—I cannot just drop these functions in and magically add savegames to those games. I will eventually revisit them to implement this, but it will require quite a bit of effort.

So how do you structure the code correctly? The trick is this: every time we enter a room in the game, we rebuild all of its state from scratch. This includes:

All of this is done conditionally based on the global state in the state table. Then when a game is loaded and we use changeRoom, everything is recreated and looks like it did when we saved (not exactly the same, but close enough to be happy about it).

This is the structure that’s missing from my previous games. There the rooms are only populated once at the beginning of the game, and changes (like hiding objects) are made once as needed. It probably wouldn’t take too long to refactor everything as needed, but that’s something to look into after the current game is done.

And now, some code to give an idea of how a room is set up.


 1 2 3 4 5 6 7 8 910111213141516171819

global function changeRoom(roomName, position, direction) {
    var room = assert(g.rooms[roomName])

    -- move Ines to the room
    ines:changeRoom(room, position, direction)
    engine.game.set_room(room)

    -- reset the camera
    camera.moving = false
    stopCameraThread()
    var x = math.floor(g.gameWidth/2 - ines._position.x)
    x = math.max(x, g.gameWidth - room._bounds[2].x)
    x = math.min(x, 0)
    camera:set(x)

    -- run the function that rebuilds the room
    if room.setup
        room:setup()
}

 1 2 3 4 5 6 7 8 9101112131415161718192021222324252627

------------------------------------------------------
-- NOTE: there are spoilers for my next game below! --
-- READ AT YOUR OWN RISK!                           --
------------------------------------------------------

var room = g.rooms.kitchen

room.light = {1, 1, 1}

var notebook = room._objects.notebook
var generator = room._objects.generator

generator._use = {
    -- Table that specifies what happens when we use
    -- various objects with the generator.
    -- Redacted to avoid spoilers.
}

method room.setup() {
    audio:startMusic("house")
    ines._colour = room.light

    notebook.hidden = state.gotNotebook

    generator.use = !state.generatorWorks and state.knowAboutGenerator and generator._use or nil
    generator:start_animation(state.generatorWorks and "charged" or "dead")
}

Final thoughts

The most important thing I learned from this experience is that savegames are something that needs to be planned at the very beginning, they can’t just be stapled on top of what you already have.

I also found the approach with rebuilding the room every time we enter to be extremely useful for testing, regardless of whether we use it for saving or not. I set up by debug GUI so that I can toggle on and off all the state variables and reload the room on changes, which allowed me to get to virtually any point in the game without having to play through it, by just setting all the variables that would get me to the particular state I wanted. I highly recommend it.


  1. The dialogue system—which I’m quite proud of—is too complex to exaplain here. I’ll cover that in its own blog post at some point. ↩︎

  2. Hurdy is a language that compiles to Lua, so if you know Lua it should be easy to understand it. ↩︎