Site icon The Walrus Game Project

Project Delve Part 6: Turn System

Alright, now we’re getting into the real juicy stuff — making systems for our game to use! If you’re unfamiliar with the term systems in this context, a short explanation is this: Systems are pieces of code that are used for higher-level functionality in the program. They’re responsible for tying various related parts of the program together. Think of an office building: these generally have electricity, air conditioning, heating, plumbing, security, etc. Each of these things can be considered a system within the overall building, each affecting parts of the building in their own way.

The system we’ll be focusing on in all of the Part 6 posts is the Turn System (yes, this is going to be a multi-post topic). How will the game know whose turn it is, what they can do, what happens after that, and what triggers it? Let’s dive in.


Game Flow

The first thing to establish when figuring out a game’s flow are its states, and what moves the game through the states (transitions). This is known as the State Machine pattern. The State Machine pattern is a very popular game programming pattern because games are full of states and transitions. Actions the user can and can’t take depend on the state of the game, and states change when a transition happens.

As an example, take a generic FPS game. For the sake of the example I’ll keep it simple. The player may have two states:

  1. Player has ammo
  2. Player is out of ammo

While the player is in the first state (has ammunition), they can fire their weapon. If they are in the second state (out of ammunition), they cannot fire their weapon. The transition between S1 and S2 would be triggered by the amount of ammo a player has left: if the player’s ammunition is equal to zero, transition to S2. The transition between S2 and S1 would be triggered by an action: if the reload action is taken while in S2, transition to S1.


Diagraming the states and transitions

Using an online flowcharting tool, I created the following diagram based on the Descent rulebook.

The game always starts with the heroes. After the heroes all take their turn, it’s the overlord’s turn. The overlord starts their turn by “drawing a card” (something that I may or may not keep in this game, we’ll see), and then activates each monster group. When a monster group is activated, each monster in that group takes its turn. After all the monster groups have activated, the overlord’s turn is over and it’s back to the heroes turn.

One thing that I didn’t diagram correctly is that the check for whether any of the objectives have been completed only happens after the overlord’s turn, when in reality it should be happening at the end of each individual hero turn and each individual monster turn, but that’s the only caveat.


Coding The State Machine

I’ll start by saying that while I have been enjoying using Godot, GDScript is not a contributor to that enjoyment. It’s true that Godot technically supports C# scripting (which I much prefer) but I tried it briefly and it turned out to be more trouble than it was worth to use. I like that GDScript is optimized directly for the game engine, but overall it hasn’t been my favorite language to work with.

Rant aside though, one of the reasons I dislike GDScript is due to its poor object-oriented structure. It’s very much a scripting type language (as the name would suggest) and as such it doesn’t make it easy to create and use regular generic objects or create a standalone system architecture. So I struggled for a while to figure out how to make a simple state machine that fit with the GDScript paradigm instead of fighting against it, and I think I found a workable solution.

Let’s start by creating the StateMachine class.

# state_machine.gd

extends Node

class_name StateMachine

# Passes with parameters: new state name
signal changed_state

# The current state
var current

func change_state(new_state):
    current = new_state
    emit_signal("changed_state", new_state.name)

This works, but doesn’t do much at the moment. It only holds a reference to a variable that could be anything, and the state itself doesn’t know whether or not it’s active. This is important to know because we likely will want to alter the game in some way when the state gets changed. We could of course have the states connect to the changed_state signal and check if the new current state is itself, but that would get very messy very quickly. Instead, let’s create a State class that has an enter_state() and exit_state() method. Then when change_state() is called on the StateMachine, we can just call exit_state() on the current state and enter_state() on the new state.

# state.gd

extends Node

class_name State

signal state_entered
signal state_exited

func enter_state():
    emit_signal("state_entered")

func exit_state():
    emit_signal("state_exited")

    # After exiting the state we can remove it from the scene tree.
    # Otherwise we'll have memory leaks as unused objects stack up.
    queue_free()
# state_machine.gd

func change_state(new_state):
    if current != null:
        current.exit_state()

    current = new_state

    if current != null:
        current.enter_state()
        emit_signal("changed_state", current.name)
    else:
        emit_signal("changed_state", null)

And just like that we have a simple state machine with states that know when they’ve been entered and exited!


Expanding the functionality

Because some of our states need to keep track of their own sub-states, we need to give them their own state machines to use. It would also be convenient to have a reference to the StateMachine owning the state from within the state itself too. That way when a state knows it should update, it can call the change_state() function on its parent state machine to move to the next state. Let’s add these options to the State class. (Have I said “state” enough?)

