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.