10,000+ Physical NPCs running with ~60 fps on low-end device

You guys can giving me feedback if you guys want
Information:

Running on Iphone 8 plus which is consindering low-end device nowadays
I still didn’t optimize memory (which include textures)
Also no throttle, LOD, or parallel

Since I don’t have good laptop/PC or even high-end phones, The game most likely have fps drop due to rendering, so people who have decent GPU can mostly run smoothly
Try out the game yourself if you want: Game optimization - Roblox

TESTED WITH 10,000 NPCs/zombie

  • Recv: 100 - 120 KB/s (I can’t push this further)
  • Ping: 130 - 200 ms (same with this)
  • CPU: 15 - 27 ms
  • GPU: 17 - 30 ms
  • Memory: ~800 MB (pretty good tho,)
  • FPS: 55 - 60 fps (60 when I don’t look all the zombies at once)

Recorded on my phone because I think it is more convinient, sorry for low quality
(because I compressed the video down to 10 MB)

Kinda insane right?

EDIT: This is outdated now for everybody wonder, I just make a NPC system that have humanoid-based, ragdoll supported, infinite map sized, that runs about 20 Hz ( 0.05 seconds sent per frame, precision 0.2 studs ) only about 15 KB/s on 500 NPCs

So if the new system implent to this, it could turns 120 KB/s to only < 30 KB/s theoretically
( which is sending 0.2 seconds per sent, precision 1 studs )

53 Likes

incredible, this is exactly what I’m trying to achieve, running 600+ enemies without SENT and RECV flying… if it’s not a secret, how did you make it?

4 Likes

CFramed NPC is the best method to increase performance as same as increasing more Recv.
Luckily it gave you more controls over replications, you just need to stop replicating from the NPC being sent to the client,
Make a remote event, the server will sent the client the COMPRESSED DATA by using buffer library

Example:
we will limit the position range for the NPCs. The smaller the limit, the smaller the data being sent.
Since I need mid-large and not tall maps, I only need the limit about

  • X: signed 10 bits ( -512 studs to +511 studs )
  • Z: signed 10 bits ( -512 studs to +511 studs )
  • Y: unsigned 9 bits ( 0 studs to +511 studs )
