Creating enemy AI for my game

So I am about to start working on the enemies for my game, but before going into it, Id like to know the most optimized ways I can go about this.


The game will probably have around 100 enemies max at a given time, so I was curious on how I can optimize this many enemies.

Also wondering how I can use parallel lua with this sort of thing, cause from the sound of it, if you use it right it can help a load with this sorta thing (Ive never used parallel lua before so any tips are appreciated)


I was also curious on how I could go about making certain states the enemy would have. For instance, it would be nice if when an enemy is being attacked, or something like that, that it goes to cover.

I personally have no idea how to code that, so again, any tips are appreciated.

Along with any other tips and tricks you might have :D
Thanks in advanced!

1 Like

use separate scripts for each NPC… :evil:

jk, i would just use a modulescript

2 Likes

i would personally just have different behaviors for different states and just switch to the cover state when the enemy is being attacked

1 Like
  1. Run enemy logic every 0.1-0.2 seconds, not every frame
  2. If nobody is within ~80 studs, just leave the NPC playing an idle animation, no path-finding or raycasts unless necessary
  3. Reuse paths: Store the last pathfinding service result and only rebuild it when the goal moves several studs

That’s your basic optimization, for a basic parallel lua implementation:
Goal: Give each enemy a clear behavior and spread the heavy work across CPU cores.
How?: Use a small state-machine object (Idle, Chase, Attack, etc.), and put ~20 enemies inside an Actor; code in that Actor runs on its own thread (Parallel luau)

E.g.
State-machine module

local NPC = {}
NPC.__index = NPC

local STATES = {}

STATES.Idle = {}
function STATES.Idle.enter(self) 

end

function STATES.Idle.step(self, dt)
    if self:seePlayer() then 
        self:switch("Chase") 
    end
end

STATES.Chase = {}
function STATES.Chase.enter(self)

end

function STATES.Chase.step(self, dt)
    if not self:seePlayer() then 
        self:switch("Idle") 
    end
end

function NPC.new(model)
    local self = setmetatable({}, NPC)
    self.model = model
    self.stateName = "Idle"
    self.state = STATES.Idle
    self.state.enter(self)
    return self
end

function NPC:step(dt) 
    self.state.step(self, dt) 
end

function NPC:switch(name)
    self.stateName = name
    self.state = STATES[name]
    self.state.enter(self)
end

function NPC:seePlayer() 
    return false 
end

return NPC

Each state is just a table with enter and step functions

Worker script that lives inside an Actor:

task.desynchronize()

local RunService = game:GetService("RunService")
local NPC = require(script.Parent.NPCModule)

local npcs = {}
for _, m in script.Parent.Batch:GetChildren() do
    npcs[m] = NPC.new(m)
end

RunService.Heartbeat:Connect(function(dt)
    for _, obj in npcs do 
        obj:step(dt) 
    end
end)

task.desynchronize() tells Roblox to run this script in parallel.

Manager that slices enemies into Actors:

local NPCFolder = workspace.NPCs

local BATCH_SIZE = 25
local batch = {}
local index = 1

for _, npc in NPCFolder:GetChildren() do
    table.insert(batch, npc)
    if #batch == BATCH_SIZE then
        local actor = Instance.new("Actor")
        actor.Name = 'AIWorker' .. index
        actor.Parent = workspace

        local holder = Instance.new("Folder")
        holder.Name = "Batch"
        holder.Parent = actor

        for _, n in batch 
            do n.Parent = holder 
        end

        script.Worker:Clone().Parent = actor
        batch = {}
        index += 1
    end
end

Result: every Actor runs its Worker script on its own thread, ticking its 20-ish NPCs, while each NPC uses the little state machine to decide what to do next

Lmk if you have any questions or if anyone has a better way of how to optimize this

4 Likes

EDIT: Someone already posted with some great info! I will keep this up in case it may help, but their solution will probably be better! :slight_smile:

I’m not a pro scripter, and in fact, I was about to research this myself. However, I can help you the best I’m able to with some tips!! :smiley: Please keep in mind: this will not be a perfect set of tips/examples, and will not show the entire process of how you do these things.

Unfortunately, I’ve never looked into parallel lua. So I can’t help here :frowning: But some good news… I am very familiar with pathfinding! I’m not totally sure on how to make this work, but something you could research is “How to make all NPCs share a pathfind” or something like that. So the point of this option is to make every NPC you spawn all share a pathfind to the same position. No idea on how to do this, but I’ve heard it is really good for optimization with large numbers, including 100 enemies (from what I know)!

