Over time, I’ve grown to appreciate the Tiled Map Editor. It’s a pretty powerful tool for building maps, with a lot of convenience features. I used it for AMAZE back in the day, and with another game project on the horizon I plan to use it again. I should warn you in advance–I’m here to talk about asset development here, there’s nothing about the project below. I’ll write more on it another day.

Tiled has one problem: If I want to test the maps I’m building, I need to export the map, launch the game, and navigate to the map in question. Moreover, every time I edit a map I’ll need to repeat this process. With my own bespoke map editing tools I could wrap everything together, but I’d spend a long time building tech instead of building a game. There must be a better way!

A Better Way

I’m going to cut to the chase and start with this video I posted last week:

As you can see in the video, I’m able to go from Ctrl+S to playable in-game in a fraction of a second. Let’s look at how I did it!

The Basics of Hot-Reload

When I was testing game engines back in 2020, I mentioned easy iteration as one of my requirements. Love2D passed then, but I didn’t give much explanation as to why… In the back though, I had actually done some preliminary testing and gotten Love2D to hot-reload assets.

Hot-reloading generally refers to reloading something without shutting down the running process. It’s often used in the context of programming to refer to updating code while it’s still running. In our case we’re not going that far, but we’re applying the same principle to our assets: maps, art, audio, entities, etc. I should’ve talked about it then, because hot-reload is a neat concept. So today, let’s fix that by breaking down asset hot-reloading and seeing how Love2D can do it. Hot-reload generally presents 2 challenges:

When to Reload?

This first problem is obvious: To reload assets as you work on them, you need to know when they need reloading. The easiest way to do this is watching the filesystem for changes. On Linux, we have the inotify API for that. Conveniently, someone’s even bundled it up for us to use in Lua code. All we need to do is let the API watch our asset folders for changes and trigger asset reloads in response. One small thing I should not for Love2D though–I couldn’t get the latest version of this module to load correctly. Version 0.4-1 works fine though, so it’s not a big deal. In the end, I made this:

local inotify = require "inotify"

local filewatcher = {
    watchers = {},
}

function filewatcher.add(self, folder)
    if not self.watchers[folder] then
        self.watchers[folder] = inotify.init({ blocking = false })
        self.watchers[folder]:addwatch(folder, inotify.IN_MODIFY, inotify.IN_MOVE)
    end
end

function filewatcher.events(self, folder)
    local events = {}
    for ev in self.watchers[folder]:events() do
        table.insert(events, ev)
    end

    return events
end

return filewatcher

With this loaded, I can just call filewatcher:events() periodically on my asset folders. I also keep this file out of my packages, so it only tries to load the external module when I’m developing the game. This is a good thing, since inotify is platform-specific.

Keeping References

The second problem is probably obvious to programmers, but less so to others. When loading an asset, you typically get a new object with the loaded data. This is also true of typical reloading code, but you’re already referencing the old object everywhere. If not handled properly, you can wind up with multiple incompatible copies of the same asset in use.

Thankfully, the solution to this is also pretty straightforward. By wrapping loaded data in a Lua table with extra data (including things like the asset type and filepath), we can create references that don’t point directly to the loaded data. If our asset loader returns a middleman like this when loading, we can safely hold references to the data without depending on the specific object we loaded. Then, we just swap out the content without any worries:

function assets.load(self, asset_type, path, force)
    path = finalizePath(path)
    if not force and self.lookup[asset_type][path] then
        return self.lookup[asset_type][path]
    end
    print("Loading " .. path .. "...")

    local data = self.lookup[asset_type][path]
    if not data then
        -- If this is our first load, create the asset
        data = {
            path = path,
        }

        -- Try watching the parent directory
        local folder = getFolder(path)
        if filewatcher then
            filewatcher:add(folder)
        end
    end

    -- Load and assign the data
    data.data = self.loaders[asset_type](path)
    self.lookup[asset_type][path] = data

    return data
end

Back to Tiled

With the work we’ve done above, we can reload modified assets. We’re still missing one piece of the puzzle though: Tiled

Tiled can export data to Lua, which makes loading and using maps in Love2D a cinch. However, the export process is manual. After saving, we need to hit export and select a location / name for the exported file. This kills our otherwise-speedy workflow!