local bitWrite = function(count) -- count ( how many NPCs? )

	local package = buffer.create(math.ceil(count * 3.625))

	local offset = 0

	-- return the buffer, and the callback which you can write CFrame on the buffer easily.
	
	return package, function(cf: CFrame)
		
		local x = math.clamp(cf.X // 1, -512, 511) + 512	-- // Axis - X - 10 bits
		local y = math.clamp(cf.Y // 1,  000, 511)			-- // Axis - Y - 8 bits
		local z = math.clamp(cf.Z // 1, -512, 511) + 512	-- // Axis - Z - 10 bits

		buffer.writebits(package, offset, 10, x) 	offset += 10
		buffer.writebits(package, offset, 9, y) 	offset += 9
		buffer.writebits(package, offset, 10, z) 	offset += 10

	end

end

local bitRead = function(package, count)
	local result  = {}
	local offset = 0
	
	for i = 1, count  do
		local x = buffer.readbits(package, offset, 10) - 512 	offset += 10
		local y = buffer.readbits(package, offset, 9)			offset += 9
		local z = buffer.readbits(package, offset, 10) - 512 	offset += 10
		
		table.insert(result, CFrame.new(x, y, z))
	end
	
	return result
end

After this: update 1 NPC = 10 bits + 10 bits + 9 bits = 29 bits = 3.625 bytes

So 1000 NPCs ≈ 3.54 kB (kilobytes)
We will sent the data every 0.2 seconds ( optional )
→ 1000 NPCs: ~ 17.7 kB/s Recv

and the client need to create 1000 NPCs on their own client, making the NPCs on client moving what the server gives. The NPCs moving on 5 fps (kinda ugly because of the 0.2s sent), so we can use lerp to move them (I lerped these NPCs at 40 hz to improve CPU usage)

These are my bandwidth optimization, I recommended you should research about more insane network optimizations out there and tweak by your own, depends on the type of NPCs data they sent ( TD enemy, Real-time strategy NPC, zombies NPC, ect…)

13 Likes

This will help me so much, I’m using Packet framework atm but still on 200 NPCs it RECV is skyrocketing. Thank you !!!

2 Likes

This is crazy and cool! Being able to actually do such things that require a lot of device-usage on lower-end devices is great! We are finally getting to a point where what’s considered low-end nowadays is good enough!

2 Likes

im curious on how you do collisions, an explanation would be cool!

So it just using a non-humanoid character for this, using blockcast, I only do the front casting so It could bugs when it walk backwards, never tried. You can see the file in this npctest.rblx

2 Likes

and u do this for a bunch of npcs? seems pretty expensive, how do u make it work?

The one I uses it’s using raycast ( not blockcast ) which NPCs can pass through walls if the wall isn’t intersecting the ray

When running the same logic in a live Roblox server instead of actual studio ( which is ~1 FPS btw ), raycast performance is significantly better. Roblox’s engine is highly optimized for raycasting at runtime I think, so even with many raycasts per frame, it doesn’t introduce noticeable lag

I understand how you use buffers to heavily cut down on memory usage - however i dont understand how you would have seperate AI to control the movements of the NPCs or even make them go toward the player. You also mention that the new system supports ragdolls and infinite map size for somehow less memory which i would be interested in how you did.

2 Likes

The old system was pretty broken. I was using absolute positioning, so the further an NPC got from the origin (0, 0, 0), the more bandwidth we wasted sending these massive coordinate values. It just wasn’t scalable.

Because of that, I’ve switched over to delta coding, and the difference is night and day. Instead of the full coordinates, we’re just sending the change between the last frame and the current one.

Absolute positioning ( limited and high usage )
Delta coding ( unlimited and low usage )

The cool part is how it handles compression. Since the data is tied to the NPC’s velocity, sending updates at a higher frequency actually makes the data more compressible. That lower entropy means our buffer compression can crunch the packets down way smaller than before.

And you know how buffer uses ZSTD as its compression, it’s pretty wasteful because how there’s no single repeatives for a data like this, so we will uses an entropy coder instead, and the one I’m talking about is rANS!

We also tied this into a new state thingy, so NPCs can now replicate specific states like jumping, climbing, or swimming, instead of just walking around.

For the ragdolls, we had to get a bit creative. Running full physics on the client was absolutely laggy whenever things got chaotic. To fix that, we’re using “fake” ragdolls now. We basically recorded a few variations of a real physics simulation and turned them into baked animations. You get the same impact of a ragdoll death, but with basically zero CPU overhead compared to a real physics one.

Not sure what you mean about separate AI for making NPCs move toward the player

But if I am understanding correctly, the old one I just make the NPC walk like to the checkpoint, like a normal Humanoid:MoveTo with some jump checks, and the NPCs are not run in the client if you wonder, just replicated.

4 Likes

How? even personally moving like 3k bones and leveraging parallel luau to almost a 3 times speed boost is enough to spike my processing time under 60 fps

even tho this should be way faster

2 Likes

how do u “bake” physics based simulation as animations? how do u deal with instances where npcs are falling for a long time or niche ragdoll scenarios in general?

1 Like

Yeah, this same exact question has been irking me. My best guess? The 20 Hz limited cycle for updating positions is probably the most impactful performance saver here, but I could be wrong. Roblox uses 60 Hz physics at minimum for performance with adaptive timestepping for comparison (though I believe throttling can go below that).

2 Likes

this is INSANE, absolutely amazing

amazing , the delta trick was very clever, Ill use this in my zombie game

To “bake” the ragdolls, I anchored the HumanoidRootPart, applied a full ragdoll model with some scripts, and added some chaotic forces to get a realistic hit/death. While that was running, I had a script tracking every body part’s relative position and saving them as keyframes. Put them into the AnimSaves model, able to upload it to Roblox

Unfortunately, don’t know where the code now ( seems like I deleted it long time ago )

Also, I don’t deal the these scenarios that much because I record these NPC ragdolls like 10 - 13 seconds. And the NPC only died 1 times and respawned after 5 seconds so idk yet.

Just like @SpookerMe said, but some small changes, I update the NPC’s position with 4 chunked updates, like update 2500 NPC in 1 frame first, then another one and …, it gives us about 15Hz smooth updates for every NPC.

but still gives a smoother output even on a lower Hz instead of updates everything in 1 frame with 20Hz.

Then that would just make it an inconsistent framerate

How did you move the zombies on the client btw, I was getting bad performance on CPU using :PivotTo()

My guess is that each zombie’s entire body is just a meshpart instead of individual ones. That isn’t really important for like say 300 of em but on the scale of 10,000 it matters a lot from my own testing. In addition PivotTo:() might be slower performance wise than just setting the CFrame, but I somewhat doubt that. I think it’s just that less is being moved at once per zombie. If you want to optimize even further you could move them via bones I believe.

1 Like