4
\$\begingroup\$

I am currently experimenting with creating a turn based game using Unity and am trying to come up with a sensible architecture for structuring my game systems.

So far I have decided to base everything around Game states. A game state represents a sort of "mode" of the gameplay, for instance "Select unit state" or "Game over state". A global game state manager holds a stack of these game states and can be called with pop (remove top game state), push (push the provided game state onto the stack) and replace all (pop all existing game states and push provided game state). Update is called on the top most element of the stack every logic frame.

I also have Entities which exist on the scene and represent stuff like as game units, terrain, projectiles etc. These are standard gameObjects with behaviors, unity lifecycle etc.

Finally there are a bunch of services which provide methods to handle stuff like menus and camera.

The idea is that the game states handle the "logic" of the game such as player input, transitions between game states, calling the services and controlling/modifying entities.

I also use events to facilitate the communication between state, entities and systems to avoid coupling too much. These events are stuff like "Unit destroyed" and "Objective complete" which are listened to usually by entities, calls to move the camera, create entities and such are still called normally using the relevant service methods.

Whilst this system works in theory I am having some issues with the interactions between events and game state logic. Consider the following:

Update of performActionState:

void update() {
  // Do something.
  Messanger.fireEvent(EventType.ACTION_COMPLETE);
  GameSystemManager.pushState(new NewTurnState());
}

Objectives system listening for action complete:

void handleActionComplete() {
  if (checkObjectivesAllComplete()) {
    GameSystemManager.pushState(new GameCompleteState());
  }
}

What I want to happen is for the objectives system to push a game complete state and finish the game. What actually happens is that the handle action complete pushes the game complete state, the rest of the perform action state update runs and a new turn state is pushed.

The above example might be a bit simplistic but I see this as an underlying issue with my architecture. Most events would be fired due to a change in the state of the scene or completion of something, for instance a unit being destroyed or a unit entering a trigger area. The probability of the game logic needing to change state from the game states is quite high in these cases usually.

I could easily fix the above sample in a number of ways, for instance I could move the checking of objectives into the start of NewGameState and invoke a pushGameState there instead to end the game but all the ways I can think of at the moment seem like hacks which will greatly increase coupling (New game state doesn't need to care about objectives) and code spaghetti.

Any advice for ways I could potentially architecture around issues like this?

\$\endgroup\$
1
\$\begingroup\$

I think the difficulty here is that, because you have bits of non-state code (the objectives system in this case) interfering with normal operation of the state machine, the state machine will get tangled up by out-of-band state changes.

If I understand correctly, your performActionState is on a stack with some other state beneath it (I'll call it gameplayState). I would move both the game-ending logic from the objectives system and the GameSystemManager.pushState(new NewTurnState()); from performActionState into gameplayState. When performActionState ends, it will simply pop itself from the stack and let gameplayState decide what to do next.

If performActionState doesn't have such a useful parent state, then you could have it push an endTurnState that does the game-end logic and pushes the appropriate next state.

This seems to play to the strengths of your architecture because the state machine itself is responsible for detecting when to change state and what state to change to. If the current state (performActionState) can't do that, then it can get out of the way and let the next state handle it.

The objectives system could pass a "game over" event if you want to avoid direct coupling between it and the state machine.

\$\endgroup\$
0
\$\begingroup\$

Interesting question. Funny enough that is exactly what I was struggling with about a year ago.

What Victor suggested in his above answer is somewhat true. Queue is useful so I roll with it but suggest alternative approach to handling it.

Your problem which lies in multiple (tens or even hundreds) listeners reacting to your state change event asynchronously.

Let's break it down to simple (not necessarily real live) use case.

There are 3 components: A, B, C subscribed to a StateManager. A and B can trigger fire LoadLevel (X) event and C only reacts to LevelLoaded (X) event.

You fire LoadLevel (A) event from A. Level is loaded, LevelLoaded (A) is fired and C reacts to it. Same for component B. You fire LoadLevel (B) event from B. Level is loaded and C reacts to LevelLoaded (B).

It is all fine if everything is handled properly internally. What can go wrong, right? Problem lays in asynchronous nature of those actions.

Consider this:

  • loading levelA takes 10s
  • loading levelB takes 1s
  • you can fire loading each level at any time
  • you fired loading lavel A and then loading level B

Level B should be loaded overriding initial request of loading level A, right? Not to difficult to handle if you know what you are after.

In this scenario, you can obviously LevelManager that will handle it correctly but you would have to create some sort of manager for every single async action you want to deal with.

As dependencies between components grow, it becomes difficult to even spot the problem. It is error prone solution.

The best way would be extending your event manager to handle state change better. To do so, firstly, you might consider adding more events:

  • StateUpdateInit(GameState newState)
  • StateUpdating(GameState oldState, GameState newState)
  • StateUpdateComplete(GameState currentState)

It isn't a must but will give you bit more granular control.

More importantly, however, you will need couple additional parameters in your public method requesting state change.

  • ChangeState(GameState state, bool instant = false, bool async = false)

If method is called with default params you go about your state changing business as usual. If you call ChangeState(state, true, true) checking for instant == true should clear state queue and fire state change event. Additionally if async == true you should toggle lock flag on your state manager which will prevent adding new states to the queue until state transition is complete. It can be achieved with calling another method, such us:

  • CompleteState(GameState state)

Now you are save. Every time you know you want to fire async action just tell your StateManager to wait for completing the transition and other components will be prevented from triggering new states.

You can obviously extend it even more with pause and resume functionality, go back and forward methods and some other nice stuff but that is outside of the scope of this question.

Hope it helps.

\$\endgroup\$
0
\$\begingroup\$

A proper finite state machine doesn't just define the states themselves but also the transitions between them. When there is no direct transition from the GameCompleteState to the NewTurnState defined by the state machine, then this transition should simply not be possible.

That means state changes should never be triggered directly from any of your game services. They should always be triggered by the currently active state itself. For example, by having the State's update method poll all the game systems which might cause a change from this state to a different state. I could also see the entry-method of NewTurnState poll the ObjectiveSystem if the win- or loss-condition is fulfilled and when that's the case immediately transition to the GameCompleteState.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.