When it comes to making certain states work, that’s actually simpler than you may think! So you can use task.spawn for this! Here is an example of what I do (it’s just cancelling a pathfind to make this possible):

  1. The part that includes waiting for the pathfind, put it in this: local pathfindTask = task.spawn(function() end). This will continue your code below this task and allow you to cancel the pathfind, and any other code in this task, at any given moment, vs just waiting for the pathfind to finish.
  2. Let’s go with the enemy heading for cover, like you mentioned in your post. What this does is check for the running task that contains the pathfind and cancels it if the NPC should take cover:
local attack = true
local takeCover = false

local npcHumanoid = script.Parent:WaitForChild("Humanoid") --Or where the npc's humanoid is located

local pathfindTask
local takeCoverTask

--Anything you add above the loop below, make sure it is in a task.spawn, or something else that allows other code below it to run at the same time.
--Make something to check if the npc has taken damage. If it has taken damage, then do this: takeCover = true
--Also, implement a way to make attack = true when the npc has finished taking cover.

--A different loop would probably be more optimized, but for this example, we'll just use a while loop.
while task.wait() do
	if attack then
		if takeCoverTask then task.cancel(takeCoverTask) takeCoverTask = false end --It's important to check if the task exists. And if it does, then cancel it. For some odd reason, I've always had to make the variable false to check if it no longer exists, despite cancelling it.
		if not pathfindTask then
			pathfindTask = task.spawn(function()
				--Do the pathfind stuff
			end)
		end
	elseif takeCover then
		if pathfindTask then task.cancel(pathfindTask) pathfindTask = false end --It's important to check if the task exists. And if it does, then cancel it. For some odd reason, I've always had to make the variable false to check if it no longer exists, despite cancelling it.
		if not takeCoverTask then
			takeCoverTask = task.spawn(function()
				--Do the take cover stuff
			end)
		end
	end
end

I hope this reply helps you! As I can’t really answer your main questions, I apologize for that. Please take this advice, research it, and move forward to create something amazing! I’ll be praying you find a good solution to this post. Also, if you have any questions or need a more in-depth version of the supplied code, let me know and I will write a better one that contains more with pathfinding! It may take me some time to write it though, as I’ll be quite busy in the upcoming days. But if needed, I’ll try and find a moment to write a full version as quick as possible! :smiley: Have a blessed day/night, friend!

1 Like

Most of the lag will come from humanoids in the end. I would recommend storing them as a point in 3d space on the server and then using humanoids for the visual and animation on the client side. I would also make it proximity based so the client is not trying to render them all at once. This is much cheaper for replication. The main issue with this method is that you have to rebuild physics, but with some basic logic and boundary checks and using a ray for gravity that is not too advanced as long as your map has little rotation. For parallel l would recommend looking into actors, and for further optimisation I would put enemys into a sleep state where the calculations and replication would be done slower or not at all if the player is far away and not interacting with them. Good luck.

Oh by this I mean how do I make the enemy find cover, the state part is easy, Idk how to make em find cover though

Ive considered doing this, the main problem I have with this is replicating the movement across clients. Along with that, hit detection.

One thought I had is I could just put a part where the enemy is and just move it around for the hit detection, and then on the client, depending on what the part is touching change up the animations to fit the situation. Do you think that would be pretty optimized? I assume its probably more optimized then having humanoids, but gonna be honest, I got no clue how this works :sob:

Thanks yalls for your time!

if your map does not have random generation, you can have preset parts that act as cover parts, and whenever the enemy needs to take cover, look for a part in a given radius and path find to it

if your map involves random generation;
you could raycast from the enemy’s position to behind a wall (or something like that, i really don’t know how to accomplish this), and before moving to that position, see if the player can see that position (Via another raycast)

1 Like

Humanoids are the major cause for lag when it comes to a bunch of AI, and this is (to my knowledge) mostly because of HumanoidStates. States require a lot of continous calculations to be updated + all the character physics that the server has to do, especially with 100 humanoid AIs, will take its toll – hence the lag.

Like what @yotoro100 said, a really good method for solving the problem is by handling the rendering, animations, and etc on each client (instead of the server.) Here’s a really useful video on the client-sided AI method from Suphi Kaner:

If you don’t want to try the whole client-rendering schtick, you can try disabling some HumanoidStates that you might not need. According to @SingleSided in this post:

Look at the HumanoidStates docs to see what each state does, and test each one to see which ones can be taken off.

1 Like

By the way, I couldnt figure this out whilst I was reading the docs regarding parallel lua, but I was wondering if I were to require a module from one script thats in a different thread, will that module run in parallel with the script that required?

Still just a little confused on how parallel lua works, but thanks non the less,

Pretty much yes, a module will run inside the same thread that called require()