UniverCity - Saving and Loading
Since the game is out on Steam now (but still in development) the changelogs have been moved over to announcements. So I thought I’d start using this to try and talk about internals of the game more (which originally was the goal of this blog, kinda failed there).
The first thing I thought I’d talk about is how saving and loading is currently implemented in the game, the issues I’m having with it and any ideas I have on improving the system.
Saving⌗
This isn’t anything special but it helps understand how loading works and the issues with it later on.
The save file is made up of two parts: a header and the JSON serialized save structure. Originally early during development the header was part of the save structure however this introduced major performance issues when trying to peak into every file to show on the “Load Save” screen to exclude invalid versions (and later saves for other modes e.g. multiplayer).
The solution for this was to create a simple binary header that
wouldn’t require any decoder just some simple reads using
byteorder
. The header currently includes 3 parts: a u32
version number in case the format changes and I need to convert
later, a u32
save type (e.g. single player, mission, multiplayer)
to exclude them when in different modes and finally a i32
length
(or -1
for no data) followed by png of length
bytes to use
as the save icon. This is all that is currently needed for the
load menu.
The rest of the file is a SaveFile
structure serialized with
serde
to JSON. JSON isn’t the greatest choice for this but
it allows me to easily change/add fields without having to
worry about breaking old save files which is useful during
development. The other serde-*
crates that serialized to
a binary json-like format didn’t seem as maintained as serde-json
at the time of writing the saving system, this may have changed.
The creation of the structure is pretty simple (compared to loading
it). Entities are flattened into a EntityInfo
structure with
fields for all the saved component types, I have thought about
saving them in a way that is similar to how they are stored at
runtime but handling gaps (as only some entities are saved)
seemed pretty hard. Rooms save their type, id, owner, placement
area and a list of objects in the room. Rooms with scripts
can also save a lua table for the script to load itself from.
Objects only save the type, placement position, rotation and
a version number used by scripts during loading.
Loading⌗
To make the loading code easier the idea was to try and not save exact information about objects and rooms (e.g. the current floor tiles) but instead save the same information that was used during placement and then recreate the player’s actions to recreate the save.
This was done due to early versions of the save file saving what an object’s script did during placement (as stored in memory). Whilst this was fast to load it introduced some major issues when I needed to change something about an object (e.g. collision size or adding something new to it) because the exist objects wouldn’t update until someone replaced them.
In the current version rooms are recreated using the same system that players go through to build the room.
let id = assume!(log, level.place_room_id::<ServerEntityCreator, _>(engine, entities, room_id, owner, room.key.borrow(), bound));
let id = match room.state {
RoomState::Planning => id,
RoomState::Building => level.finalize_placement(id),
RoomState::Done => {
let id = level.finalize_placement(id);
level.finalize_room::<ServerEntityCreator, _>(engine, entities, id)?;
id
}
};
One thing that is notably missing is objects which during normal
play would happen between finalize_placement
and finalize_room
.
This is where the first issue with this system came up and its one
that is still not resolved but instead worked around for now.
Objects are done in the same way: the placement is started, the object is moved to the same location as the player clicked when placing it and then the object is finalized and placed. However as I said above the objects are not placed when building the rooms but are instead placed after all the rooms are done. This was due to an issue that came up during testing and was caused mainly because of one type of “room”: buildings.
Buildings are somewhat special because they allow other rooms to be built inside them but they have to allow themselves to be modified without removing all the rooms inside first otherwise adding things like benches would become a major issue for players. Benches however have the property of attaching themselves to a wall when placed near one, if that wall belongs to a room however that wall wouldn’t exist during loading until the rooms inside are placed. This would cause the bench to face the wrong way if it was placed during the room’s creation so I’ve deferred all objects until later as a work around.
Another building related issue that came up is the reason an object ended up being placed a certain way when it was first placed could change after building rooms. One case where this caused a crash was with a door on a building which had a room built next to it. The way door placements work is that they’ll snap to the nearest wall to your cursor and then complain if it can’t be placed on that wall (e.g. if that wall belongs to another room). This caused an issue in this case because the player clicked on the north edge of the square but at the original time of placement there was only a wall to the east so the door placed there, later they built a room to the north of the room. When they reloaded the save the script saw that the north wall was the closest and attempted to place on it only to find that the wall was invalid and error out. The current fix for this was to change the door script to prefer valid walls over invalid ones during the search for a wall, this could still cause the door to change wall in some cases but it wont crash.
-- Try twice, once looking for a valid wall and
-- then a second time allowing invalid ones.
-- This is done to let players see a useful error
-- message when trying to place on an invalid wall
-- whilst trying to prevent a crash when loading
-- saves.
for try = 1, 2 do
for _, dir in ipairs(direction.ALL) do
if level.get_wall(lx, ly, dir) ~= "none" then
local ox, oy = direction.offset(dir)
local dx = (lx + 0.5 + ox * 0.5) - x
local dy = (ly + 0.5 + oy * 0.5) - y
local skip = false
local valid = is_door_valid(ox, oy, dir)
if valid ~= true then
if try == 1 then
skip = true
end
end
local distance = math.sqrt(dx*dx + dy*dy)
if distance < nearest_dist and not skip then
nearest = dir
nearest_dist = distance
end
end
end
if nearest ~= nil then
break
end
end
I feel like the best way to solve this would be to provide a way for scripts to save some data with the object that could be used to recreated even after the walls/rooms have changed around it but I’m still interested in other options.
Thoughts⌗
This system isn’t great but it works. There is a lot that could be improved on. It would be nice to have some sort of streaming decoder to load and create at the same time instead of loading it all into memory and working with that (same with saving as well) however I might end up losing the ability to use serde(?) if I went that route.