Commit 616c7df6 authored by Matthew Collins's avatar Matthew Collins

Write about saving and loading

parent a833da47
---
layout: post
title: "UniverCity - Saving and Loading"
---
Since the game is out on Steam now (but still in development) the
changelogs have been moved over to [announcements][steamnews]. 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.
```rust
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.
```lua
-- 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.
[steamnews]: https://steamcommunity.com/app/808160/allnews/
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment