< Back 

Sword Masters: Zombies

2024-09-15

The zombie hunts in Sword Masters spawn waves from eight enemy types. Each zombie chases in hops of at most 12 studs on top of SimplePath, re-plans at the end of every hop, and has a one-second watchdog that turns any silent pathfinding failure into a retry.

LuauRobloxGameplayAIPathfinding
Go to quick view

Zombies that re-plan every twelve studs.

Warning

Sword Masters was abandoned in October 2024, and most of its animations, sounds and images belong to the group that owned it, so the game can’t run as it did and this system can’t be shown on video.

Abstract. Two of the six maps in Sword Masters are zombie hunts. While the round runs, ZombieProvider spawns waves of enemies drawn by rarity from four types per map, and each one chases the nearest player, punches when it gets close and dies with an animation or as a ragdoll. Movement uses SimplePath, a small open-source module by V3N0M_Z that wraps Roblox’s PathfindingService. I didn’t write SimplePath. What I wrote is the zombie on top of it. A path computed towards a player goes stale as soon as the player moves, so instead of walking a whole path, each zombie walks hops of at most 12 studs and computes a new path when it finishes each one. A watchdog on Heartbeat re-launches the path if a second passes without any event, so every failure that SimplePath reports quietly, or doesn’t report at all, ends in a retry. The article covers the hop rule and what it costs, the watchdog and why one second works for these speeds, target selection, the head that follows the player, attacks, spawning, and the leftovers I’d fix.

1. The problem

PathfindingService computes a path to a fixed point. A player isn’t a fixed point: they run, jump and dash, and a path towards where they were three seconds ago is useless. Recomputing every frame solves that but costs a ComputeAsync per zombie per frame, and a hunt can have dozens of zombies alive at once. In between there’s a whole family of strategies, every one of which picks how often to re-plan [5]. I wanted something that cost little, followed the player well enough for a zombie (they’re supposed to be clumsy) and, above all, couldn’t get stuck forever, because a stuck zombie in a round with a kill goal blocks the objective.

2. SimplePath

SimplePath [1] sits on top of PathfindingService [2] and gives an NPC a Path object. Path:Run(target) computes a path from the model’s PrimaryPart to the target with ComputeAsync and walks it by calling Humanoid:MoveTo on each waypoint, chaining them with MoveToFinished. It exposes four events:

  • Reached, when the agent gets to the last waypoint;
  • WaypointReached, at each intermediate waypoint;
  • Blocked, when an obstacle blocks the path ahead of the agent;
  • Error, with a type: LimitReached, TargetUnreachable, ComputationError or AgentStuck.

Two of those errors come from SimplePath’s own rules and not from the engine. If Run is called again less than 0.07 s after the previous call, it doesn’t compute anything and fires LimitReached. And on each Run it compares the agent’s position with the one from the previous Run: if it moved 0.07 studs or less, it makes the agent jump and fires AgentStuck. It also hands the network ownership of the agent to the server so it doesn’t jitter, and it has an option to draw the waypoints as neon balls.

3. A zombie is an object

ZombieProvider.NewInfected(name, reaction, onAttack, timeout, debug) builds a zombie and returns an object whose metatable is the service itself, so the methods (Revive, Attack, Kill…) live in one place. Construction does this:

  1. Clones the model from ReplicatedStorage.Assets.Zombies[map][name] and reads its stats from Classes/Zombies.
  2. Clones a standard Animate script and replaces its animation IDs with the type’s own, falling back to Roblox’s default animations when a type doesn’t define one.
  3. Leaves the root anchored, so the zombie doesn’t move until it’s revived on its spawn point.
  4. Creates the Path with WaypointSpacing equal to half the reaction distance.
  5. Connects the path events, a Heartbeat loop (watchdog and head), the death handler and the attack, and puts the connections in a Janitor to clean them up together.

The round calls it with reaction = 12, timeout = 1 and the punch as onAttack. So each hop is at most 12 studs and has waypoints every 6.

4. Chasing in hops

4.1 The goal rule

Let h be the zombie’s position, p its target’s position and d=‖p−h‖. With R=12, the goal of the next hop is

g=h+min⁡(1,Rd)(p−h)

that is, a point R studs towards the player if the player is further than R, and the player’s own position if they’re closer. This is the “seek” behavior from steering [3], quantized: instead of correcting the direction every frame, the zombie corrects it every R studs.

4.2 Re-planning on arrival

All the work happens in the Reached handler. When a hop ends, the zombie looks for the nearest player, tries to attack if it’s within reach, computes the next goal with the rule above and calls Run again. Blocked and AgentStuck also end in a new Run. In practice the zombie re-plans every time it covers about R studs.

4.3 What it costs

If the path follows a straight line, a hop takes R/v seconds, where v is the zombie’s speed. Speeds go from 13 studs/s (the Colosseum miniboss) to 27 (the fast ones), so a hop lasts between 0.44 and 0.92 s, and each zombie computes between

vR∈[1312, 2712]≈[1.1, 2.25]

paths per second. The speed weighted by spawn rarity on Mystery Forest is 0.45⋅19+0.30⋅27+0.20⋅16+0.05⋅14≈20.6 studs/s, about 1.7 paths per second per zombie. With 30 zombies chasing at once that’s around 50 ComputeAsync calls per second on the server, against the 1,800 it would take to re-plan every frame at 60 Hz.

The price is lag. The zombie only finds out the player changed direction when it finishes its hop, at most R/v seconds later, which is under a second for every type. For enemies whose whole job is walking at you and swinging, that delay reads as clumsiness and not as a bug.

5. The watchdog

A path can fail without anyone hearing about it. ComputationError and TargetUnreachable fire Error, but the zombie doesn’t handle them. And if ComputeAsync fails before there’s a path, nothing moves. Without a backstop, a zombie that hits one of those cases stays still until the round ends.

The backstop is a countdown on Heartbeat:

self.Timeout -= deltaTime;
if self.Timeout <= 0 then
	self.Timeout = timeout
	self._TimeoutSignal:Fire();
end

When it reaches zero, it fires a signal that calls Run again towards the current goal and re-arms itself. Every event that shows the path is alive (Reached, Blocked, AgentStuck, LimitReached) resets the counter to one second. So the zombie re-launches its path whenever it goes a second without news, whatever the reason.

One second isn’t arbitrary given these speeds. For the watchdog not to cut normal hops, a straight hop has to finish before the counter runs out:

Rv<τ⟺v>Rτ=12 studs/s

Every type goes faster than 12. The slowest one, at 13, finishes a hop in 0.92 s, with 0.08 s to spare. When the path detours around something and is longer than vτ studs, the watchdog fires before the zombie arrives and makes it re-plan from wherever it is, which is also reasonable. A type slower than 12 studs/s would get cut on every hop, and I’d have had to tie the counter to the speed. WaypointReached doesn’t reset the counter, so a long path still gets cut after a second even if the zombie keeps passing waypoints.

The errors that do have a response are cheap. AgentStuck marks the zombie as stuck and re-launches the path, and a Blocked that arrives while it’s marked as stuck does the same. LimitReached waits 0.333 s, well above SimplePath’s 0.07 s, and calls Run again.

6. Picking a target

ClosestPlayer goes through the players in the round and keeps the closest one, starting from a maximum distance of 255 studs. As the code stands, if every player is further than 255 studs away it returns the first one in the list anyway, so as long as someone is in the round, a zombie always has a target and always chases.

There’s a leftover that looks like a random tie-break:

if ((result==0) and (magnitude < dist) or (magnitude <= dist)) then

and binds tighter than or, so the condition is (r=0∧di<d)∨(di≤d), and since di<d implies di≤d, the coin flip r has no effect: the condition is just di≤d, and in a tie the last player checked wins.

7. The head that looks at you

Every frame, if the zombie has a target, it checks whether the target is in front of it. The test is the sign of the Z coordinate of the target in the zombie’s object space:

(h−1⋅p)Z<0⟺(p−h)⋅fˆ>0

where fˆ is the zombie’s look vector, because Roblox’s forward is −Z. If the target is in front, the Neck joint’s C0 is rotated towards it, with the root’s yaw subtracted, since C0 lives in the torso’s frame. If it’s behind, or there’s no target, the head drops 30° and the zombie walks looking at the ground. It’s two lines per frame and it’s what makes them look like they’re coming for you.

8. Attacking

When a hop ends with the target within AttackDistance (25 studs for every type), the zombie attacks. The attack is a function wrapped in a promise: it sets a Resting flag, plays the punch animation, applies the damage 0.333 s later so the hit lines up with the punch in the animation, and waits out the type’s cooldown (2 s for all of them) before resolving. When it resolves, the flag clears. While Resting is set, Attack returns immediately, so there’s at most one attack in flight per zombie.

9. Spawning

When a zombie hunt starts, the service opens a promise with a loop that runs until RoundEnded. On each pass, if the count is at most the map’s MaxZombies, it waits SpawnRate seconds and spawns between 1 and MaxSpawn zombies on random spawn points. Each type is drawn by roulette wheel [4] with weight equal to its Rarity, which adds up to 100 on both maps:

Map Type Rarity Health Speed Damage
Mystery Forest Zombie 45 25 19 8
Mystery Forest Fast Zombie 30 20 27 8
Mystery Forest Slow Zombie 20 40 16 12
Mystery Forest Crystal Guard (miniboss) 5 250 14 20
Colosseum Zombie Gladiator 45 30 20 10
Colosseum Attacker Zombie 30 40 27 18
Colosseum Armored Zombie 20 75 15 15
Colosseum Victorious Zomdiator (miniboss) 5 450 13 24

The miniboss is 5% of the spawns but weighs a lot more in health. On Mystery Forest the expected health per spawn is 0.45⋅25+0.30⋅20+0.20⋅40+0.05⋅250=37.75, and 12.5 of that comes from the Crystal Guard, a third. On Colosseum it’s 22.5 out of 63.

Dead zombies stay in the service’s list (Kill adds them to Corpses but doesn’t remove them from Zombies), so MaxZombies caps the total spawned per round and not the zombies alive. The loop stops when the count goes over the cap, so at most MaxZombies+MaxSpawn come out: 82 on Mystery Forest and 50 on Colosseum. With an average of (1+MaxSpawn)/2 zombies per wave, Mystery Forest hits its cap after about 19 waves, around 95 s into a 225 s round, and Colosseum after about 8 waves, around 56 s.

When a zombie dies it plays its death animation if its type has one (only the minibosses do) or becomes an R6 ragdoll, using a module from the Creator Store that the repo doesn’t redistribute. Its body moves into the map’s folder, so it disappears along with the map, and its connections and path are destroyed. When the round ends, the promise resolves and cleans up whatever is left.

10. Limitations

Some leftovers from debugging and earlier versions are still there. The round creates zombies with debug = true, so SimplePath draws every path’s waypoints as neon balls, creating and destroying parts on every hop, in production. The random tie-break from section 6 does nothing. And the code for wandering when there’s no target is dead: SetWalkingPurpose(false) assigns self.GoalPosition or self:RandomPosition(), but the goal is never nil (it starts as Vector3.zero), so the zombie keeps its previous goal.

Several stats don’t do anything: Shield, HasShield and HasMelee are in the data but no code reads them. AttackDistance is 25 studs for every type, which is a lot for a punch, and the damage lands 0.333 s after the check without checking the distance again.

The MaxZombies cap interacts badly with the goal. Colosseum asks for 20+7(n−1) kills and spawns at most 50 zombies, so from 6 players up the goal can’t be reached. On top of that, only sword kills count towards it, because ability kills go through another path that updates stats and not the objective. As the round article explains, the goal doesn’t end the round, so nobody gets stuck, but the panel promises something the map can’t deliver.

11. What I took away

The hop rule was the best decision in this system: one parameter, R, controls both the server’s cost and how well a zombie follows you, and it’s easy to reason about. The watchdog was the second best, because it turned “what do I do with each SimplePath error” into “if nothing happens for a second, try again”, which covers the cases I knew about and the ones I didn’t. If I did it again, I’d tie the watchdog to the speed (τ=kR/v with some margin k>1), keep a count of live zombies separate from the total, and delete the stats that don’t do anything instead of leaving them in the data.

References

  1. V3N0M_Z. SimplePath documentation. https://v3n0m-z.github.io/RBLX-SimplePath/
  2. Roblox Creator Documentation. “Character pathfinding”.
  3. C. W. Reynolds. “Steering Behaviors For Autonomous Characters”. Game Developers Conference, 1999.
  4. D. E. Goldberg. Genetic Algorithms in Search, Optimization, and Machine Learning. Addison-Wesley, 1989.
  5. I. Millington, J. Funge. Artificial Intelligence for Games, 2nd ed. Morgan Kaufmann, 2009.

This text by Mastedore. All rights reserved.