Making of Castle Tintagel, Part 2
Finite State Machines
Hi, Pixelated Opus here and today I would like to continue the discussion of the development process of my playdate game titled "Castle Tintagel".
Finite state machines are commonly used in game development to control and organize logic flow. It is commonly used in implementation for AI(its worth noting that there are other ways to implement AI, i.e behavior trees, but for the purposes of my game FSMs worked perfectly fine). You can model AI behavior of an enemy using states( "Attack", "Run", "Evade" , etc )
Visually, each state can be represented by a node and the edges between them the transitions. The transitions are the conditions required to move into another state or change states. A well implemented FSM guarantees that an entity will only be in 1 state at a given time.
There are many ways to implement an FSM. I am going to show you my method that I was originally introduced to me at my Game Development internship . I have since modified the original pattern and adapted it, but at its core it remains the same.
Its quite simple, we define a set of states via an enumeration of a table in LUA.
local PlayerState <const> = { IDLE = 1, RUN = 2, JUMP = 3, ATTACK = 4, BLOCK = 5, ... }
We then implement two functions that will set and finalize the state change.
function Player:SetState(state) if self.requestedState ~= state then self.requestedState = state; end end function Player:FinalizeStateChange() if self.state ~= self.requestedState then self:OnExitState(self.state); self:OnEnterState(self.requestedState); self.state = self.requestedState; end end
Finalizing a state change is done at the end of the update tick. This ensures we only actually attempt to exit and enter a new state once per update.
function Player:OnEnterState(state) if state == PlayerState.RUN then self:OnEnterRun(); elseif state == PlayerState.IDLE then self:OnEnterIdle(); elseif state == PlayerState.JUMP then self:OnEnterJump(); elseif ... end
This is a nice way to keep things tidy. You can ensure logic only runs when you are in a given state and not muddy your update loop. I use this pattern often to control logic flow of various entities and systems within my game. You can use this to control the logic flow for interacting with doors, driving enemy logic, player logic, UI transitions, and so on.
You can also extend the enter and exit state functions to take in the previous and next states. That way if you special behavior when transitioning from Run->Idle vs Attack->Idle or Jump->Idle, you can account for that.
Here is sample attack logic from the Frog enemy found in the Underground Cave.
function Frog:OnUpdateAttack() local elapsed = pd.getCurrentTimeMilliseconds() - self.frogAttackStartTimeInMs; local t = elapsed / self.frogAttackDurationInMs; t = math.abs((2.0 * t) - 1.0); t = 1.0 - t; local offsetX = self.facingDir * t * ATTACK_DISTANCE; self.tongue:SetPositionOffset(offsetX, 0); local xPos, yPos, width, height = Game:GetPlayerBoundsRect(); if self.tongue:CheckIntersectsRect( xPos, yPos, width, height ) then Game:DamagePlayer(self.attackDamage, self.tongue:GetEndXPos()); end if elapsed >= self.frogAttackDuration then self:SetState(FrogState.IDLE); end end
This pattern is mostly for organization. The logic for updating the frogs attack is guaranteed to only run during the attack state. The update function is not bloated with a bunch of branching logic. For example:
DONT DO THIS! :) function Frog:update() Frog.super.update(self); if self.state == FrogState.Attack then if self.justEnteredAttack then self.justEnteredAttack = false; -- initialize attack state vars end end end
DO THIS INSTEAD :) function Frog:update() Frog.super.update(self); self:OnUpdateState(); self:FinalizeStateChange(); end
Enemies
Enemies in Castle Tintagel are all implement in their own lua files, but extend from a common base Enemy class. The base enemy class handles movement, collision, taking damage and sets up some common vars like health and damage.
Each derived enemy object needs only implement their own specific behaviors. I have left the movement fairly flexible. Each derived enemy can overwrite values that make it easy to implement unique behaviors. For example, here is the movement code in the base enemy class.
-- compute velocity self.velocity.y += self.gravity * self.gravityScalar; self.velocity.x = Clamp(self.velocity.x, -self.maxSpeed, self.maxSpeed); self.velocity.y = Clamp(self.velocity.y, -self.maxVerticalSpeed, self.maxVerticalSpeed); -- apply movement local posX, posY = self:getPosition(); local targetXPosition = posX + self.velocity.x; local targetYPosition = posY + self.velocity.y; if self.targetPositionOverrideY then targetYPosition = self.targetPositionOverrideY; end if self.targetPositionOverrideX then targetXPosition = self.targetPositionOverrideX; end
You want a flying enemy? Set self.gravityScalar to 0.0. in the derived enemies init function. You want to control the position yourself completely? Use the position override vars (this is extremely flexible for some custom movement) You can even set an overlapAll flag to make enemies overlap everything in their collision response, useful for enemies that don't collide with walls.
As with everything, there are many different ways to solve problems, always choose the solution that provides flexibility and works for your project.
There is plenty more to cover in the upcoming blog segments, so I hope you enjoy reading along and feel free to drop any questions in the chat.
Until next time! 😊
-PixelatedOpus
Get Castle Tintagel
Castle Tintagel
More posts
- Castle Tintagel - Accessibility SettingsJun 20, 2023
- Making of Castle Tintagel, Part 1Jun 18, 2023
- Castle Tintagel - Playdate LaunchJun 16, 2023
Leave a comment
Log in with itch.io to leave a comment.