2023-12-04 - Application state and game state
Last week I had to take a break from the project to catch up with "real life" stuff.
The last thing I did was having a working "limbo" zone in mixed reality.
Let's update the tasks list I wrote in the "main loop design" day (2023-11-22):
define two new zones, `limboZone` and `menuZone`allow `limboZone` to have the _passthrough_ video feed as background, instead of a skyboximplement the activation of the paused state, which will bring the player back to the `
menuZone
`implement the deactivation of the paused state, which will bring back the player to the zone where they were playing
implement the paused state itself, that should make visually clear to the player that the game simulation is interrupted, and how to go back to playing (or to quit the game)
implement the save functionality, allowing a game state to be stored to the file system
implement the load functionality, allowing to load a saved state from the file system, and resume its execution
So, to keep it simple, we want to be able to pause, save and load the game.
To keep things tidy, I'm going to have a clear distinction between application state and game state.
The application state is somewhat higher level, and its update cycle is always in execution when the application is running.
The game state, instead, has to do with the gameplay simulation, which can be controlled (paused, saved, loaded) by the application.
The game state should contain all the information needed to put the gameplay simulation into a specific state, and to restart the execution from there (that's what happens when loading a savegame). The representation should be compact, so that it can quickly be saved and loaded.
Additionally, keeping a ring buffer with a certain number of game states from the last seconds is a nice way to allow rollback/time rewind. While it's something we've seen many times in flat screen games, I think it could be quite novel for VR, and I have an idea about how to have it working without breaking the immersion or causing discomfort. Also consider that being able to rewind, even just for a few seconds, could be an awesome testing and debugging tool.
What about the application state? Does it need saving and loading?
Sometimes it can be nice to have an application that, on startup, goes into the same state you were when you quit it (or when it got abruptly terminated for some reason).
But it's not really a thing in games, I think. That said, we can view the user settings that one normally changes in the Options screen as part of the application state. More precisely, when starting the application, it will read a settings file and initialize the application state on the basis of those settings (for example, adjusting the sound effects volume).
The core difference is that the settings that we want to save and load are just a single item, while instead we're going to deal with many game state data items.
But is it really like that? Some application settings might be device specific. You might start the game on multiple headsets, and you want to have separate audio settings for each device (maybe you use headphones on one, but not the other), but shared, cloud based savegames (because maybe you stop playing on one headset, and resume on another).
I'm just thinking about things and writing, because obviously these details don't matter much at this stage.
Let's recap the key elements:
a settings file, device specific, loaded at startup and saved when some setting gets changed
a number of savegame files, potentially shared by multiple devices, loaded and saved when appropriate (both automatically or according to user requests).
an application state, let's call it `
AppState
` from now on, that gets initialized at startup on the basis of the settings file, or with some defaults on the first runa game state, let's call it `
GameState
` from now on, that can be saved/loaded to/from a savegame file
So, it makes sense that `AppState
` is always defined after starting the application, while the `GameState
` might be present or not (we, depending on if we have a gameplay session running or no.
In case we decide to automatically load the latest savegame on startup, we will also have a `GameState
` always ready, but we must not forget to also allow the possibility to start a "new game" (that will switch execution to a specific, predefined `GameState
`).
Let's sketch a FSM for the `AppState
`:
Basically, we load the settings and use them to initialize the `AppState
`. Then, by providing a `GameState` , we can switch to the `playing
` state, which will update the gameplay simulation at every game loop, unless we interrupt it by going to the `paused
` state.
How the `AppSettings
` are loaded, or how the `GameState
` is provided (from an embedded definition, like in the case of a new game, or by loading a savegame), it's not relevant to this generic, very basic state machine. Soon, we're going to improve upon it by adding the save/load functionalities.
Let's also sketch the code for this FSM. This is going to be similar to the FSM implementation we did for the play area management (see 2023-11-21), but let's try and do a little better by only allow a state change from inside the FSM update logic itself, on the basis of a specific input signal that can carry some data with it if needed.
I'm using the same names used in the diagram for clarity:
namespace BinaryCharm.ParticularReality.StateManagement {
public class AppStateFSM {
private enum EState {
not_initialized,
ready_to_play,
playing,
paused
}
private enum ESignal {
init,
startPlaying,
pause,
unpause
}
private struct Input {
public readonly ESignal m_id;
public readonly object m_rData;
public Input(ESignal id, object rData = null) {
m_id = id;
m_rData = rData;
}
}
// -------------------------------------------------------------------
private AppSettings m_rAppSettings;
private GameState m_rGameState;
private EState m_state;
private Input? m_input;
public AppStateFSM() {
m_rAppSettings = null;
m_rGameState = null;
m_state = EState.not_initialized;
m_input = null;
}
public void init(AppSettings rSettings) {
m_input = new Input(ESignal.init, rSettings);
}
public void newGame() {
m_input = new Input(
ESignal.startPlaying, GameState.getNewGameState()
);
}
public void loadGame(GameState rGameState) {
m_input = new Input(ESignal.startPlaying, rGameState);
}
public void pauseGame() {
m_input = new Input(ESignal.pause);
}
public void unpauseGame() {
m_input = new Input(ESignal.unpause);
}
// -------------------------------------------------------------------
public void updateFSM(float fDt) {
Input? input = m_input;
m_input = null;
switch (m_state) {
case EState.not_initialized:
if (!input.HasValue) return;
switch (input.Value.m_id) {
case ESignal.initialize:
m_rAppSettings = (AppSettings)input.Value.m_rData;
m_state = EState.ready_to_play;
break;
}
break;
case EState.ready_to_play:
if (!input.HasValue) return;
switch (input.Value.m_id) {
case ESignal.startPlaying:
m_rGameState = (GameState)input.Value.m_rData;
m_state = EState.playing;
break;
}
break;
case EState.playing:
m_rGameState.update(fDt);
if (!input.HasValue) return;
switch (input.Value.m_id) {
case ESignal.pause:
m_state = EState.paused;
break;
}
break;
case EState.paused:
if (!input.HasValue) return;
switch (input.Value.m_id) {
case ESignal.unpause:
m_state = EState.paused;
break;
}
break;
}
}
}
}
This code is pretty bad! There are `switch
` statements with a single `case
`, something that doesn't really make much sense, but I wanted an almost "mechanical" translation of the diagram to this code. In every state, we switch to another state only when the right transition is possible, which happens when we have an input message of the right kind (`ESignal
`). Where we need data (an `AppSettings
` for the `init
` transition, a `GameState
` for the `startPlaying
` one), we find it stored into the `m_rData
` field of the `Input
` message. The cast is not pretty, but there's no way to feed the FSM incorrect messages, because only the public API methods prepare new input messages, and they do it properly. Another thing to note is that, depending on where `updateFSM()
` is called, the input might be processed with a frame of delay.
Tomorrow, I'll use this (untested) code as a starting point for a generic, versatile FSM that I will be able to use every time I need a FSM in the game (and it's going to happen very often).
If I don't screw up (yes, I'm improvising a bit) I'm going to put together an improved version of other FSM implementations I did in the past.
2023-12-05 - A generic FSM implementation
As we've seen, FSMs can be easily mapped to `switch
` code blocks.
That feels a bit crude and involves a bit of boilerplate.
Another classic implementation goes on the opposite side of the spectrum (all-in OOP), defining a class for each state, with virtual method calls taking care of the FSM logic.
I'm not a fan of this latter implementation: often, states are very simple, and having to define a class for each feels overengineering. And the states logic code gets scattered around, while there's value in keeping it compact and easily readable.
I won't go into performances, but even on that side I have a bad feeling about that kind of implementation.
So, can we find a middle ground? Something more structured that a custom `switch`, but less cumbersome that a class hierarchy with dynamic allocation and lots of pointers and virtual calls?
I know we can, because I've already done it in the past. Still, there were some quirks of my previous implementations that bothered me. So, I will try to do a little better.
These are my desiderata, that are motivated by practical requirements: we don't care about a strict adherence to the formal definition of a finite state machine, we want something that helps us build the game offering useful features.
a FSM should be able to hold some data and access it from its states
a state should execute some operations on the basis of the data held and and some input
state changes should only happen from inside the FSM itself, but we want to be able to force a FSM into a specific state and assign to it some specific data, so that we can restore an execution
we want some degree of type checking
we want the execution to be reasonably efficient
we want to keep a history of a certain number of previous states the FSM has been into, which can be useful both for debugging and to simplify the logic ("ok, I'm in this state, but where am I coming from?")
* we want that a single update cycle can potentially execute multiple states: otherwise, we will introduce all kinds of problems because we can't traverse multiple states in a single frame (been there, done that)
We might not get all this perfect right from the start, but let's get going.
I started by importing into the project my previous implementation.
Then, I read it carefully (I didn't remember the details) and spent some time trying to refactor it to address its flaws.
I think I'm pretty close, but I need more time and energy to wrap it up. Tomorrow I should definitely complete a first version.
2023-12-06 - The thin line of overengineering
Today I was possessed by the demon of software engineering.
Sometimes it just happens: at the end of the day, simple architecture and simple code are usually the best from all angles: quick to implement, fast execution, readability, maintainability.
BUT.
Sometimes the demon comes, and the temptations start.
"What if we split that interface to isolate write access?"
"Ok, it works, but why not make it generic?"
”Wouldn't an abstract base class there fit perfectly to avoid repeating those two methods?"
"Hey, what a nice spot to apply that design pattern you read about..."
And that's how I basically spent my day, trying to get to the perfect, generic FSM implementation that could accommodate all my needs, fiddling with the C# type system and shuffling code around.
Is there any excuse for this?
Well, sometimes a bit of extra engineering can be the right thing. That happens when few bits of convoluted, hard to understand code can enable simpler, better, non repetitive code in many other parts of a codebase.
As always, it's an act of balance.
Did I get the it right, or went overboard?
Time will tell. I will add plenty of FSMs during development, and I'm going to see if the generic features I provided today are sufficient, stable and good in terms of performances.
If not, I'm going to evolve my design. If the evolution goes towards further complexity and not simplification, I might restart from scratch.
All this considered, and not only because it's really late, I won't go into the details of the FSM implementation. I might do a special post about it at some point, maybe after the design has been battle tested and refined in the upcoming months.
What I'm going to show, though, is the FSM we implemented a couple of days ago using a `switch
`, but adapted to the new approach.
using AppStateFSM = BinaryCharm.ADT.FSM<
BinaryCharm.ParticularReality.StateManagement.AppStateFsmDefs.EState,
BinaryCharm.ParticularReality.StateManagement.AppStateFsmDefs.ESignal,
BinaryCharm.ParticularReality.StateManagement.AppStateFsmDefs.AppState
>;
using IAppStateFSMView = BinaryCharm.ADT.IFSMView<
BinaryCharm.ParticularReality.StateManagement.AppStateFsmDefs.EState,
BinaryCharm.ParticularReality.StateManagement.AppStateFsmDefs.ESignal,
BinaryCharm.ParticularReality.StateManagement.AppStateFsmDefs.AppState
>;
//...
private static AppStateFSM getFSM() {
AppState rAppState = new AppState(
new AppSettings(),
new GameState(
new Player(),
new Portal()
)
);
Dictionary<EState, Func<IAppStateFSMView, EState?>> rStates =
new Dictionary<EState, Func<IAppStateFSMView, EState?>>() {
{
EState.not_initialized,
(IAppStateFSMView rExec) => {
if (rExec.isInputAvailable()) {
Input<ESignal> input = rExec.fetchInput();
if (input.m_id == ESignal.initialize) {
rExec.getData().m_rAppSettings =
(AppSettings)input.m_rData;
return EState.ready_to_play;
}
}
return null;
}
},
{
EState.ready_to_play,
(IAppStateFSMView rExec) => {
if (rExec.isInputAvailable()) {
Input<ESignal> input = rExec.fetchInput();
if (input.m_id == ESignal.startPlaying) {
rExec.getData().m_rGameState =
(GameState)input.m_rData;
return EState.playing;
}
}
return null;
}
},
{
EState.playing,
(IAppStateFSMView rExec) => {
rExec.getData().m_rGameState.update(rExec.getDeltaTime());
if (rExec.isInputAvailable()) {
Input<ESignal> input = rExec.fetchInput();
if (input.m_id == ESignal.pause) {
return EState.paused;
}
}
return null;
}
},
{
EState.paused,
(IAppStateFSMView rExec) => {
if (rExec.isInputAvailable()) {
Input<ESignal> input = rExec.fetchInput();
if (input.m_id == ESignal.unpause) {
return EState.playing;
}
}
return null;
}
}
};
return new AppStateFSM(
"AppStateFsm",
rStates,
rAppState,
EState.not_initialized
);
}
The core part is the `rStates
` definition, that are nothing else that functions receiving a view of the FSM (from which they can access the bundled data, any input, and other useful information like the previously executed state, or the elapsed time in the current state). Such functions return `null
` when the execution must remain in the same state, or a state identifier when the execution must switch to another state (which happens instantly, in the same frame).
If you look carefully, the lambda expressions that define the state logic do essentially the same work which was carried by the `case
` branches of the `switch
`.
But now, the execution of these states is abstracted away into a generic FSM implementation, which can add a number of useful features in a centralized fashion.
For example, I added a logging of the state changes, and a history of state execution and received inputs. These features will be available automatically to any FSM I'm going to add, independently from the logic they will implement.
2023-12-07 - Gameplay avatar basics
Considering how I'd like to implement and visualize some features, I think it's time to start adding to the `GameState
` more detailed information about the player state.
It's also the right moment to start structuring the visualization of the player avatar so that it can change depending on the application state: we want a clear visual distinction between the avatar that the player is controlling in a gameplay session, and in the limbo/menu.
In practice, this should mean building an extra layer above the default hands visualization offered by the Meta SDK, and working on its visualization and behaviour.
As a first step, I will implement a gameplay avatar following the player body when the application is in the `playing
` state.
I'm going to start using three primitive shapes for hand palms and head, and test the structure using just that limited avatar representation. Then, I'm going to extend the system to properly handle the fingers bones and maybe some bones of the upper part of the body (as deduced via inverse kinematics by the Meta SDK to use full body avatars).
I did just what I said, adding three spheres (scaled to fit the back of the hands and the head) and having them follow the tracked elements (hands and head) when not in pause mode.
About that: I repurposed the "left hand thumbs down" gesture, that previously stopped the (now disabled) enemies, to a pause/unpause switch. Just something for testing, while we figure out the details about how to activate/deactivate the pause.
Of course, I didn't just parent or set the pose of these new nodes to the pose of the tracked ones. I started to structure different components with different responsibilities, like this:
`
PlayerAvatarBhv
` hold the references to the scene nodes in charge of displaying the avatar (currently, the three spheres - soon, a proper avatar)`
GameState
` holds a `PlayerAvatar
` member that contains the information needed to display the avatar, so that we're going to be able to rewind the gameplay, or to know the pose in which a savegame was taken (that we're going to match to resume playing)`
PlayerAvatarManagerBhv
` acts like a bridge between `GameState
` and `PlayerAvatarBhv
`:can be queried by `
GameState
` for new input data coming from the sceneupdates `
PlayerAvatarBhv
` on the basis of the `GameState
`
The key principle is that the avatar visualization does not directly come from the input data, but from the game state (which is updated by the input data). We'll make use of this very soon.
Maybe even tomorrow!
2023-12-08 - Reworking the energy bars
Yesterday I started the process laying the basic structure and adding some placeholder objects to represent the hands and head.
But we also need to fit into the new structure the elements we already had: the energy bars showing the health and mana levels, for example.
As they are related to the gameplay state, they should only be part of the gameplay avatar.
For the occasion, I will also change a bit their positioning, which I've never liked very much.
I restructured the code and the scene nodes handling the energy bars so that they fit the new structure. There is still some refactoring to do to have the player logic correctly use the ` GameState
`, but I'm getting there.
See you in a week!