Creating a Level Editor for Sub-Optimal

JoeStrout - Aug 18 - - Dev Community

Last month for Micro Jam 018, a game jam with the theme "Water" and restriction "health is power", developer Florian Castel developed an amazing puzzle game called Sub-Optimal (in only two days!) using MiniScript and Mini Micro. He ended up winning not only "best MiniScript entry," but also first place overall, beating out 84 other entries!

Screen shot of Sub-Optimal, Level 2

After the jam, I reached out to Florian and offered to contribute a couple of small refinements. The biggest item on my wish list was a level editor. Sub-Optimal is sort of a sokoban-style game; it came with ten delightful levels, but I thought that with a level editor, players would have fun inventing hundreds more.

This post is about how the level editor works. Perhaps you'll find some tidbits in here you can use to make a level editor for your own game someday!

Level Editor Overview

Florian made his levels during the jam using the built-in level editor (at /sys/demo/levelEditor). This is really just a tile display editor; you load a tile map (which Florian got from here), and then arrange tiles however you like. The levelEditor program writes these out to a plain text file, and also provides code for reading such a text file back into a tile display at run time.

This works fine and was a great solution for during the game jam, when there was no time to develop something better. But it has two shortcomings:

  1. Selecting the right tiles to neatly join together around corners etc. is a bit of a pain.
  2. It doesn't provide an easy way to place the starting position and hazards, so Florian wrote a bit of code for each level to manually place those things.

I wanted to make a tile editor that would be a joy to use, so the first thing I had to figure out was a way to automatically select the right tiles, so all the user has to do is draw and erase the cave with a single tool.

Auto-Tiling

Auto-Tiling is a general term referring to any program that automatically selects the correct tile for you, as you draw with a tool that just adds or removes the general type of tile (in this case, rock around the underwater cave). There are many different approaches to it, including the Wang 2-corner tilesets discussed here before.

Unfortunately, the tile set Florian found isn't a 2-corner tileset. A bit of quick study of this one revealed:

  1. If the cell itself is empty (not rock), then the neighbors don't matter; the cell is entirely empty.
  2. But if the cell is solid, the correct tile to use can depend on all eight of the surrounding cells.

Example tiles

This implies 2^8 = 256 different possible combinations. However, there were not that many different tiles in the tileset. It turns out that a lot of those 256 different combinations actually map to the same tile.

My general approach to untangling this was to first, let any cell be "solid" (rock) or not solid (empty). When loading a level, for example, we would first just use a standard solid-rock tile for every non-empty tile. Then, we'd call a fixTile method, whose job it is to look at the surrounding cells and update the given cell with the correct tile for its situation. When editing the level interactively, we'll do the same thing: change a cell from solid to empty or vice versa, and then call fixTile on all cells to ensure they now have the correct appearance.