Tiled offers a couple of possible solutions for this, including commandline export options and a scripting API. I could’ve tried the latter, but with no experience scripting Tiled I felt it was a safer bet to make use of the former. In theory, we can just write a script that takes a Tiled source file and exports it to Lua. This leaves us with one problem:

When do we need to export our data?

Well, if you read the previous sections you already know the answer. I opted to throw together a file watcher/exporter caller in C# and .NET Core, since I’ve used its file-watching APIs several times before as part of my job:

static void Main(string[] args)
{
    // ...
    _fileSystemWatcher = new FileSystemWatcher(filepath)
    {
        EnableRaisingEvents = true,
        IncludeSubdirectories = true,
    };
    _fileSystemWatcher.Filters.Add("*.tmx");
    _fileSystemWatcher.Filters.Add("*.tsx");
    _fileSystemWatcher.Changed += FileChanged;
    _fileSystemWatcher.Created += FileChanged;
    _fileSystemWatcher.Deleted += FileChanged;
    _fileSystemWatcher.Renamed += FileChanged;

    Console.ReadKey(false);
    // ...
}

private static void FileChanged(object sender, FileSystemEventArgs e)
{
    if(e.ChangeType != WatcherChangeTypes.Deleted)
    {
        Console.WriteLine($"File modified: {e.FullPath}");
        BuildFile(e.FullPath);
    }
    else
    {
        ClearFile(e.FullPath);
    }
}

private static void BuildFile(string path)
{
    Console.Write($"Rebuilding {path}...");
    if(MatchExtension(path, ".tmx"))
    {
        Process.Start("/bin/tiled", new[] { "--export-map", "lua", path, Path.ChangeExtension(path, ".lua") });
    }
    else if(MatchExtension(path, ".tsx"))
    {
        Process.Start("/bin/tiled", new[] { "--export-tileset", "lua", path, Path.ChangeExtension(path, ".lua") });
    }
    Console.WriteLine("done!");
}

.NET’s FileSystemWatcher operates on largely the same principles as inotify, so the code is pretty similar to what we have above. It could use more checks and less hardcoding, but for a disposable tool this is fine as it is. Now, all we have to do is run our exporter once at the start of a work session, and from then on our maps will export themselves whenever we save them. Combined with the Lua code from earlier, we now have an easy way to play in our maps as we design them!

Some Caveats

It would be irresponsible of me to wrap up this post without addressing the problems in the methods above. I’ve run into two issues so far:

Too Soon?

My art program, GrafX2, saves files in a way that sometimes triggers events before it’s done. When a reload is triggered before the file is saved, the load can fail and cause errors. For a race condition like this, there are a few possible solutions. The cheapest is just to wait a few ms before performing a reload, and beyond that you enter the realm of error handling, retries, etc. For now I haven’t taken the time to resolve this, but most likely I’ll just opt to delay the reload slightly.

CI/CD

One of my hopes for this system, and part of the reason I built a custom exporter, was to be able to perform asset exports as part of my CI/CD pipeline. With that I could fully automate packaging my game, which would be pretty nice. However, I missed an obvious issue: Even running in the commandline, Tiled is still a graphical application and needs a desktop to launch. That means I can’t really get it running from a Docker container, and I can’t perform exports in my pipeline. The only “true” fix would be to cut Tiled out of the export process entirely. With a custom exporter like mine, I could add the necessary XML-to-Lua conversion code. After some consideration though, I decided not to do so and instead just do packaging locally.


Looking back at both issues, you might notice that I’m not really solving these issues “The right way”. There’s a reason–best practices are there for the general case, to ensure a team can work well together or to avoid the assumption that every team member knows exactly what they’re doing. That’s important in a mid-size to large production, but for a one man band it’s total overkill! The truth of “cheap” hacks and shortcuts is that they’re not inherently bad. In my case I don’t need automated packaging or complex error recovery, because I’m one guy making one game. This is something the me of the past would balk at, but it’s very important to balance cost and benefit when working, especially when working with very few resources. For my projects, if the cost of doing something “right” outweighs the benefit it’ll provide then I’ll do it “wrong” any day of the week.

← Back to Blog