Enemies with Physics Replication System (How to reduce server stress and improvements)

Hello everyone! I am currently making a replication system that is able to replicate entities with Physics to clients, not full Physics but atleast falling, jumping and stuff like that!
Article Used for the Physics in this System

I am wondering how i could improve, and if you guys have any solutions for my current issue / how i should do it.
My current issue:

:alien_monster:
Too many Entities currently stress the server way too much, and if their Goal CFrames change too rapidly the server exhausts.
I am still planning to add some stuff to this System, but i want to focus on optimizing it first, here’s a video of it working with 100 Entities and the performance: Watch 2026-01-23 17-39-55 | Streamable

Currently for every single Entity, i do these calculations when a new Goal CFrame is set for them:

local function Calculate_New_Instance_CFrame(Instance_ID: number, Given_CFrame: CFrame, Current_CFrame: CFrame): CFrame
	local GIVEN_INSTANCE_DATA = (Replication_Service.Stored_Data.Instances[Instance_ID] or Replication_Service.Cached_Data.Instances[Instance_ID]);
	local GIVEN_INSTANCE_EXTRA_DATA = Replication_Service.Stored_Data.Extra_Data[Instance_ID];
	local GIVEN_INSTANCE_TYPE_DATA = INSTANCE_TYPES[GIVEN_INSTANCE_DATA.Instance_Name];

	-- GET DATA RELATED TO THE INSTANCE, IF IT DOES NOT HAVE PHYSICS RETURN
	if not GIVEN_INSTANCE_TYPE_DATA.Has_Physics then return Given_CFrame end;
	local MoveSpeed = GIVEN_INSTANCE_EXTRA_DATA.MoveSpeed;
	local INSTANCE_HITBOX = REPLICATED_OBJECT_STORAGE_FOLDER[INSTANCE_TYPES[GIVEN_INSTANCE_DATA.Instance_Name].Instance_Name].HumanoidRootPart.Instance_Hitbox;
	local ORIGINAL_GIVEN_CFRAME = Given_CFrame;
	local Final_Calculated_CFrame = nil;
	-- Settings / Variables from the Given Instance
	local Raycast_Params = RaycastParams.new();
	local Velocity = GIVEN_INSTANCE_DATA.Velocity;
	local JUMP_MULTIPLIER = 0.5;
	local FALL_SPEED = 2;
	local ROTATION_SPEED = 8; -- I think lower values make NPCs feel less robotic.
	local JUMP_DELAY_TIME = 0.1; -- Add some delay so its doesn't over inf jump.
	--Raycast_Params.BruteForceAllSlow = true;
	--
	local Size = INSTANCE_HITBOX.Size;
	local Size_Y = Size.Y;
	local Size_Z = Size.Z;
	local Half_Z = Size_Z / 2;
	local Half_Y = Size_Y / 2;
	--
	local Z_CFrame = CFrame.new(0, 0, -Half_Z);
	local Down_Vector = Vector3.yAxis * -Size_Y;
	
	--local lastJumped = os.clock()
	local deltaTime = task.wait();
	local ORIGINAL_POSITION = Current_CFrame.Position;

    -- Main Logic, runs until a valid CFrame is found
	while true do
		local Current_Position = Current_CFrame.Position;
		local New_MoveSpeed = deltaTime * MoveSpeed;
		local Movement_CFrame = CFrame.new(0, 0, -New_MoveSpeed);
		local Front_Raycast_Result = workspace:Raycast(Current_Position, Current_CFrame.LookVector * (Size_Z + New_MoveSpeed), Raycast_Params);
		local HAD_CHANGES = "";

		if Front_Raycast_Result then
			Movement_CFrame = CFrame.new(0, 0, -Front_Raycast_Result.Distance + Half_Z);
			HAD_CHANGES = "Wall";
			--if Velocity == 0 and workspace:GetServerTimeNow() - Physics_Data.Last_Jumped > JUMP_DELAY_TIME then 
			--	Velocity = Distance_Function(Current_Position, Given_CFrame.Position) < 6 and 0 or JUMP_MULTIPLIER Replication_Service_Client.Physics_Instances[Instance_ID].Last_Jumped = workspace:GetServerTimeNow();
			--end
		end
		local Falling_CFrame = CFrame.new(0, 0, 0);
		local FALLING_RAY_DIRECTION = Vector3.yAxis + Down_Vector;
		local FALLING_RAY_POSITION = Current_Position + Vector3.yAxis * Half_Y;
		local Down_Raycast_Result = workspace:Raycast(FALLING_RAY_POSITION, Vector3.new(0, -25, 0), Raycast_Params);
		local i = 1;
		if Down_Raycast_Result then
			Falling_CFrame = CFrame.new(0, -Down_Raycast_Result.Distance + Size_Y, 0);
			HAD_CHANGES ..= "_Fall";
		end
		local _, Yaw = CFrame.new(Current_Position, Given_CFrame.Position):ToOrientation();
		local Old_Rotation = Current_CFrame.Rotation;
		Final_Calculated_CFrame = CFrame.new(Current_Position) * CFrame.Angles(0, Yaw, 0) * Movement_CFrame * Falling_CFrame;
		if HAD_CHANGES == "_Fall" then
			local DIFFERENCE = tonumber(string.sub(tostring(Final_Calculated_CFrame.Y), 1, 5));
			
			if (DIFFERENCE - tonumber(string.sub(tostring(Current_Position.Y), 1, 5))) <= 0.25 then
				HAD_CHANGES = "";
			end
		end
		Current_CFrame = Final_Calculated_CFrame;

		if HAD_CHANGES ~= "" or (Current_CFrame.Position - Given_CFrame.Position).Magnitude <= 0.8 then
			break;
		end
	end

	return Final_Calculated_CFrame;