var _parent: StateMachine
var _state_machine: StateMachine

func _init(owning_state_machine: StateMachine):
    _parent = owning_state_machine
    _state_machine = StateMachine.new()

    # We want to add the state machine as a child to this Node so that
    # when it's freed, the state machine will be freed as well.
    add_child(state_machine)

func _change_state(next_state: State):
    _parent.change_state(next_state)

To test that this is working, let’s create a new scene and add a script onto the root Node that extends StateMachine, as well as two inner classes that extend the State class (these don’t need to be attached to any nodes since they’ll be created and destroyed dynamically). Add a method to the StateMachine-derived class that calls change_state() with a new instance of one of the state classes we just created. From that state class, inside the enter_state() method, we’re going to call _change_state() with a new instance of the second state class. In each of the enter_state() and exit_state() methods, add a print() statement indicating we just entered or exited the state.

In code it’ll all look something like this.

# my_state_machine.gd

extends StateMachine

func test_state_machine():
    print("Starting state machine")
    change_state(State1.new(self))

class State1:
    extends State

    func enter_state():
        # Putting a period before the function name will call the
        # super-class's method
        .enter_state()
        print("Entered state 1")

        # Passing a reference to the parent state machine to the next state
        _change_state(State2.new(_parent))

    func exit_state():
        .exit_state()
        print("Exited state 1")

class State2:
    extends State

func enter_state():
    .enter_state()
    print("Entered state 2")

Now all that’s left to do is add a button to the scene and connect its pressed signal to the test_state_machine() method! Before running the code, let’s first think about what we expect the output to look like. First, the print() method in the test_state_machine() method will run. Then we would expect to see “Entered state 1” as the enter_state() method is called on the state. We would then expect to see “Exited state 1” and then “Entered state 2.” Let’s test it out and see!


Implementing The Game Flow

Now that we can see that our state machine works as expected, let’s scaffold up the game flow based on the diagram at the start of the post. Due to the number of state classes I’m not going to post the contents of every script, but I will point out a few key things.


Purple state blocks

First of all, we have the purple state blocks. These states will be the ones that make use of their own state machines to create sub-states. These “super” states will want to monitor when their sub-states change so that when the EndPhase state is entered, we can make the appropriate check for whether we should initiate another round or move on to the next phase. I’ll demonstrate this with the HeroGroupPhase state.

# hero_group_phase.gd

extends State

class_name HeroGroupPhase

# The syntax here of _init().(): is just calling the super method of _init
# (like calling the super constructor)
func _init(sm: StateMachine).(sm, "HeroGroupPhase"):
    _state_machine.connect("changed_state", self, "_on_sub_state_machine_change_state")

func enter_state():
    .enter_state()
    print("Hero group turn started!")
    # Make the parent state machine of the sub-state this class's
    # state machine
    _state_machine.change_state(HeroStartPhase.new(_state_machine))

func _on_sub_state_machine_change_state(new_state_name: String):
    if new_state_name == "HeroEndPhase":
        print("Have all heroes been activated?")
        # !! Check if any heroes still need to take their turn !!
        _change_state(OverlordPhase.new(_parent))

When the HeroGroupPhase state is entered, it starts the sub-state machine of the hero turn. When the state machine changes states, the HeroGroupPhase state checks the new state. If it’s the HeroEndPhase state we make our check and then proceed according to the flowchart. (In practice these sub-states will receive which hero’s turn they’re acting on, among other things, but since I don’t have that coded out just yet I’m leaving it be for now.)


Orange state blocks

The orange state blocks represent states that will immediately change themselves to the next state. They all look almost identical to this.

extends State

func _init(sm: StateMachine).(sm, "OrangeStateType"):
    pass

func enter_state():
    .enter_state()
    _change_state(NextState.new(_parent))

“But why make a state that is only transitory and has no self-contained functionality?” I hear you ask. Well the reason for these “empty” states is so that any actions that trigger “at the beginning of the hero’s turn” or “at the end of the hero’s turn” can listen for these states to be entered. We may not end up needing them because I may find a better way to do the same thing, but it works for now!


Green state blocks

Green state blocks are where the actual gameplay will happen. These states will not auto-advance to the next state until certain conditions are met. Because they are much more complex, I’ll be explaining these in more depth in the following post.


The Result

After implementing each of the states in my diagram, this is how the flow of the state machine looks now.

So that’s all for today. In the next post, like I mentioned in the above section, we’ll be diving deeper into what will happen in the “functional” states. Primarily we’ll be focused on the HeroActionPhase.

The code for this lesson can be found here.

Exit mobile version