D20 RPG – Game Flow

So far our project can go from a loading screen to a main menu. You can “start” a “New Game” but nothing is really happening under the hood. The AppFlow will just start another pass of its own loop and show the menu once again. In this lesson we will connect the menu selection to the process of actually creating a game.

Getting Started

Feel free to continue from where we left off, or download and use this project here. It has everything we did in the previous lesson ready to go.

Game System

Let’s use a “system” to handle what it means when we start a “New Game” or “Continue” a saved game. Create a new folder in Assets -> Scripts named Game. Next create a new C# script in the new folder named GameSystem. Add the following:

using Cysharp.Threading.Tasks;

public interface IGameSystem : IDependency<IGameSystem>
{
    UniTask NewGame();
    UniTask ContinueGame();
}

public class GameSystem : IGameSystem
{
    
}

We start with an injectable interface named IGameSystem which defines the two methods we would invoke based on the main menu selection. The class GameSystem will conform to this interface.

Add the following snippets inside the GameSystem class:

public async UniTask NewGame()
{
    var dataSystem = IDataSystem.Resolve();
    dataSystem.Create();
    await UniTask.CompletedTask;
}

We will use the NewGame method to coordinate with our DataSystem so that we create a new Data object. In the future, we can also use this method to do whatever kind of initial setup of our data that will be needed, such as creating a hero entity and determining an initial story entry.

You may have noticed that NewGame is an async method, but we aren’t actually doing anything that requires it. In the future I know that I will be using some async features, such as creating the hero from assets that are loaded via Addressables. For now, I simply await a “completed task” as a placeholder.

public async UniTask ContinueGame()
{
    IDataSystem.Resolve().Load();
    await UniTask.CompletedTask;
}

The ContinueGame method is probably pretty obvious. Like before, we coordinate with the DataSystem so that our game’s data will be loaded. I also await a “completed task” here as a placeholder in case I also want async handling in the future.

Game Flow

Create a new C# script at Assets -> Scripts -> Flow named GameFlow and copy the following:

using Cysharp.Threading.Tasks;

public interface IGameFlow : IDependency<IGameFlow>
{
    UniTask Play();
}

public class GameFlow : IGameFlow
{

}

Once again we started with a simple injectable interface, IGameFlow. It is super simple, merely defining a singe method named Play – just like we did for the MainMenuFlow.

Add the following snippets inside the GameFlow class:

public async UniTask Play()
{
    await Enter();
    await Loop();
    await Exit();
}

While experimenting with using async “flows” as a replacement for the state machine pattern, I decided that I would at least carry over one idea, and that is the simple interface of it. A State does something when it enters, it often has an update loop, and it does something when it exits. Of course you aren’t required to implement these methods, but as a general idea of what to expect, I feel like it may help keep my code intuitive and my thoughts organized.

All we are really doing is calling each of those steps in sequence, but it helps visually document what happens and when.

async UniTask Enter()
{
    var option = await IMainMenuFlow.Resolve().Play();
    switch (option)
    {
        case MainMenuOption.Continue:
            await IGameSystem.Resolve().ContinueGame();
            break;
        case MainMenuOption.NewGame:
            await IGameSystem.Resolve().NewGame();
            break;
    }
}

When a GameFlow “enters” we want to show the main menu. We use the IMainMenuFlow to handle the specifics of that process. The result of the main menu selection then goes through a switch statement so that we can route logic based on it. We use our new GameSystem (because it will be injected to the corresponding interface) to do the real work of creating or loading the game based on the user’s choice.

async UniTask Loop()
{
    await UniTask.CompletedTask;
}

The Loop method will be the main game loop, where we handle exploration and encounters as necessary until the game has been won or lost. For now, we just leave this as a stub for where that logic will go in the future.

async UniTask Exit()
{
    IDataSystem.Resolve().Delete();
    await UniTask.CompletedTask;
}

After determining that the game is “complete”, we can delete the current game data and exit the game flow. The application flow can decide to start another game flow if it wants to.

Injection

We created two new classes that need to be injected. For our GameSystem, we will add it to the root level injector. Open Injector and add the following:

IGameSystem.Register(new GameSystem());

For our GameFlow, we will use a nested level injector. Open the FlowInjector and add the following:

IGameFlow.Register(new GameFlow());

App Flow

Now we must connect our GameFlow with the AppFlow. Open the AppFlow script and replace this:

await IMainMenuFlow.Resolve().Play();

with this:

await IGameFlow.Resolve().Play();

Assuming everything has been implemented correctly, you may now play the experience starting from the Loading Screen and see the Main Menu. Really it should look basically equivalent to what we had in previous lessons, except that now you know it is connected to “real data”.

Summary

In this lesson we created a new “system” and “flow” for the “game”. The game system handles any logic specific to creating or loading game data, and the game flow handles things like showing a main menu screen, playing the game, and clearing the game when finished.

If you got stuck along the way, feel free to download the finished project for this lesson here.

If you find value in my blog, you can support its continued development by becoming my patron. Visit my Patreon page here. Thanks!

Leave a Reply

Your email address will not be published. Required fields are marked *