end

The main issue with is that i can’t really find a solution to, or i am just clueless, is that first off i am running this possibly multiple times whenever i execute this Function, because i have to basically “step” the Entity forward to check if they need to fall, or if there is a wall in front of them.
I can’t just do one big raycast because then the Entity might just skip a hole.

:thinking:
And then when the Entity is just walking around sometimes the Y Position on the server changes slightly for some reason, so my solution to that was to do some string stuff with the Y position to shorten the decimal count, since the difference was very small. But that could be very performance heavy as well. (This is in the part HAD_CHANGES == "_Fall")

:sweat_smile:
I am heavily wondering on what i need to do to improve this System, because this is also my first time doing something like this, i could show the Client as well if needed, but the Client works fine!
Thanks for any help that might be provided :).

UPDATES:

My goal for this system is 300 Entities maximum, i even added a Render Distance System to try and counter some of the stress that the server can have. But still it would be too much with this current system

i dont really care about the update code, but make sure you are wrapping this under one actor per npc so you can divide the workload between 8 cores. also defer the position/rotation data sending so you only fire up to one remote per frame. an additional optimization you could make is batching where you only send x amount of position/rotation updates per frame to not overload the client’s network receive. the final thing you could do is only update every 2 frames (30fps) if you are over x amount of npcs

Yeah sorry, i forgot to mention that my system runs at 8 FPS, and all the events get batched into one.

And all the optimizations for bandwith have been made that you mentioned!!!
The actor part is kinda weird to do since it is a module and to work in Parallel the requiring script needs to be in Parallel also i’m pretty sure, when i tried to last do this i had to rework a bit on how my game worked, by wrapping the entire main scripts into one actor, and then the AI for the entities in another, is this the only way / the best way to do it?

And if you require modules they also work on different environments, like they do not replicate the variables the same way, etc ,etc, i haven’t really messed with Parallel LuaU before so I don’t really know anything about it so i might not be seeing something obvious right now.

Thanks for the response!

Interesting thing i’ve also noted:

The amount of times the script is looping, once my character jumps is crazy (i have set entities to move to my position, every 0.2s they update the goal position)
I do not know why this happens btw
This must definetely be the reason for the lag and stuff, even when i don’t jump it can sometimes go to upwards of 200 loops, and i am not going too far away from any entity.

you should not be wrapping your entire main script into one actor, each actor has its own run context which allows it to run on separate cores in parallel. the best solution for you is most likely cloning an actor which contains the physics script, then firing a bindableevent or setting a value in a sharedtable when the calculations are done so you can batch them together as you are doing now. be careful with sharedtables as it is very expensive to read/write from (usually 5-10x a normal table from my tests)

as for your main loop code, try adding a task.wait() in your while loop if it does not break so it waits until the next heartbeat to try calculating again (or do RunService.Stepped:Wait() for pre-simulation)

to add onto the actor stuff, you can save on the cost of cloning actors by using object pooling and only deleting scripts/actors if they are absolutely no longer needed

1 Like

Hm ok, it is pretty late for me so tomorrow i will implement all the things you’ve suggested, and will reply with any confusions or if it worked, etc.

My only concerns right now though are for some entities which would be enemies, would try to move to the player’s position always at a set interval, for example 0.5s.
Adding a task.wait() would make it so it would take a while to compute a final CFrame and by then another call to compute another CFrame would commence, but i’m pretty sure i can find a workaround to this.

So it seems to me that i might have fixed the issue on the Server, but now the Client is having issues for some reason, here is a video, still the same issue when i jump but now it seems that it’s the client taking a toll: Watch 2026-01-24 13-08-12 | Streamable