I wrote this fixTile method by handling the easy cases first. For example, if this cell is solid but all four of its orthogonal neighbors (North, East, South, and West) are empty, then I want to use tile 36 from the tileset. (There's no magic to that number — I just looked in the tile set for the rock-surrounded-by-emptiness tile, and found it was index 36.) The full function ended up looking like this:

fixTile = function(x,y)
    if not solid(x,y) then return
    N = solid(x, y+1)
    S = solid(x, y-1)
    E = solid(x+1, y)
    W = solid(x-1, y)
    NE = solid(x+1, y+1)
    SE = solid(x+1, y-1)
    NW = solid(x-1, y+1)
    SW = solid(x-1, y-1)
    NESW = N*1000 + E*100 + S*10 + W
    if NESW == 0000 then
        idx = 36
    else if NESW == 0001 then
        idx = 35
    else if NESW == 0010 then
        idx = 3
    else if NESW == 0100 then
        idx = 33
    else if NESW == 1000 then
        idx = 25
    else if NESW == 0101 then
        idx = 34
    else if NESW == 1010 then
        idx = 14
    else if NESW == 0011 then
        if SW then idx = 2 else idx = 7
    else if NESW == 0110 then
        if SE then idx = 0 else idx = 4
    else if NESW == 1100 then
        if NE then idx = 22 else idx = 37
    else if NESW == 1001 then
        if NW then idx = 24 else idx = 40
    else if NESW == 0111 then
        idx = [8, 6, 5, 1][SW * 2 + SE]
    else if NESW == 1110 then
        idx = [48, 26, 15, 11][NE * 2 + SE]
    else if NESW == 1101 then
        idx = [41, 39, 38, 23][NW * 2 + NE]
    else if NESW == 1011 then
        idx = [51, 29, 18, 13][NW * 2 + SW]
    else if NESW == 1111 then
        idx = [
           52, 42, 31, 50,
           32, 20, 30, 28,
           43, 19,  9, 17,
           49, 16, 27, 12,
        ][NW*8 + SW*4 + SE*2 + NE]
    else
        idx = 120
    end if
    tileDisp.setCell x, y, idx
end function
Enter fullscreen mode Exit fullscreen mode

You can see that I started by checking whether each of the eight neighbors is solid, and storing this in variables called N, NE, E, SE, etc. I also combined the four orthogonal neighbors into a variable called NESW, as NESW = N*1000 + E*100 + S*10 + W. So now when NESW equals 0010 (ten), I know that the S (south) neighbor is solid, while the north, east, and west neighbors are empty.

This function was a little tedious to write, but it sure is a pleasure to use! Thanks to this magic, I could now simply click and drag to draw and erase rock, making it dramatically easier to create a beautiful level.

Animated GIF of auto-tiling in action

Starting Position and Hazards

The next challenge was to eliminate the need for custom code for each level. For this, I modified the original tileset, to add little icons for the player starting position (orange sub), a big red "X" indicating the erase tool, and the three kinds of hazards/pickups in the game: a heart, a shark, and an air bubble.

Cliff.png modified tileset

These extra tiles would be used in the tile editor only. Where we load a level for the actual game, I added some extra code that scans the tile display for these special tiles, clears out that cell, and adds the appropriate sprite. (You can find this in main.ms, around line 130.)

    for x in range(0, tiles.extent[0]-1)
        for y in range(0, tiles.extent[1]-1)
            c = tiles.cell(x,y)
            if c == levelEditor.PLAYER then
                player.playerStartX = x
                player.playerStartY = y
            else if c == levelEditor.BUBBLE then
                item = new bubble.Bubble
                item.init
                item.setPos x, y
                collectables.push item
            else if c == levelEditor.HEART then
                item = new heart.Heart
                item.init
                item.setPos x, y
                collectables.push item
            else if c == levelEditor.SHARK then
                sh = new shark.Shark
                sh.init
                sh.setPos x, y
                sharks.push sh
            end if
            if autotile.nonSolidIndexes.indexOf(c) != null then
                tiles.setCell x, y, null
            end if
        end for
    end for
Enter fullscreen mode Exit fullscreen mode

This is a very common trick I've used in other games too, such as Kip and the Caves of Lava. It's simple but effective.

Level Codes

The last major hurdle for the Sub-Optimal level editor was: I really wanted to compress the level down to a small, printable string. This brings some neat advantages:

  1. We can plop a list of these strings into the code somewhere for the built-in levels.

  2. You can easily save these strings to a notebook or plain text file.

  3. Players can share these strings on Discord or in comment posts, easily saying "Check out my level!"

This is essentially the same idea as "save codes" in older games — back in the days before game consoles contained a hard drive or battery-backed RAM, these were the only way to save the state of a game to continue it later.

Our challenge here is in compressing the level design down to the smallest number of easily-typed characters as we can. The levels are 15 x 10 = 150 cells, each either solid or empty. That's 150 bits, which would be about 19 bytes; but there aren't 256 unique characters we can easily type in English, so we can't do one character per byte. (Though if we were doing this in something like Chinese, this would be no problem!) And, in addition to the rock layout, we have to also encode the player start position, and the other hazards/pickups in the level.

So I ended up using a combination of tricks:

  1. I made a bitstream class that lets me write information to a RawData one bit (or sometimes several bits) at a time.

  2. I then wrote a base64 module, to encode and decode data in Base64 format. As the name suggests, Base64 uses 64 printable characters to encode data in 6-bit chunks.

  3. I added an extra, nonstandard feature to my base64 module, enabling it to do run-length encoding whenever the code has more than three of the same character in a row. (This is fairly common in Sub-Optimal levels, which often have long runs of solid or empty space.)

With these tools in hand, the packLevel and unpackLevel functions in levelEditor.ms were fairly straightforward. To pack a level, we just loop over the rows and columns of the tile display, writing a bit for each cell indicating whether that cell is solid rock. Then we write some additional bits for the player and pickups/hazards, and base64 the whole thing (with RLE compression). The result is a string like this:

/*B+w/2H+E/2A+A/*EMhJwAA

Not too bad!

Other Refactoring

Once I had the level editor basically working, I recreated each of Florian's original 10 levels, copying down the level code for each. Then I could delete the entire "levels" folder previously containing a text file for each one, as well as the special code for each level in levels.ms, reducing that file to:

levelList = [
    "/*EAH4A/AA4A/*EUA*A",
    "/*EDH4YHAH48/*EUA*A",
    "/*CP/4D/AH4D/A/4H/*BUgAAA",
    "/*B4R/AP45/AP+h/9f/r/9/JAEnAA",
    "/*B+w/2H+E/2A+A/*EMhJwAA",
    "/v/9//v/4/+H/g/8D/gf+H///QQAEA",
    "/*B8B/gn8AHwH+H/*EMgAjdiQ",
    "///3/8D/gf8D/g/4B/AH/v/9/QQJDbiYGJA",
    "///t/9v/gDwNfBjwgeGD+Af3/QBVyMjgmuCQ",
    "/*B8B/AH4AHAA4A/gP/*BMQAEA",
]
Enter fullscreen mode Exit fullscreen mode

This simply lists the level code for each of the built-in levels. I also revised the main program (main.ms) to make it possible to go back and forth between the level editor, and actually playing the game. This works using a single global variable, customLevelCode. If such a global is found, the main program knows that the level editor is in use, and it loads and plays that level, and then returns to the level editor once the level is complete (or the user presses the Escape key). Otherwise, it works its way through the built-in levels as usual.

Try It Out!

Want to try creating your own Sub-Optimal level? You can try it right now just by going to its itch.io page, running the game, and then pressing Control-C to break out to the MiniScript prompt. Then simply enter run "levelEditor".

However, there is a limitation to web builds of Mini Micro games. For security reasons, they can't access your system clipboard. So, if you click Get to see the level code, you will have to manually retype that somewhere on your host system. Similarly, to load (apply) a level code, you'll have to manually retype it into the game after clicking Apply.

So, for a smoother level-editing experience, I recommend you download one of the builds Florian has prepared for you — or better yet, clone the repo and run it in your own copy of Mini Micro. Then you will be able to copy and paste level codes directly.

Screen shot of level editor with

When you have created a cool level, don't keep it to yourself! Share it in the comments on the itch.io page. If it's fun to play, Florian may incorporate it into future versions of the game (so be sure to mention how you want your name to appear in the credits!). You can also share level codes on the MiniScript or Micro-Jam Discord server, where you are likely to find other Sub-Optimal players.

Conclusion

This was a long post — thanks for hanging in there with me! Sub-Optimal is an amazing game, and creating a level editor for it presented some fun challenges. I hope you find something here you can apply to your own games. Happy coding!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .