How do you guys create zombie systems?

This is a question about the concept and optimization.

How do you guys created your own zombie systems for your own games? Did you applied any optimizations?
If you did applied optimizations, what the tricks?
Do you think zombies should really be optimized? In which cases you think optimizations shouldn’t be really applied?

Every answer is appreciated!

1 Like

Any systems that use npcs can follow the common optimizations where only the npc’s position and data is held on the server and then the client does all the rendering, vfx, animations etc.
Also look into humanoid optimizations if you’re gonna be using those.

1 Like

Since im just finishing the actual zombie spawn and their abilities I cant say much, but personally:

  1. I used a module script to handle the “zombie per wave” calculation and actual spawning of the zombies (which is in another separate module because the main one recreates the endless mode from Guts & Blackpowder)
  2. I dont know if it counts but I made the animation tracks for the zombies be stopped and deleted after a set amount of time or when a zombie lands a hit (in case the humanoid of the zombie reaches 64 animation tracks), same with player tools
  3. Yes, optimization is always important for the zombies, even more if there will be lots of them since it will cause server lag and ruin overall experience, for pathfinding I reccomend using SimplePath cause it already handles the tedious parts of it
  4. Couldnt really say something here… I would always rather have optimized zombies just in case

I use one script to control all zombies. Because of this, I have been able to even have pathfinding on the server with 300’s of zombies. I didn’t actually try to optimize anything; I just made it good enough. Either way, my game won’t ever have 100 zombies at once.

So, after I tested 100 zombies and noticed no performance loss on the server or client (considering my computer has 8gb ram and horrible specs) I decided the system was good to use.

2 Likes

Hey! Is there any tutorial or example on how to do it? I was kinda stuck with it for a while..

You generally don’t want to use position-only proxies in Roblox games. Roblox servers are pretty capable machines, and it’s generally best for NPCs to be server owned and simulated, for everyone to have the most consistent experience, and to not burden every client with having to do the physics simulation of hundreds of client-only entities. Purely visual effects can be client side, but you normally want your server-side zombies to be fully-formed characters with the server simulating their collisions.

The most common optimizations in Roblox games with lots of zombies is to spread out their updates. It’s not necessary for all 200 zombies to do their target acquisition and pathfinding logic on every server step. You can use Actors to parallelize their code, but you can also just manually group them in batches, like update 10 to 20 zombies at a time. They don’t need to all raycast to every player every frame to try to detect people. You can use some manner of spatial hashing to reduce the checks to just nearby players, but you can also hugely reduce the frequency, like have zombies only do their detection code 2 times a second, not 60. As long as the time is on the same order as player ping, no one will even notice the difference. Keep dialing it down until you can see a difference, then take one step back from there.

Lots of Roblox devs just put scripts in their entities that do all of their logic on every Heartbeat or Stepped, and yeah, that can kill your server framerate, but that’s not because doing it on the server is wrong, it’s because doing something at 60 fps that you can do at 2 fps and get the same player experience is wrong. If you have any kind of spatial partitioning setup, even just a basic grid, you may be able sleep a lot of the entities that no one is near, unless it’s core to your game that they always wander and patrol and must be seen doing so even from a distance.

3 Likes

For the “one script controls all zombies” approach exports255 described — the thing that makes it work isn’t the single script, it’s that you stop doing per-zombie work every frame. Rough shape:

local RunService = game:GetService("RunService")

local zombies = {}   -- { { model = , humanoid = , root = , target = }, ... }
local cursor = 1
local THINKS_PER_SECOND = 5

RunService.Heartbeat:Connect(function()
	if #zombies == 0 then return end
	-- only a slice of the horde re-decides on any given frame
	local budget = math.max(1, math.ceil(#zombies * THINKS_PER_SECOND / 60))
	for _ = 1, budget do
		if cursor > #zombies then cursor = 1 end
		local z = zombies[cursor]
		cursor += 1
		if z.humanoid.Health > 0 then
			think(z)
		end
	end
end)

think() picks a target and calls z.humanoid:MoveTo(pos). With 300 zombies at 5 thinks per second that’s ~25 zombies re-deciding per frame instead of 300, and a zombie that re-evaluates its target 5 times a second is indistinguishable from one doing it 60 times a second.

Two things actually cost you:

Target selection. Done naively, every zombie loops every player — 300 zombies by 20 players is 6000 distance checks a frame. Build the player position list once per tick and let all zombies read it.

Pathfinding. Path:ComputeAsync() yields, and it is by far the most expensive thing in the loop. Don’t path unless you have to. Raycast from the zombie to the target first, and if nothing is in the way just walk straight at it:

local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = { z.model, targetCharacter }

local delta = targetPos - z.root.Position
local blocked = workspace:Raycast(z.root.Position, delta, params)

if not blocked then
	z.humanoid:MoveTo(targetPos)   -- clear line, no path needed
else
	-- compute a path here, cache the waypoints on z, follow them over several thinks
end

In open areas that skips pathfinding almost entirely, and open areas are where you have the most zombies visible at once.

On the animation tracks Df_HaxxYT mentioned: load each track once per zombie and store the AnimationTrack on the zombie’s record.

local animator = z.humanoid:FindFirstChildOfClass("Animator")
z.tracks = {
	walk = animator:LoadAnimation(walkAnim),
	attack = animator:LoadAnimation(attackAnim),
}

Then z.tracks.attack:Play() on every swing. Calling LoadAnimation per attack is what gets you — you accumulate a new track under the Animator each time instead of reusing one.

On your “when shouldn’t you optimize” question: none of this is worth doing below roughly 30-50 zombies. Individual per-zombie scripts are far easier to debug, and the point where it stops being fine is measurable rather than a matter of taste. Open the MicroProfiler (Ctrl+F6 in Studio) and check whether your frame time is going into your AI loop or into Humanoid physics. Those are different problems with different fixes, and guessing which one you have is how people rewrite the half that was already fine.

1 Like

wrote this awhile ago, it should be useful

1 Like