2024-02-05 - A generic Id type
Last week I did a short break to take care of my personal website, which really needed a makeover. I also did a little something for the game website, but that's for a future surprise.
Now, back at work on the game!
A good task to ease me back into the development is getting rid of the damned string based identifiers.
While strings can be convenient to identify elements when an enumeration is not suitable (because the elements come from an arbitrary number of external data items, for example), it's not a good idea to have string comparisons and lookups in the update loop, if one can avoid it.
Comparing (or hashing) integers or enumerations (which are, behind the scenes, other integers) is extremely fast, because it boils down to very simple CPU instructions.
String comparison and manipulation, instead, can be significantly slower and involve a variable number of cycles depending on the strings involved.
This doesn't mean that one should ditch strings completely and give up on their convenience. Usually, it's reasonable to establish a 1-to-1 mapping between a string and an integer value on load, and then use the integer whenever possible.
In our case, we're using strings for zones and for platforms that need to be identified because they're the target of a bridge portal. I discussed that I could just have used hex coordinates to identify the target platform, but I decided to use string identifiers to decouple the level definitions: if there's a bridge portal defined in `ZoneA
` to connect `ZoneA.exit`
with `ZoneB.entrance
`, I can edit `ZoneB
` and change which platform has the `entrance
` id, with no need to touch `ZoneA
`.
But we definitely can process the zone data at start-up and have in memory a version that uses integer identifiers and not strings. That's basically what I'm going to do.
Currently, we have:
`
ZoneDesc
` and `PlatformDesc
`, that implement the serialization/deserialization to save/load level data`
Zone
` and `Platform
` that are related to elements in the Unity scene, and have static methods to instantiate and destroy zones and platforms in the scene, on the basis of a `ZoneDesc
` or a `PlatformDesc
``
Location
`, which identifies a platform in the whole game world (it's a zone identifier and a set of hex coordinates)a `
PlatformBhv
` behaviour attached to each platform node in the scene`
ZoneManagerBhv
` that does a bit of everything, loading the `ZoneDesc
` external data, instantiating/destroying `Zone
` and `Platform
` elements as needed etcthe classes that handle the "state" data of the world related to a gameplay session (the part we added in the last couple weeks)
It works but it's not pretty.
Now, I want to build another layer which is derived from the `ZoneDesc
/PlatformDesc
` data but is optimized for runtime access using `int
`-based identifiers and not strings. The use of `Desc
` classes will be limited to the serialization/deserialization: post initialization, I won't need to query into it anymore.
The change will snowball into the rest of the codebase, and if everything goes as I imagine, I will simplify the code significantly in many places.
As first step, let's define an `int
`-based `Id
` type that we can use in different contexts and that will allow to write very explicit method signatures, expressing our intent and not only the type of data we're actually passing.
using Newtonsoft.Json;
namespace BinaryCharm.ParticularReality.BasicTypes {
public readonly struct Id<T> where T : struct {
[JsonProperty("id")]
public readonly int m_id;
[JsonConstructor]
public Id(int id) {
m_id = id;
}
public static Id<T> ZERO = new Id<T>(0);
public static implicit operator int(Id<T> value) {
return value.m_id;
}
public Id<T> followingId() {
return new Id<T>(m_id + 1);
}
}
}
I just wrapped an `int` into a `struct` and provided an implicit conversion to `int` (but not _from_ ` int`, which would prevent the type checker from helping us to pass the correct identifiers as parameters).
Now, I can easily define some types to represent the world definition in a way which is suitable for intensive runtime usage.
I defined a bunch of structs (with `RT
` prefix in their name, for runtime):
`
RTLocation
` - global pointer to a platform (expressed as a `Id<RTZone>
` and a `Id<RTPlatform>
`)`
RTBridgePortal
` - a bridge portal definition (a `Hex.EDir
` defining the portal direction, and a `RTLocation
` identifying the portal destination)`
RTPlatform
` - a platform, which always has a `Id<RTPlatform>
` identifier and a set of `Hex` coordinates, and optionally a label and a bridge portal definition (a `RTBridgePortal`)`RTZone`: a collection of `RTPlatform`, with a label and an `Id<RTZone>` identifier
`RTWorld`: a collection of `RTZone`
The code is incomplete and not really worth showing. Tomorrow I'm going to complete it and progressively refactor other code so that it uses the new, "optimized" level data.
2024-02-06 - Following the compilation errors
Not much to say: following up on yesterday's work, I started refactoring the code so that it uses the new data types appropriately.
I started changing from `Location
` to `RTLocation
`, and proceeded with the others.
On the way, I moved some methods from old to new types, when it made sense.
I also replaced a few dictionaries using string based identifiers with arrays where the new `int
`-based identifier is nothing else that the position in the array.
This kind of refactoring makes you appreciate static type checking and careful API design: most of the work is trivial, just start changing a method signature somewhere and then follow the trail of errors that the change will cause, fixing one by one.
2024-02-07 - Platform managers, serialization
During week #12, I took a shortcut: I put the bridge portals managers directly into the zone managers.
While restructuring data and serialization to use the new numeric identifiers, I decided it was a good moment to introduce the missing layer: the platform managers.
We currently have a pretty simple hierarchical structure in our scene:
World (made of zones interconnected by bridge portals)
Zone (made of platforms interconnected by portals)
Platform (the basic "cell" where the player can be, which sometimes is an access point to a bridge portal)
With today's changes, the state management mirrors this same structure.
Everything works as expected, and I'm not doing any expensive string comparisons in the update loop.
What did I lose in the process? A bit of readability and flexibility in the savegame: there are no labels for zones and portals, so in case of problems it's going to be harder to check the JSON.
But I can definitely add some extra "debug only" information for those cases.
I'm going to do it when I'll need it.
Why am I saying I also lost flexibility? If the world data changes, it's now easier to break savegames. It was totally possible before this refactoring too, but you had to change the zone or platform identifiers. Now, instead, it's just a matter of adding/removing a platform and bad things will happen.
The robust way to handle savegames data is
storing the game version in the savegame
have a data transformation function, for each new version one releases, that when needed adjusts the loaded data so that it's compatible with the new version
At some point, I'm going to implement that kind of mechanism, but it's definitely not a priority.
So far, this week has only involved work "behind the scenes".
Not having nothing new to show it's a bit annoying, but I felt this was the right moment to do a bit of refactoring and clean-up, before proceeding. And I'm not done yet...
2024-02-08 - Moving code, fixing bugs
Today I'm going to proceed with shuffling code around trying to improve the architecture, and tomorrow I'll try to wrap it up and describe what is emerging and the rationale behind it.
Worth nothing that, during the process, I spotted a bug: sometimes the bridge portal indicators (the hexagons switching between red/green/white that you should be familiar from week 11/12) got stuck. I realized that I was only updating the state of the bridge portal (if any) on the platform where the player was standing on. So, depending on the state that the indicator was when one changed platform (when it stopped updating), one could reach an invalid, non-updated visualization of the game state.
Similarly, I was only updating the current zone, but then I could spot, through a bridge portal, some indicators in the adjacent zone that weren't in the correct state.
I changed so that the adjacent zone gets updated too, and now everything works as expected.
Hypothetically, thanks to the state and visualization separation I'm doing, I could update the state of all the zones in the game (e.g. having enemies "living their life" in the whole game world), but that would be useless at the moment. If needed, it's going to be easy to change this in the future.
2024-02-09 - Refactoring wrap-up
Today I did some more restructuring of the code, without altering its functionalities.
I added some further namespaces to make sure that I had a decent separation between the state management layer and the presentation layer, and that data flows "one-way" from state management to presentation.
I also did a lot of renaming to increase the consistency of the codebase.
In the state management layer, I have the logic which updates the game state on the basis of previous state and input data, all in "plain" C# classes and structs which never access the Unity scene.
In the presentation layer, I only have scripts that inherit from `
MonoBehaviour
` and live attached to a scene node, and that manipulate nodes (and component attached to them) to render (both in visuals and sounds) the current game state to the player.
Swapping the current game state for a saved one instantly changes the visualization seamlessly.
To have this working, I'm keeping the visualization behaviours somewhat "stateless": the scene nodes get updated entirely on the basis of the little piece of game state data that is bound to a certain behaviour.
This can become a performance issue, but it usually isn't, as long as one avoids creating and destroying objects indiscriminately. If no memory allocations are needed, doing a bit of extra CPU work to set component values etc is usually fine: it's often faster to do the work that to check if the state is already updated and so nothing needs to be touched.
When that's not the case, one can do something more sophisticated (e.g. use a timestamp system) in the presentation logic.
There are still some architecture details I don't like, but everything is significantly better than one week ago, and I feel confident to go back to adding new features next week.
There's also some imperfections, like a one frame glitch when stepping into the portals. But I'm resisting the temptation to debug it, because considering I'm going to change the whole portal rendering at some point, it would be a waste of time.
There's nothing really "new" to see, but I don't have time to do fancy architecture diagrams, and the post is an intimidating wall of text, so let's add a gameplay video with the scene view on the side, to add some color!
A little recap for next week. Let's fetch our TODO list and update it.
TODO:
refactor the zone management so that it fits the new architecture (getting rid of string based logic in the process, mapping to `int` identifiers once, at load time)work on a fully fledged player avatar (now we just have three spheres for hands and head)
implement (more or less explicit) access to the _save/load_ system in VR (now we just use keypresses for testing)
improve the pause/unpause visualization and interactions (now we access it with the "system" pinching gesture, or with the test "left hand thumbs down" gesture)
improve the visualization of thebridge portals(now we just have a small blue sphere on the platform edgeadd a pooling system to recycle platforms (and other stuff in the future)
I crossed two entries in three weeks. Not great, but I feel better thinking that along the way I also did some "system-wide" improvements adding generic features like the state machine parameters. Honestly, I also hoped to add the pooling system, but what can you do, there's no escape from Hofstadter's law.
I won't do it next week, because it would more "behind the scenes" optimization work that is not really necessary at this stage. I want to add and see new stuff! I'm going to start working on the player avatar management. Until next time!