<- Wizwag DevLog
October 20, 2024

Let's Make a New Enemy!
100% Tektite-Free

I thought it might be fun to show the process of creating a new enemy. The process has evolved some throughout the course of development, and is reasonably simple.

However, we want to ship a large cast of unique enemy types, and we will need to further simplify this workflow to do so.

Step 1: Ideas and Sketching

First, we decide we want to make an enemy with some specific behavior. In this case, let's say we want to make a "tektite" sort of guy: He jumps around the room to some subset of valid tiles, waits for one of several timer lengths before jumping again, and he's hit by most weapons the player has.

Once the basic behavior exists, we can tweak it to make it more interesting, maybe.

Based on the behavior, we'll then go sketch some ideas for how the enemy could look, like this:

I didn't have a ton of great ideas for this guy, but this is enough to get going.

Step 2: Making [Placeholder?] Art

Next, I open up Aseprite and try to make some art based on one of the ideas. In this case, I picked the weird bacteriophage crab thing.

Sometimes, this goes poorly and I spend a couple hours arguing with myself, trying and failing to depict what I want! In those situations, I try to make myself just do some temp sprites, and use them until the art block goes away.

On this occasion, the art attempt turned out ok, so I'm moving forward with it. The next step is to export a sprite sheet and animation data. These are the settings I typically use:

  • Packed layout.
  • Trimmed cels.
  • Array for the tags rather than Hash.

The files will live somewhere like this: wizwag/assets/enemies/hopper/hopper.png

So far, we've spent about 40 minutes on this guy, inflated somewhat by trying to use Patreon's article editor...

Step 3: Creating a "Stub" of the Actor

At this point, we're ready to start creating our new Actor type. I am unhappy with how this process currently works. Nevertheless: The next step is to copy an existing enemy's source file, rename it, rename the type, and comment out, or remove, most of the code therein. :')

After this, I am left with a file with ~140 lines of code in it. Sheesh! To give some context, here's our new enemy's tick function:

method tick*(self: Hopper, scene: Scene, ctx: TickContext): void =
self.tickPartsDefault(scene, ctx)
#if self.altitude.bounced: self.doLandedSound(ctx)
if self.tickPitsDefault(scene, ctx) == earlyOutYes:
return
self.tickKnockbackAxial(scene, ctx)
if self.tickDying(scene, ctx) == earlyOutYes:
return
self.tickSoftCollide(scene, ctx, checkWalkable = true)

let player = scene.player

# Blunderbuss
block:
let struggleAnim = "walk"
if tickBlunderbussDefault(self, ctx, scene, player, struggleAnim) == earlyOutYes:
return

# Brain.
if not self.suck.active:
if not self.stun.active:
self.brain.tick(self, scene, ctx, player)
if self.brain.state == "Wander":
let state = self.brain.getCurrentState(BrainState_WanderFixed)
case state.state:
of Wait: self.anim.play "prepare"
of Walk: self.anim.play "walk"
else:
if self.altitude.velocity >= 0: self.anim.play "jump"
else: self.anim.play "fall"

# Player interaction.
if not player.isNil:
self.tickPlayerTouchOrShieldDefault(scene, ctx, player)
# Check for player attacking us.
if self.flicker.inactive:
if player.attackOverlaps(self):
player.onAttackHit(self)
let damage = player.attackDamage()
self.takeDamage(scene, ctx, damage = damage.amount)
self.knockback = player.facing.vec2 * 50 * damage.knockback

