Payton.Codes

Musings on life, games, and code

Enemy State Machines in Lost Card

This summer I made a simple game called The Lost Card using TypeScript and the Phaser framework. I wanted to write a little about some of the things I learned during that process. This post is about enemy behavior.

Once I had a character I could move around my world, I needed to have enemies they could encounter, and those enemies needed some way to encode what they would do. This is one of many things I didn’t know how to approach in gamdev.

Looking up how to write enemy behavior in games turns out to be one of those things that’s complex enough that there’s practically a whole field devoted to it, but the tutorials I found were not helpful for this poor beginner.

Writing simple logic for enemies that walk around was pretty easy, but how about for a boss which chains a bunch of behaviors together? How about reusing those behaviors for different enemies?

Ultimately, I made up my own system based on a Finite State Machine (FSM). It’s probably not the best but I’ve been surprised at how flexible it is and it enabled me to make some pretty cool enemy logic without a lot of work.

The Behavior type

Here’s how behavior in The Lost Card works.

Each enemy has a currently active state (just a string) and a function called constructNewBehaviorFor() which is called by the enemy base class on every update when the current active state changes.

For example, the Mountain Kingdom Boss marches back and forth and throws rocks and it marches faster and throws more rocks when its hit points have been reduced below half.

constructNewBehaviorFor(state: AllStates) {
	const isBelowHalfHP = this.hitPoints < 5;
	switch (state) {
		case "leftrightmarch":
			return new LeftRightMarch(state, "throwrocks", {
			speed: isBelowHalfHP? 100 : 80,
		});
		case "throwrocks":
			return new ThrowRocks(state, "leftrightmarch", {
			speed: 500,
			rockCount: isBelowHalfHP? 5 : 3,
			delayBeforeEnd: 1200,
			delayBetweenRocks: isBelowHalfHP? 450 : 600,
		});
	}
}

constructNewBehaviorFor() is passed the active state and must return a new Behavior, which is a relatively simple object interface that has init() and update() methods.

interface Behavior<
	Sprite extends Phaser.GameObjects.Sprite,
> {
	name: string;
	init(
		sprite: Sprite,
		goToNextBehavior: (key: string) => void,
		worldData: WorldData
	): void;
	update(
		sprite: Sprite,
		goToNextBehavior: (key: string) => void,
		worldData: WorldData
	): void;
}

The init() method is called when the Behavior is created and the update() method is called each time the enemy sprite updates as long as the state has not changed.

These methods are passed a reference to the enemy sprite itself as well as a data object that contains references to the player, map, other enemies, etc.

That’s basically it, and this simplicity is what makes the system so powerful, but there’s a few other key pieces.

Getting the next behavior

Each Behavior is also passed a string which refers to the next behavior and a callback to change the behavior. When a behavior is finished, it will use the callback to move to the next behavior, but the enemy class decides what that next behavior is.

In a more traditional FSM , each state has hard-coded states that it transitions to, but that presented a problem for my enemies. They need to combine states in arbitrary orders.

By making the next state an argument, each enemy can activate whatever behaviors it likes inside constructNewBehaviorFor() and decide the order of those behaviors itself. That order can even be dependent on other factors like the enemy’s current hit points or a random number.

Here’s the function for a relatively simple enemy, the Ghost. You can see that it has two behaviors: “wait” and “follow“.

function constructNewBehaviorFor(state: string) {
		switch (state) {
			case "wait":
				return new WaitForActive(state, "follow", {
					distance: this.awareDistance,
				});
			case "follow":
				return new FollowPlayer(state, "wait", {
					speed: this.speed,
					awareDistance: this.awareDistance,
				});
		}
}

The “wait” arm runs the Behavior WaitForActive which monitors the distance to the player and moves on to the next behavior when the distance is smaller than a certain number. It is passed “follow” as the next behavior, so when it detects the player being close enough, it begins the FollowPlayer Behavior. That code moves the enemy toward the player at a specific speed as long as the player remains within a certain distance. If the player gets too far away, the Behavior ends and moves to its next state, which is "wait".

Keeping state

One more relevant piece is that some behaviors need to keep state and interact with each other. Fortunately, in Phaser, each sprite can hold arbitrary data in a registry. Since the Behavior has a reference to the sprite, it can use that registry as a way to store its own state which can then be accessed by that Behavior or other Behaviors in the future.

For example, some enemies have the LeftRightMarch Behavior which walks back and forth horizontally. However, I didn’t want to have the enemy repeat the same choice as the last time it ran, so I stored the previous movement direction in the sprite’s data and made sure to go in the opposite direction if it exists.

function init(
		sprite: Phaser.GameObjects.Sprite,
		goToNextBehavior: (key: string) => void,
): void {
		const previousDirection: SpriteDirection | undefined =
			sprite.data.get("direction");
		const direction = (() => {
			if (previousDirection) {
 				return invertSpriteDirection(previousDirection);
			}
			return Phaser.Math.Between(0, 1) === 1 ? SpriteLeft : SpriteRight;
		})()
		sprite.data.set("direction", direction);
		switch (direction) {
			case SpriteRight:
				sprite.anims.play("right", true);
				sprite.body.setVelocityX(this.#enemySpeed);
				break;
			case SpriteLeft:
				sprite.anims.play("left", true);
				sprite.body.setVelocityX(-this.#enemySpeed);
				break;
		}
		sprite.scene.time.addEvent({
			delay: this.#getWalkingTime(),
			callback: () => {
				sprite?.body?.setVelocity(0);
				goToNextBehavior(this.#nextBehavior);
			},
		});
}

Because each Behavior is directly created by the enemy, it can receive parameters to customize its effects, like the walking speed for LeftRightMarch. The SummonCircle Behavior accepts a callback which creates another enemy that exists only for the life of the current enemy, but the summoned enemy can be any other creature in the game.

An even more complex example is the final boss which has all sorts of behaviors. One of the more interesting ones is the way it uses its summoned black orb creatures.

The SummonCircle behavior stores references to each new enemy it creates and then the FlingSummon behavior launches one of those summoned creatures at the player. If the summoned creature has been destroyed by the time FlingSummon is run, it will not be able to make an attack.

There’s definitely ways that this system could be improved, but it was extremely useful for what I needed to do in my game. In the future I’d like to learn more about how to properly use Behavior Trees to accomplish these tasks.

Photo by Sven Mieke on Unsplash


Comments

Leave a comment