It doesn’t seem to be received data or anything, but i also have been receiving packet loss since sometimes the Instances do not load in on the Client, and yes i’ve checked the events are not getting to the Client.
image

Here’s how i did the actor, and other scripts for reference:


-- This is what i added to the Server Physics Calculation Script:
if Index >= 100 then
			deltaTime = task.wait();
			Index = 0;
		end
		Index += 1;

How the Client handles the Physics is every Heartbeat it loops through every Physics instance an uses deltaTime to calculate the Physics for them, it is practically the same script as the Server but with some differences:

   -- Calculate Script for the Client:
   local Instance_Model = Physics_Data.Instance_Model;
			local GIVEN_INSTANCE_DATA = Replication_Service_Client.Stored_Data.Instances[Instance_ID];
			local GIVEN_INSTANCE_EXTRA_DATA = Replication_Service_Client.Stored_Data.Extra_Data[Instance_ID];
			local GIVEN_INSTANCE_TYPE_DATA = INSTANCE_TYPES[GIVEN_INSTANCE_DATA.Instance_Name];
			local INSTANCE_HITBOX = Instance_Model.HumanoidRootPart.Instance_Hitbox;
			local Given_CFrame = GIVEN_INSTANCE_DATA.Goal_CFrame;
			if (Instance_Model.HumanoidRootPart.Position - Given_CFrame.Position).Magnitude <= 0.8 then Play_Walk_Animation(Physics_Data.Instance_Model, false); continue end;
			Play_Walk_Animation(Physics_Data.Instance_Model, true);
			--
			local MoveSpeed = GIVEN_INSTANCE_EXTRA_DATA.MoveSpeed;
			local ORIGINAL_GIVEN_CFRAME = Given_CFrame;
			local Final_Calculated_CFrame = nil;
			--
			local Raycast_Params = RaycastParams.new();
			local Velocity = GIVEN_INSTANCE_DATA.Velocity;
			local JUMP_POWER = 50;
			local JUMP_MULTIPLIER = JUMP_POWER / 100;
			local FALL_SPEED = 2;
			local ROTATION_SPEED = 8; -- I think lower values make NPCs feel less robotic.
			local JUMP_DELAY_TIME = 0.1; -- Add some delay so its doesn't over inf jump.
			Raycast_Params.FilterDescendantsInstances = {Instance_Model};
			Raycast_Params.FilterType = Enum.RaycastFilterType.Exclude;
			--Raycast_Params.BruteForceAllSlow = true;
			--
			local Size = INSTANCE_HITBOX.Size;
			local Size_Y = Size.Y;
			local Half_Z = Size.Z / 2;
			local Half_Y = Size_Y / 2;
			--
			local Z_CFrame = CFrame.new(0, 0, -Half_Z);
			local Down_Vector = Vector3.yAxis * -Size_Y;

			local Current_CFrame = Instance_Model.HumanoidRootPart.CFrame;
			local Current_Position = Current_CFrame.Position;
			local New_MoveSpeed = deltaTime * MoveSpeed;
			local Movement_CFrame = CFrame.new(0, 0, -New_MoveSpeed);
			local Front_Raycast_Result = workspace:Raycast(Current_Position, Current_CFrame.LookVector * (Half_Z + New_MoveSpeed), Raycast_Params);

			if Front_Raycast_Result then
				Movement_CFrame = CFrame.new(0, 0, -Front_Raycast_Result.Distance + Half_Z);
				--if Velocity == 0 and workspace:GetServerTimeNow() - Physics_Data.Last_Jumped > JUMP_DELAY_TIME then 
				--	Velocity = Distance_Function(Current_Position, Given_CFrame.Position) < 6 and 0 or JUMP_MULTIPLIER Replication_Service_Client.Physics_Instances[Instance_ID].Last_Jumped = workspace:GetServerTimeNow();
				--end
			end
			local Falling_CFrame = CFrame.new(0, Velocity, 0);
			local Down_Raycast_Result = workspace:Raycast(Current_Position + Vector3.yAxis * Half_Y, Vector3.yAxis * Velocity + Down_Vector, Raycast_Params);
		    if Down_Raycast_Result then 
				Falling_CFrame = CFrame.new(0, -Down_Raycast_Result.Distance + Size_Y, 0);
				Velocity = 0;
			else 
				Velocity -= deltaTime * FALL_SPEED;
			end
			local _, Yaw = CFrame.new(Current_Position, Given_CFrame.Position):ToOrientation();
			local Old_Rotation = Current_CFrame.Rotation;
			Final_Calculated_CFrame = CFrame.new(Current_Position) * Old_Rotation:Lerp(CFrame.Angles(0, Yaw, 0), deltaTime * ROTATION_SPEED) * Movement_CFrame * Falling_CFrame;
			Replication_Service_Client.Stored_Data.Instances[Instance_ID].Velocity = Velocity;
			if Final_Calculated_CFrame.X ~= Final_Calculated_CFrame.X then
				Final_Calculated_CFrame = CFrame.new(Current_Position);
			end
		
			INSTANCE_HITBOX.Parent.CFrame = Final_Calculated_CFrame;

