Lightweight NPCs

Hi!
One of the challenges I see again and again for creators is having lots of NPCs

So I built a framework that does a pretty good job of letting you have hundreds (or thousands?) of NPCs in your game world.

I know theres a lot of other great replication frameworks out there, but this one just focuses on making NPCs and lots of them.

image

The code is uncopylocked here:

Features:

  • Fully custom replication
  • Low Hz update and Think Rates, making it very conservative on bandwidth and cpu
  • Culling (per player, or whatever you like really)
  • Easy to use API
  • Custom events per NPC
  • Can still edit the NPC model on the server (add weapons etc)

Some benchmarks:

The demo place has 300 NPCs spawn in with a 500 unit culling radius, and takes about 13kb/s RECV
Admittedly they’re pretty low fidelity (2hz updates) but it’ll send more updates if something sudden happens like jumping or animation changes.

Anyhow, check out a quick demo usage:


local npcCullRadius = 500

local npcs = {}

for j=1, maxNpcs do

	config.walkAccel = 10
	config.walkSpeed = 16
	config.thinkHzRate = 10 --Technically this can be as low as the replication rate
	config.replicationHzRate = 2
		 
	local box = 50 + j
	local npcRecord = npcModule:SpawnNpc(game.ServerScriptService.LightweightNpcs.Rigs.Bob, config, Vector3.new(math.random(-box,box),10,math.random(-box,box)), 0)
	 
	DoSillyColors(npcRecord :: any)
	 
	--data is just custom scratchpad per npc, for convenience
	npcRecord.data.nextMove = 0

	npcRecord.onThink:Connect(function(dt)
		if (tick() > npcRecord.data.nextMove) then
			npcRecord.data.nextMove = tick() + 0.5 + math.random( )
			
			if (math.random() > 0.5) then
				--idle
				npcRecord:Move(Vector3.zero)
				npcRecord:PlayAnimation("idle", false, 1, 1)		
			else
				--run
				local ang = math.random() * math.pi * 2
				npcRecord:Move(mathUtils:PlayerAngleToVec(ang))
				npcRecord:PlayAnimation("run", false, 1, 1)	
			end			
		end
		
		if (randomlyJump) then
			if (math.random() < 0.1 and npcRecord.isOnGround == true) then
				npcRecord:Jump()
				npcRecord:PlayAnimation("jump", false, 1, 1)
			end
		end
	end)
	
	npcRecord.onCullCheck:Connect(function()
		--Basic culling, but you could technically mask NPCs to individual players
		--or anything else you want to do here
		for key,player in game.Players:GetPlayers() do

			if player.Character and player.Character.PrimaryPart then

				local bubbleVec = npcRecord.position - (player.Character.PrimaryPart :: any).Position 

				if (bubbleVec.Magnitude < npcCullRadius) then
					npcRecord:SetPlayerVisibleFlag(player, true)
				else
					npcRecord:SetPlayerVisibleFlag(player, false)
				end	
			end
		end
	end)
	
	npcRecord.onCrashland:Connect(function()
		--don't technically need to do anything here
		--but because we want a new animation we'll go to idle and then trigger a new move
		npcRecord:PlayAnimation("idle", false, 1, 1)		
		npcRecord.data.nextMove = 0
	end)
	
	npcRecord.onFellOffMap:Connect(function()
		print("Fell off map:", npcRecord.npcId)
	end)

	npcRecord.onCleanup:Connect(function()

		print("Cleaned up:", npcRecord.npcId)
	end)
	
	npcRecord.onBumpIntoWall:Connect(function(instance)
		 
		--send an event to clients if we bump into something
		if (npcRecord.instance) then
			--This costs RECV, but flashes the NPCS white for fun
			npcRecord:AddClientEvent({id = "Bump"})
		end 
		npcRecord:Move(Vector3.zero)
		npcRecord:PlayAnimation("idle", false, 1, 1)		
	end)

	table.insert(npcs, npcRecord)
174 Likes

I still have a few little quality of life changes to go, but all the major beats are in place.
Let me know if you find any bugs!

5 Likes

Awesome work by the GOAT :heart: seems perfect for non interactible npcs to walk around places

6 Likes

They’d be just fine for combat too :slight_smile:

3 Likes

Does this support, say, custom rigs that have to chase and attack (just shoot at) players / other NPCs?

1 Like

Was testing around in my own baseplate, and was able to set up something like this. Worked incredibly well.

1 Like

How did you achieve performance and optimisation? I think running pathfinding on a hundred NPCs is very costly so I am just wondering how you made it work.

3 Likes

I’m not a scripter.. so it is a little confusing for me.
The stuff at the top is outside the script…

Is it all supposed to look like this ?

local npcCullRadius = 500

local npcs = {}

for j=1, maxNpcs do

config.walkAccel = 10
config.walkSpeed = 16
config.thinkHzRate = 10 --Technically this can be as low as the replication rate
config.replicationHzRate = 2
	 
