Back in 2022, before I had this website, my friends and I made a game for the GMTK Game Jam called Unweighted. It was an isometric puzzle game where you control a die, and have to hit buttons and toggles to open gates and get to the finish pad. The challenge comes from you having to re-orient the faces of your die so that the right number is facing upwards, which is a requirement for interacting with some objects in the game.
I have been bouncing the idea of making a level creation tool in this game since probably during the jam itself, but I knew it was going to be a big undertaking, so I didn’t get around to actually starting work on it until August of this year. It is now December, and I finally put up a pull request in our repo for my teammates to review. In this series of blog posts I will be going through the process of adding this feature, and the interesting problems I had to solve along the way. This first post will be about the overall concept for the feature, and the first part of it I worked on, saving and loading levels to base-64 encoded strings.
High-Level Overview 🔗
The overall concept is very easy to describe. The user has a bunch of things they can put down on the fabric of the level, which is really just a 2-D grid. It should be possible to place down ground tiles, wires, and level objects without too much hassle. Then once a level is complete, there needs to be some magic code that ties all the objects together so that things connected by wires operate with each other. Finally, there needs some way of saving and loading levels, so that they could be shared between users.
Saving And Loading to Binary 🔗
The first challenge I took on was to save and load levels to a byte string. I figured this would be a pretty good starting point, as I could use the existing levels we already created for the game in order to test my code. So I got to work, tackling the easiest parts first.
Saving Tiles, Start Tiles, and Finish Pads 🔗
Every level is guaranteed to have a start and an end, and it’s not that hard to just get the grid position of both of those tiles. I save each coordinate of those positions as a signed 8-bit integer, which is large enough to cover the visible area on the screen several times over. I also store the minimum and maximum weight for the finish pad. These are the same value except in the case where there is no requirement, in which case the minimum is 1 and the maximum is 6. The code that does this is below:
var output := StreamPeerBuffer.new()
var start_tiles := ground_tile_map.get_used_cells_by_id(1) as Array[Vector2i]
if len(start_tiles) == 0:
return [false, "Level does not have a start tile."]
if len(start_tiles) > 1:
return [false, "Level has more than one start tile."]
output.put_8(start_tiles[0].x)
output.put_8(start_tiles[0].y)
var level_ends := objects.get_children().filter(
func is_level_end(x): return x.get_object_type() == LevelObject.LEVEL_END
)
if len(level_ends) == 0:
return [false, "Level does not contain a finish tile."]
elif len(level_ends) > 1:
return [false, "Level has more than one finish tile."]
var finish_tile := ground_tile_map.local_to_map(level_ends[0].position - level_ends[0].get_position_offset()) as Vector2i
output.put_8(finish_tile.x)
output.put_8(finish_tile.y)
output.put_u8(level_ends[0].minimum_weight)
output.put_u8(level_ends[0].maximum_weight)
This takes advantage of the StreamPeerBuffer
class, which is a dynamically sized wrapper around a PackedByteArray
with an internal cursor, allowing easy appending and reading of data. For a while, this was using a PackedByteArray
by itself, with an external cursor, but the StreamPeerBuffer
is a much cleaner solution, since it relies more on Godot’s built in infrastructure.
Saving the coordinates of the regular ground tiles is as simple as another call to get_used_cells_by_id()
and looping through the returned coordinates. The only wrinkle here is keeping track of the number of total tiles so that we know how many coordinates to read before stopping when we load the level. That’s easy enough to do by checking the length. Again, here’s the relevant code:
var normal_tiles := ground_tile_map.get_used_cells_by_id(0) as Array[Vector2i]
output.put_u8(len(normal_tiles))
for tile in normal_tiles:
output.put_8(tile.x)
output.put_8(tile.y)
Loading in this data is as simple as saving it, but in reverse. Saving the total number of tiles makes it very clear how many values need to be read before the level loader knows it’s done. With these two parts done, I modified the game to save and load the level data before giving control to the player. Everything worked for the few early levels with no objects or wires, so I moved on to the next stage.
Saving Wires 🔗
I knew going in that doing the wires and objects would be challenging. In the game as it existed, the wires were purely cosmetic. They changed color when they were turned on and off, but the actual logic for controlling things attached to them was completely separate. Each level had its own script which was responsible for triggering the various objects. I’ve put an example below.
extends Level
var toggle_wires := [...]
var button_wires := [...]
func _on_Toggle_toggled() -> void:
$Gate.toggle()
invert_wires(toggle_wires)
func _on_LevelButton_button_pressed() -> void:
$Gate2.open()
invert_wires(button_wires)
The two functions are triggered by signals manually connected in the Godot scene editor. invert_wires()
is responsible for changing the appearance of the wires. Clearly a new solution would be needed to work on arbitrary levels.
What I came up with was pretty clever. I got a list of every tile that had a wire on it (TileMapLayer.get_used_cells()
worked for this), and started looking for connected sets until there were no more wires left to check. Practically, it looked like this:
var nets: Array = []
while len(wires) > 0:
var net: Array[Vector3i] = []
var tiles_to_search: Array[Vector3i] = [wires.pop_front()]
while len(tiles_to_search) > 0:
var wire := tiles_to_search.pop_front() as Vector3i
var wire_tile := Vector2i(wire.x, wire.y)
var wire_layer := wire.z
net.append(wire)
var directions = get_wire_directions(wire_tile, wire_layer)
for direction in directions:
var next_tile := get_wire(wire_tile, direction)
if next_tile not in net and next_tile not in tiles_to_search:
var index := wires.find(next_tile)
if index != -1:
wires.remove_at(index)
tiles_to_search.append(next_tile)
nets.append(net)
The real magic happens here in get_wire_directions
which takes a tile and from its configuration returns a list of directions to search. So a straight line wire would return Vector2i.DOWN
and Vector2i.UP
, for example. This function is a massive tree of if statements (75 lines long!) that match each possible case.
You will also notice that the values here are Vector3i
and not Vector2i
like you might expect for a 2-D game. This is because of my nemesis, the crossing wires tile. Already in the game there was a notion of a z-coordinate of the wire tiles, so that each leg of the cross was independent of each other. I piggy-backed on this existing system, which led to some kludges, but I tried by best to at least leave comments to ensure it was readable for whichever poor soul works on this code next (no doubt it will be my future self).
Finally, after this function we have a list of connected sets of wires, which I called “nets” thanks to my electrical engineering background (LTSpice haunts me to this day). Now all that’s needed to do was to figure out which net an object belonged to and we could start hooking things up. But let’s not get too ahead of ourselves; we still need to store the wires into our byte array.
For this, I followed a similar strategy as for the ground tiles: for each net the first byte is the number of wires contained in it. Similarly, before any of the nets is a byte containing the total number of nets. Each individual wire tile gets stored in this 3-byte format, which packs all the information needed to reproduce it when it gets loaded.
Saving Objects 🔗
Objects are a bit simpler to save than the wires, given it’s really just a type and location you need to keep track of. However, this didn’t stop me from making a few improvements to the code while I was already in there. One thing I did which made things much easier to handle was to make a base class for the level objects, aptly named LevelObject
, that contained a lot of the common interfaces. The definition is below:
class_name LevelObject
extends Node2D
enum {
LEVEL_END,
BUTTON,
TOGGLE,
GATE
}
@export_range(1, 6) var minimum_weight := 1
@export_range(1, 6) var maximum_weight := 6
func get_object_type() -> int:
push_error("Function not implemented.")
return -1
func get_position_offset() -> Vector2:
push_error("Function not implemented.")
return Vector2.ZERO
func get_grid_position(tile_map: TileMapLayer) -> Vector2i:
var tile_center_position := position - get_position_offset()
return tile_map.local_to_map(tile_center_position)
func update_weight_display() -> void:
push_error("Function not implemented")
The enum makes it so it’s very easy to get the type of the object. get_position_offset()
was helpful because the actual location of the object in screen space is dependent on its size, so gates, buttons, etc. all had different offsets from the tile center position.
As with the wires and ground tiles, the first byte in the object section was the number of objects. Notably this excludes level end objects, as that was taken care of previously. We use the position offset to find the true grid position of the objects, and then we store each object in the following 4-byte format:
The fourth byte, which is called the state in the code can be different for each object type. You can see that for gates it stores whether or not the gate is open, and for other objects is stores the weight (a.k.a. face) requirements.
Loading Objects 🔗
The last bit of problem solving was figuring out how to connect the objects and wires together upon load. You may have noticed that I didn’t specify which wire net an object belonged to. To solve this problem, I added some new variables to the level class. The first was level_wire_nets
an array of arrays, each containing the wire tiles of a complete net. This was used mostly for the aesthetic changes when turning the wire on or off. The second variable was wire_sinks
, which was another array of arrays. This time, each array represented objects whose state would be changed by a wire change. The index for this was the same as the index for level_wire_nets
.
Complementing this was a new function in the Level
class:
func toggle_wire_net(index: int) -> void:
invert_wires_3(level_wire_nets[index])
for object in wire_sinks[index]:
object.toggle()
The call to toggle
here is the thing that changes the state of the gates connected to that wire net.
Finally, on level load, the wire “sources” (i.e. buttons and toggles) could use the list of wire nets to figure out which they were attached to, and then connect their signals to the new function. I included the process for the button here as an example, but the toggle behaves in much the same way.
# In load_level_data()
for _idx in num_objects:
var type := input.get_u8()
var coords := Vector2i(input.get_8(), input.get_8())
var state := input.get_u8()
var net_idx := -1
for idx in range(len(level_wire_nets)):
for tile in level_wire_nets[idx]:
if Vector2i(tile.x, tile.y) == coords:
net_idx = idx
var connect_to_wires := true
if net_idx == -1:
push_warning("Object not connected to a wire net. (coords = %d, %d)" % [coords.x, coords.y])
connect_to_wires = false
var object = CLASS_TO_SCENE[type].instantiate() as LevelObject
object.position = ground_tile_map.map_to_local(coords) + object.get_position_offset()
match type:
LevelObject.BUTTON:
var button := object as LevelButton
button.minimum_weight = state & 0xF
button.maximum_weight = (state >> 4) & 0xF
if connect_to_wires:
button.button_pressed.connect(toggle_wire_net.bind(net_idx))
...
And with that, everything was connected! I tested the saving and loading code on all the existing levels to confirm that everything was working correctly. As a consequence of having completed this work, I could remove all of the one-off level scripts that controlled the objects individually. Just look at how much I deleted!
Base-64 Encoding 🔗
A binary string is all well and good for computers, but in order to send levels between users, I wanted there to be a more portable format people could use. So I settled on base-64 encoding, since it is 50% more space efficient than putting everything in hexadecimal. Each base 64 character encodes 6 bits, so every 3 bytes turns into 4 characters. The details of the conversion are trivial, and are even abstracted away in Godot’s Marshalls
class, which was helpfully pointed out to me by my partner.
A typical level has a code like the following:
CwUL/gUFDgv/CwALBAsDCwIKAwkDCQIJAQgCCAEKAAsBCQABBQz/BwwABQv/HQwBHgsBHQIDC/8AAQsBVQQAAABFcGlj
That is small enough to be manageable. I’m sure I could have maybe squeezed it down a few more characters by being smarter about compression, but when the output is already this short I don’t see the need.
Conclusion 🔗
All of this came from just the first two commits on the merge request branch, so there is much more still to come. But this post is already getting too long, so I’ll cut it here. If you found this at all interesting then please do stick around for the next posts on this topic. Until then, have a happy new year, and I’ll see you all in 2025!