My only guess atm is that i am doing the Actor wrong, but i researched what you meant about actor cloning and didn’t see any mention, plus i don’t really know how that would work!

   -- How my Replication Module gets the Calculated Physics Position:
   -- Request:
   	local GIVEN_INSTANCE_EXTRA_DATA = Replication_Service.Stored_Data.Extra_Data[Instance_ID];
		
    PHYSICS_ACTOR:SendMessage("Calculate_Physics", Instance_ID, GIVEN_INSTANCE_DATA.Instance_Name, GIVEN_INSTANCE_EXTRA_DATA.MoveSpeed, New_CFrame, GIVEN_INSTANCE_DATA.Past_CFrame);
   ---------
   -- Receive:

        local GIVEN_INSTANCE_DATA = Replication_Service.Stored_Data.Instances[Instance_ID];
		local PAST_CFRAME = GIVEN_INSTANCE_DATA.Past_CFrame;
		local NEW_INSTANCE_DATA = {Velocity = GIVEN_INSTANCE_DATA.Velocity, Instance_Name = GIVEN_INSTANCE_DATA.Instance_Name, Goal_CFrame = Calculated_CFrame, Past_CFrame = PAST_CFRAME, Move_Start_Time = workspace:GetServerTimeNow()};

		Replication_Service.Stored_Data.Instances[Instance_ID] = NEW_INSTANCE_DATA;
		Replication_Service.Data_To_Send.Instances[Instance_ID] = Convert_Instance_CFrame_To_Buffer(GIVEN_INSTANCE_DATA.Instance_Name, Calculated_CFrame);
		UPDATE_THREADS[Instance_ID] = task.delay((Calculated_CFrame.Position - PAST_CFRAME.Position).Magnitude / Replication_Service.Stored_Data.Extra_Data[Instance_ID].MoveSpeed, function()
		UPDATE_THREADS[Instance_ID] = nil;
	    Replication_Service.Stored_Data.Instances[Instance_ID].Past_CFrame = Calculated_CFrame;

One thing i am worried about using task.wait() though is that multiple cframe calculations for the same Instance might occur, which could be an issue, but i just removed this by canceling the current calculation and just setting the current cframe of the Instance in the Server to be the goal CFrame.
( And i’m a bit worried still about the update script, it’s so odd that it would lag when the Goal CFrame would change Y Position? )

I am going to keep searching for anything that i might need to do for this, but what do you think i need to do here??

it looks like you are almost there, but you have a few minor issues to resolve:

  • move the Physics_Calculation module outside of the actor. cloning a reusable function is not needed and actors have their own run context which makes the script generate a new function on require anyways.
  • assuming the module is doing all of the work for you and the script is just listening to an event, you can move all of the modulescript code into the script as long as no other script needs the module
  • in your BindToMessageParallel, you can most likely turn this into a BindableFunction so the script sending the message yields until the physics calculation is done, then it will do the updating. this is most likely what is causing your issue. in the physics calculation function, you will not have to task.sync as returning a value in the bindablefunction will automatically sync before continuing the non-actor script

Ok so the System seems to be working much better, but my main issue from above is still not fixed for some reason, the reason being if i jump the whole thing dies, if i do add a task.wait() it works! BUT the Entity seems to just stop, either it is taking extremely long to calculate a new CFrame or it just stopped completely, i didn’t wait enough to see.

And the other smaller issue which they do not spawn everytime, but i believe i can fix that easily if i look into it.
To save time, and if you do have time i will provide the Place file i have been using to make this System so you can take a look:
Replication System.rbxl (134.7 KB)

I have disabled animations so there is no errors regarding permissions.
Again thanks for trying to help me!!! I hope we can resolve this

I don’t have a lot of time to do debugging for you so i just added a part that prevents the script from crashing the server, from what i saw you may have some type of logic issue that prevents the zombie from pathing correctly but you must figure that out. i also saw that you are only using one actor for your replication system when you should be using one actor per npc (or 16 actors max, distributing multiple scripts into those 16 actors). other than that it should no longer crash you now
Replication System.rbxl (141.0 KB)

Ok, I will do the actor thing then I understand it now, the issue wasn’t with the server crashing though it was with when i jumped the system would die, and it still does, when i jump the Entity stops moving!

That’s why you though the logic was not working or smth lol, i will try to find a fix for this but i don’t even know what the problem is since even when i removed the raycast that checks for anything below the entity it seems to still bug out.