local box = 50 + j
local npcRecord = npcModule:SpawnNpc(game.ServerScriptService.LightweightNpcs.Rigs.Bob, config, Vector3.new(math.random(-box,box),10,math.random(-box,box)), 0)
 
DoSillyColors(npcRecord :: any)
 
--data is just custom scratchpad per npc, for convenience
npcRecord.data.nextMove = 0

npcRecord.onThink:Connect(function(dt)
	if (tick() > npcRecord.data.nextMove) then
		npcRecord.data.nextMove = tick() + 0.5 + math.random( )
		
		if (math.random() > 0.5) then
			--idle
			npcRecord:Move(Vector3.zero)
			npcRecord:PlayAnimation("idle", false, 1, 1)		
		else
			--run
			local ang = math.random() * math.pi * 2
			npcRecord:Move(mathUtils:PlayerAngleToVec(ang))
			npcRecord:PlayAnimation("run", false, 1, 1)	
		end			
	end
	
	if (randomlyJump) then
		if (math.random() < 0.1 and npcRecord.isOnGround == true) then
			npcRecord:Jump()
			npcRecord:PlayAnimation("jump", false, 1, 1)
		end
	end
end)

npcRecord.onCullCheck:Connect(function()
	--Basic culling, but you could technically mask NPCs to individual players
	--or anything else you want to do here
	for key,player in game.Players:GetPlayers() do

		if player.Character and player.Character.PrimaryPart then

			local bubbleVec = npcRecord.position - (player.Character.PrimaryPart :: any).Position 

			if (bubbleVec.Magnitude < npcCullRadius) then
				npcRecord:SetPlayerVisibleFlag(player, true)
			else
				npcRecord:SetPlayerVisibleFlag(player, false)
			end	
		end
	end
end)

npcRecord.onCrashland:Connect(function()
	--don't technically need to do anything here
	--but because we want a new animation we'll go to idle and then trigger a new move
	npcRecord:PlayAnimation("idle", false, 1, 1)		
	npcRecord.data.nextMove = 0
end)

npcRecord.onFellOffMap:Connect(function()
	print("Fell off map:", npcRecord.npcId)
end)

npcRecord.onCleanup:Connect(function()

	print("Cleaned up:", npcRecord.npcId)
end)

npcRecord.onBumpIntoWall:Connect(function(instance)
	 
	--send an event to clients if we bump into something
	if (npcRecord.instance) then
		--This costs RECV, but flashes the NPCS white for fun
		npcRecord:AddClientEvent({id = "Bump"})
	end 
	npcRecord:Move(Vector3.zero)
	npcRecord:PlayAnimation("idle", false, 1, 1)		
end)

table.insert(npcs, npcRecord)

It makes more sense in the context of the file - theres some boilerplate missing at the top.

Really fun fact, everything you did here we did at Neptuna Ltd. for our game, Teensy Tiny Tankery months ago, for an in-house NPC system called VUNI (Virtual User Networked Instances).

I didn’t expect anyone else to build the same concept, let alone the person who taught me all the optimization tricks I used to do it!

We were able to get 64 tanks moving about (32 per side) seamlessly at < 15kb/s* network traffic, and with low user device impact.

7 Likes

Forgive my noobness - but even that’s Greek to me.

1 Like

Any chance at a Github repo for the framework?

2 Likes

this is cool but why do their heads flash white ingame

Thats just some example code showing they have a OnBumped event and a custom client event to render it on the client.

Great stuff!

I have tested locally with up to 3000 of the Bob and Robert models and it works perfectly!

It took me a minute to figure out why the StockR15Blocky model was spawning several studs high.

Setting the PivotOffset of the HumanoidRootPart to all zeroes fixed it.

Humanoid

2 Likes

this is actually really cool i like it

maybe you should Let them emote.. Maybe.l… idk im not a optimization wizard but i dont think thatd take up much preformance

Looks awesome.
I had to do some kind of system to handle hundreds of NPC for a upcoming project.
Was kinda hard, my only problem was/is with shirts and pants.
Without humanoids we can’t use them (as far as I know), so i’m using surface appearance at the moment, I still don’t think it’s the best way around, but better than humanoids.

I was able to handle up to 500 npc at around 120 fps, moving npc in a path like tower defenses. Movement is handled by client only. NPC on server are only a table with a spawn time and some other infos. Everything is checked with math calculations. Was boring to do, but is looking good.

Does this support Pathfinding?

Why does the NPC has some sort of delay when rendering on client? Upon enabling debugHitboxes I can see the client NPCs fall behind what the server sees a lot. My setup has replicationHzRate and thinkHzRate set to 40 and nothing else. I tried digging in further and I assume it has to do with the timeline system(?)

2 Likes

In the demo game, what does it mean when the NPCs head blinks?

1 Like