(This code is fairly concise and easy to read, BUT it's largely redundant with other enemies, and other Actors in general. Maintaining it is annoying. I don't actually even want to use inheritance in Nim, or some of the other language features I use, but I've somewhat "ended up here" at the end of a long procession of concessions.)

Next, we need to do a special step to enable the map builder to spawn this new enemy type: There is a file called REGISTER.nim which imports every type of Actor in the game that needs to be spawned from data files. Inside each Actor type's file, there is a call to a global function registerActorFactory to give the actor a named constructor for maps to use.

## REGISTER.nim
## This file should import every Actor with an ActorFactory to register for maps.

{.warning[UnusedImport]: off.}
when defined(nimHasUsed): {.used.} # Prevent unused warning when importing this file.

import enemy/enemy_defaults # Defines conventions for falling in pits, being crushed, etc.

import
./drops/heart,
./drops/munz,
./drops/drop_bomb,
./drops/drop_arrow

import blobfish
import bouncer
import brazier, sconce
import eyestatue
import player
import blocks/[pushblock, pushcrate, iceblock, pot, enemyblock, lockblock]
import drops/[smallkey, smallkeykillenemies]

import ./enemy/sadonion
import ./enemy/sadonionstaydead
import ./enemy/turnip

# ... many more ...

# In hopper.nim:
registerActorFactory("Hopper") do (info: SpawnInfo) -> Actor:
newHopper(info.id, info.pos)

Step 4: Spawning the Enemy in a Map

All right, now we have an Actor type, and it's registered. Currently, he has the same behavior as some existing enemy, but stripped down to whatever degree. This is fine, for now.

Next, we need to add the new enemy to our "spawns.tsx" tileset in Tiled. This is a special tileset where each tile comes from a separate image, and each one has the information needed to spawn a specific type of Actor:

Now that the Actor is in our tileset, we can spawn it by adding a Tile Object to a map. So, I quickly threw together a test room in an empty screen of my overworld, and dropped a spawnpoint and some Hoppers in:

At this point, I run the game, the world is rebuilt, and I warp to the room to test things. The game crashes, because I forgot to change animation names in the other enemy's code I copied. While I'm fixing that, I go to my tweaks file and set my default spawnpoint to "test_hopper" so I will go straight to this room on startup.

So I run the game again, and now my little crabby bois are wandering around aimlessly, as they should. I wack one with my cane (this kills the crab), and it dies, but it never despawns. Oops! I forgot to mark the death animation as "once" instead of looping. I fix that, then re-export from Aseprite. The problem persists.

Oh right, I say, I forgot! I need to go add a newline to the hopper.nim so that the animation data, which is built at compile time, will actually be rebuilt, because Nim does incremental builds, which never actually work in any language.

So, at this point, I have wandering crabs, I can kill them, and they deflect arrows:

Step 5: Writing the Enemy's Behavior

All right! Now we can finally go program the enemy to behave how we want. Right now, he has a very simple brain:

result.brain = newBrain(
"Wander",
newState("Wander", BrainState_WanderFixed(
speed: 1.0,
walkDurations: @[30, 60, 90, 120],
waitDurations: @[0, 60, 90, 120],
),
),
)

All he does is wander around at fixed intervals. What a layabout! Let's fix him. So: What do we want him to do? We want him to:

  • Wander and stand around like he currently does, but-
  • Occasionally jump to a random "legal" tile in the room.

Let's start by making the closest thing we can using existing parts:

result.brain = newBrain(
"Wander",
newState("Wander", BrainState_WanderFixed(
speed: 1.0,
walkDurations: @[30, 60, 90, 120],
waitDurations: @[0, 60, 90, 120],
),
("Jump", condAll(
BrainCond_OnGround(),
BrainCond_RandomTimer(durations: @[30, 45, 60, 90], timer: 30),
).BrainCond,
),
),
newState("Jump", BrainState_JumpTowardPlayer(jumpImpulse: 200, speed: 1.5),
("Wander", condAny(BrainCond_Succeed(), BrainCond_Fail()).BrainCond),
),
)

With these changes, we now have killer crabs that clumsily jump after the player:

First, let's change their properties a bit to make the jumps nicer: faster horizontal speed and zero bounciness.

Whew! That's better. Now, the real work: We need to make a new BrainState that will jump to a random legal tile within some radius, and will ignore tile collisions on the way there. Well, that's one way to do it, anyhow. Another way would be to have the critter jump in a random direction, and reflect off the collision normal when it hits something. Let's try the latter first:

type BrainState_JumpRandomDirection* = ref object of BrainState
speed*: float = 1.5
jumpImpulse*: float = 100
jumpDir*: Vec2

method enter*(self: BrainState_JumpRandomDirection, actor: Actor, scene: Scene, ctx: TickContext, player: Player) =
actor.altitude.velocity = self.jumpImpulse
self.jumpDir = fromRadians(rng.rand(0 .. 7).float * 45/180*PI)

method tick*(self: BrainState_JumpRandomDirection, actor: Actor, scene: Scene, ctx: TickContext, player: Player) =
if actor.altitude.val != 0:
let hits = actor.moveBy(scene, self.jumpDir * self.speed, checkWalkable = false)
for hit in hits:
if hit.hit:
self.jumpDir = self.jumpDir.reflect(hit.norm)
else:
self.status = brainSucceed

result.brain = newBrain(
"Wander",
newState("Wander", BrainState_WanderFixed(
speed: 1.0,
walkDurations: @[30, 60, 90, 120],
waitDurations: @[0, 60, 90, 120],
),
("Jump", condAll(
BrainCond_OnGround(),
BrainCond_RandomTimer(durations: @[30, 45, 60, 90], timer: 30),
).BrainCond,
),
("Jump2", condAll(
BrainCond_OnGround(),
BrainCond_RandomTimer(durations: @[30, 45, 60, 90], timer: 30),
).BrainCond,
),
),
newState("Jump", BrainState_JumpRandomDirection(jumpImpulse: 200, speed: 1.5),
("Wander", condAny(BrainCond_Succeed(), BrainCond_Fail()).BrainCond),
),
newState("Jump2", BrainState_JumpTowardPlayer(jumpImpulse: 200, speed: 1.5),
("Wander", condAny(BrainCond_Succeed(), BrainCond_Fail()).BrainCond),
),
)

Honestly, in combat this feels... Pretty ok? Sometimes he pursues you, sometimes he doesn't. This enemy brings some chaos to the battlefield, and will be fun when paired with other enemies you want to remain stationary (or use stationary attacks) for.

From the snippets shown, you can imagine it's relatively easy to say, make variants of this guy: A different colored one with more health, who aggros if he gets close or you hit him once, and then he starts pursuing you relentlessly in fast, low hops. Or a version with a stone helmet who can deflect everything except explosion damage. Or a boss one of these. Et cetera!

Workflow Issues

It has taken about ~3 hours to make this enemy, art and article-writing included. That still feels way too long to me, for how simple it is. I've done a lot to speed up my workflow when adding "content" (I hate this word) to the game, but it still feels like molasses in places. This is why I can't use anybody else's game engine unless I'm being bribed lmao. Half the engines I try can't even live edit maps and artwork! Shudder.

Part of the problem is that I've accumulated a small handful of partial solutions to the problem of codepath proliferation (thanks to Ryan Fleury for this phrase, and many good tips). I have some bitflags, for certain features. I have flat composition for others. Reference-based composition for still more. Inheritance in several places (not my first pick, by a long shot). I used to have some tagged unions too, but they were so much slower than C unions, even with checks turned off, that I removed them all.

Over the past week I've done a lot of thinking about what it would take to unify all my half-baked strategies of expedience for managing complexity into a whole, a whole with sane defaults, minimal redundant systems reaching over each other, and fewer, straighter, flatter codepaths for me to keep updated as I work on the game for the next ~year.

I have some solid ideas there, and have been doing experiments to prove them out, so we'll see what happens!

What do you guys think? Does this workflow seem reasonable to you, or awful?


<- Wizwag DevLog