Just like our previous post on arrays, it is challenging to talk about these concepts in a way that is meaningful. As an aside, I feel that all programming is like this. A new topic can be discussed, but until a real world applicable situation can be talked about in the same context it means less to the learner, making it more difficult to “get it”. I, personally, haven’t used maps generally in my game development journey.
Just like our previous post on arrays, it is challenging to talk about these concepts in a way that is meaningful.
As an aside, I feel that all programming is like this. A new topic can be discussed, but until a real world applicable situation can be talked about in the same context it means less to the learner, making it more difficult to “get it”.
I, personally, haven’t used maps generally in my game development journey. But I do use them in one specific instance. However, this specific instance is another challenging topic in computer science, and I hesitate to fully discuss the topic here. But I do feel that introducing them together, has the tie in mentioned above to help someone “get it”.
So, as this post is a two-for-one, let’s introduce our second topic. The Finite State Machine, even as a seasoned software developer, I often find my self revisiting/relearning how this works (so don’t feel bad if it gets a mite complex and hard to understand).
In a game, there is the main loop. With the case of ebitengine, there is the update portion and the draw portion. When we start animating our sprites, we’ll be drawing a different image for each frame change. This frame change can be controlled by an animation component; say for every 1/6 of a second, we can change the frame to the next one and loop back. Hopefully you’ve followed along so far. But there is a catch (there is always a catch isn’t there).
The catch is, our game object can be in different states. Lets think about the game that I’ve introduced before (that I’m working in class on building). This is the submarine game.
Our player object is the ship above that can drop depth charges on submarines below. Let’s think for a minute about the states that our ship can be in.
This is the default state. It isn’t actively doing anything else. However, there is an animation that could be run for it, bobbing in the ocean. We want to be in this state if we’re not in any other state.
This is the state we’re in if the player is moving the ship object forward. I imagine a somewhat similar bobbing animation, but with a slight favor to the front of the ship being angled up. Once the player stops moving forward, it should return to the idle animation.
This is the state we’re in if the player is moving the ship object backward. I imagine a somewhat similar bobbing animation, but with a slight favor to the back of the ship being angled up. Once the player stops moving backward, it should return to the idle animation.
This is the state our ship object is in if it is hit by a torpedo from below, any state can transition into this state and must. In most games this is indicated by either a flashing in and out, or a flashing regular and white. This is where the real use of a finite state machine will come in. We don’t want any other animation to display, until we’re done with this animation.
This can be rather challenging to understand, if you’re just reading it. Let’s look at a diagram that represents this concept.
This diagram represents the list from above. There are two types of things in our diagram.
A couple of things to think about. The states represented in our diagram are the state of our animations. When we’re in the idle state, we want to show our idle animation. When we’re in the other states we want to show their associated animations. Also, we only want to transition between states in instances where there is a verb connecting the two. This is specifically important, noticing, that the only way out of the Hit state is once the animation is complete.
Before we can start talking about representing our states in code, we need to look at how maps work. It will work best, to put this into code. So we’ll look at the code and then comments to help make sense of it.
package main
import "log/slog"
// NameAgeMap is a map that uses strings for the keys and ints for the value.
// When defining a map, it will loop like this map[typeA]typeB
var nameAgeMap map[string]int
func main() {
// This is how we actually instantiate the map (a fancy way of saying creating the memory for it in your program),
// the make keyword takes a map definition
nameAgeMap = make(map[string]int)
// This is how we assign a value to a key in the map, the square brackets `[]`, with the specified type inside the bracket,
// and the desired value on the right of the assignment operator `=`
nameAgeMap["Bob"] = 48
nameAgeMap["Doug"] = 52
// This is how we can access a value from the map, we use the square brackets to access the
// map with the key inside the brackets, this can provide an empty value though, so...
bobsAge := nameAgeMap["Bob"]
slog.Info("Bob's info", "age", bobsAge)
// This is how we check for a value in a map, and determine if the map contains the value.
// The second variable, ok, is a boolean to indicate if the key was in the map.
// Your code can change, read have an if-else statement, to have different behavior
// depending on if the map contains the value or not
nancysAge, ok := nameAgeMap["Nancy"]
slog.Info("Nancy's info", "has value", ok, "age", nancysAge)
// We can loop over the map, and access the keys and values with our range for loop
for key, val := range nameAgeMap {
slog.Info("Name Age Map", "Key", key, "Value", val)
}
// Here, we can define and initialize a map in one step, this works if
// you know your keys and value before hand.
nameColorMap := map[string]string {"Bob": "Green", "Doug": "Red Red"}
slog.Info("Names and colors", "map", nameColorMap)
}
This has been quite the discussion, before we’ve even looked at any code in go. And there are just a few more things that we need to think about. We need a data type to represent our states and a data type to represent our verbs. We can use go type declarations (a poor way to do enums in my opinion, but that is a later discussion) to create the types that we need.
Once we have our types (states and verbs) defined, we can create a map, as discussed above to represent our state machine.
package main
import "log/slog"
// ShipState represents the valid states a ship can be in
type ShipState int
const (
ShipIdle ShipState = iota // iota will increment an integer value for each item in this const block
ShipForward
ShipBackward
ShipHit
)
func (ss ShipState) String() string {
switch ss {
case ShipIdle:
return "Idle"
case ShipForward:
return "Forward"
case ShipBackward:
return "Backward"
case ShipHit:
return "Hit"
default:
return "Unkown State"
}
}
// ShipInput is the set of verbs that can transition between states
type ShipInput int
const (
InputStop ShipInput = iota
InputMoveForward
InputMoveBackward
InputHit
InputHitComplete
)
func (si ShipInput) String() string {
switch si {
case InputStop:
return "Stop"
case InputMoveForward:
return "MoveForward"
case InputMoveBackward:
return "MoveBackward"
case InputHit:
return "Hit"
case InputHitComplete:
return "HitComplete"
default:
return "Unkown Input"
}
}
// A map is just a type, so we can have maps of maps.
// Its turtles all the way down -> https://en.wikipedia.org/wiki/Turtles_all_the_way_down
var shipStates = map[ShipState]map[ShipInput]ShipState {
ShipIdle: {
InputMoveForward: ShipForward,
InputMoveBackward: ShipBackward,
InputHit: ShipHit,
},
ShipForward: {
InputStop: ShipIdle,
InputHit: ShipHit,
},
ShipBackward: {
InputStop: ShipIdle,
InputHit: ShipHit,
},
ShipHit: {
InputHitComplete: ShipIdle,
},
}
/*
Let's break this code above down just a little bit.
We have the states, as the keys of the outside map.
For the inside maps, we have the verbs (inputs) and the states that they're associated with.
We will store the current state, then when a change happens, we will load the inside map from the key of the current state.
Then check if this state responds to the input, if so, set that as the current state. If not, we'll just move on.
Our example in main will be a bit contrived (as we won't have a real game state to interact wiht), but we should see how it works.
Then, we can just migrate this state map, to our update method for our ship, and handle our states in roughly the same way.
*/
var currentState ShipState
func main() {
currentState = ShipIdle
// Here is a slice of simulated Inputs, in a game world we'd
// get these inputs from either a keypress or some in game interaction
simpulatedInputs := []ShipInput{
InputMoveForward, // Would be a key right
InputStop, // Would be no key pressed
InputMoveBackward, // Would be a key left
InputHit, // Would be a collision detection in the update loop
InputStop,
InputMoveForward,
InputStop,
InputHitComplete,
InputMoveBackward,
InputStop,
}
for _, input := range simpulatedInputs {
slog.Info("Current State", "state", currentState)
updateState(input)
slog.Info("State Info", "Input", input, "Current State", currentState)
}
}
// The challenge with this setup is, it isn't a full game environment, so it is difficult to discuss
// this concept of changing our states, due to game interaction but we can put together a simulated update method
func updateState(input ShipInput) {
// There are a couple of things going on here, and something new.
// The new thing -> We're joining a map lookup and an if statement in one.
// Our declaration of variables go first, then the check happens after the ; 'semicolon'
// Here we're also, indexing into thoutside and inside map in one call
if newState, ok := shipStates[currentState][input]; ok {
currentState = newState // If we have the new state, as our old state responds to the input, we will set the newState to the currentState
}
}
2024/03/30 11:13:18 INFO Current State state=Idle
2024/03/30 11:13:18 INFO State Info Input=MoveForward "Current State"=Forward
2024/03/30 11:13:18 INFO Current State state=Forward
2024/03/30 11:13:18 INFO State Info Input=Stop "Current State"=Idle
2024/03/30 11:13:18 INFO Current State state=Idle
2024/03/30 11:13:18 INFO State Info Input=MoveBackward "Current State"=Backward
2024/03/30 11:13:18 INFO Current State state=Backward
2024/03/30 11:13:18 INFO State Info Input=Hit "Current State"=Hit
2024/03/30 11:13:18 INFO Current State state=Hit
2024/03/30 11:13:18 INFO State Info Input=Stop "Current State"=Hit
2024/03/30 11:13:18 INFO Current State state=Hit
2024/03/30 11:13:18 INFO State Info Input=MoveForward "Current State"=Hit
2024/03/30 11:13:18 INFO Current State state=Hit
2024/03/30 11:13:18 INFO State Info Input=Stop "Current State"=Hit
2024/03/30 11:13:18 INFO Current State state=Hit
2024/03/30 11:13:18 INFO State Info Input=HitComplete "Current State"=Idle
2024/03/30 11:13:18 INFO Current State state=Idle
2024/03/30 11:13:18 INFO State Info Input=MoveBackward "Current State"=Backward
2024/03/30 11:13:18 INFO Current State state=Backward
2024/03/30 11:13:18 INFO State Info Input=Stop "Current State"=Idle
As I’ve been working on this blog and the course that I’ve been teaching in real life, I’ve developed a library to work with some of the common use cases that I’ve run into. One of them, is running a finite state machine for the different game objects that I’m building. So, I have a library called, go2d to help with this. There is a struct StateMachine that works through this. It uses generics of golang to have a specific state and input type. Generics are beyond the scope of this post, but you can follow the link and start learning about them on your own time.
I learned early on, that unless I conected what I learned to what I needed to do to complete a problem, I really struggled assimilating that new knowledge. We covered two topics today, maps and finite state machines, so it is understandable if you’re feeling a bit overwhelmed. I think the most important part of this, is that you’re learning something new and familiarizing yourself with it. From here, should a problem arise that needs a solution like this, you’ll have an introduction in your mind that you can turn to.