Synchronized client-based zombie NPCs?

{
   ["e45d3bc5-72fd-41f5-b96d-1c9dc4a764b7"] = {
      -- data about this specific npc
   },
   ["b7346c53-44d8-4db9-ad21-a745a6eea2d1"] = {
      -- data about this specific npc
   }
}

The table is keyed by a UUID, generated through HttpService (guid method). You then send all this data to the client (or just data for the specific key if you are updating them at different times and want to reduce network). The client would either tag the NPC with this UUID, or set an attribute. This would allow the client to track that specific NPC instance when the server wants to update the NPC’s data.

This is a tactic I use for tools in a game I am creating. There is data associated with a tool, but that data belongs to the player until it’s dropped. Instead of using the physical instance for the data, I use a UUID. This lets me move raw data around between players and just apply an attribute to the tool associated with that data.

Let’s say someone joins the game after all of this has started. They may have a goal, which is communicated to the new client, but that new client will be out of sync with the other clients.

I think you can just make it so when the player joins they get synced up. Is there a reason you cant just send the position of the zombies when the client joins?

Please refer to this part of one of my previous messages:

This is not a safe way of doing this. The client should ask for the data when it’s ready. If the server tries to give the client some data before the client is ready to receive it, the code will not work.

I understand how to keep track of an NPC between the client and the server, the issue is specifically updating the server position of the NPCs. The only way I can think of doing this is to choose a client at random (or perhaps the client with the lowest average ping) to ask, which would introduce some pretty clear issues

Your calculations should be done on the server. Run your pathfinding from the server and get the target points, then pass those to the client. Have the server handle obstructions to that path and give the clients new information every so often.

Pathfinding just locates the shortest path between two points. You’ll just need to have it act as if there’s a standard size character it needs to account for with jumping, width, height, etc.

Have the server repeatedly check the path. Whenever it finds an issue and ends up recalculating the path, send that new calculation to the clients. Avoid sending calculations to the client that don’t change anything.

1 Like

Each NPC on the server should have 2 values
position and target
and these 2 values need to be shared with the clients using something like attributes or objectvalues

now each client will spawn a NPC at the position the server has shared with the clients
if the target value is nil then the NPC will stand at the position
if the target value is not nil the NPC will walk towards that target

the server does not need to do a magnitude check for each client. Each client will do a check to see if there in range of a NPC

lets say player1 does a range check with NPC1 and find that there in range + there target is set to nil then player1 will send a remote event to the server asking the server to target there character

the server will validate this remoteevent and if everything is acceptable then it will set that NPCs target to player1 then this value is replicated to all other clients and all other clients will make this NPC chase player1

Q) is it safe to rely on the client to tell the server when they go in range of a NPC could a exploiter just not send the remote event

this is true but its not as bad as it sounds first of we have to recognize that the client tells the server there characters positions this means that when the server does a magnitude check its relying on information the client has told us so this means a exploiter could just tell the server a position that is outside of the range of the NPC and the server will never detect that the player is in range of a NPC

so in one case we are relying on the client telling the server there position and if this position is in range of the NPC or in the other case we relay on the client firing the remote event to tell the server they are in range of a NPC its almost the same thing

but the benefit of making the client send a remote event is now we can spread the load and make each client do the check for there own character + because the NPCs are client sided its easy for the client to know when they get in range of a NPC

one option is to have a ball shape part welded onto the NPC and each client has a touch event to detect when there own character touches this ball then if it does send the remote event to the server


how to estimate the NPCs position using time + speed
if we save a speed + time value for each NPC we can use this to estimate its position

lets say NPC2 is standing at position 0, 0, 10
then at time 1000 we change the target position to 0, 0, 100
then lets say 2 seconds later when the time is 1002 we want to find out where the NPC is
well if we know that the NPC walks at 16 studs per second then we can say 2 * 16 = 32 meaning the NPC has walked 32 studs closer to the target position from its previous position
so we can know that the NPC is currently around 0, 0, 42

local NPC1 = {
	Position = Vector3.new(0, 0, 10),
	OldPosition = Vector3.new(0, 0, 10),
	Target = nil,
	Time = time(),
	Speed = 16,
}

local function GetPosition(npc)
	local deltaTime = time() - npc.Time
	local moved = deltaTime * npc.Speed
	local direction = npc.Position - npc.OldPosition
	if moved >= direction.Magnitude then return npc.Position end
	return npc.OldPosition + direction.Unit * moved
end

local function SetTargetPosition(npc, position)
	npc.OldPosition = GetPosition(npc)
	npc.Time = time()
	npc.Position = position
end

task.wait(5)
SetTargetPosition(NPC1, Vector3.new(0, 0, 100))
task.wait(2)
print(GetPosition(NPC1))

the example shown above can be used to estimate the position of a NPC assuming the NPC moves in a straight line and this can be used in cases where a player logged in late to workout where the NPC needs to be or it can also be used when a NPC chases a players character to guess how much closer the NPC has gotten closer to get to the character

4 Likes

Thank you for the in-depth answer, it’s much appreciated. So, in your system, is the position of the NPCs on the server initialized at the beginning and never updated afterwards? (This is a round-based game btw) This sounds like it could be problematic for the case you mentioned where a player joins late (or even experiences a decent amount of lag for a bit,) because the straight-line simulation might not be accurate enough. What if, for example, the zombies bump into each other (or even something like a wall) and are stopped for a moment? Is there a time when you’re updating the position attribute? And if so, how is that new position being calculated? (presumably not with the straight-line prediction, because this doesn’t take into account the obstacles in the map, the changing target position of the player being followed, etc., right?)

The only way I can think of resolving this is by having the server actually spawn NPCs and run the simulation in Camera (that is, creating the NPC objects and calling MoveTo(), etc., all in the server’s Camera so that it’s not replicated), and then using that as a way to have authoritative positions for NPCs. But that’s a lot more work on the server’s end and it feels almost “hacky” in a way, even though it gets past the “sending too much data” problem.

Its up to you how often you want to update position there are no specific rules you have to follow ideally your goal is to send the lowest amount of data and keep the clients in sync this will change based on what type of game your making

The position attribute does not really describe where the NPC is but where the NPC needs to go to

I’m not really understanding why you need to simulate the NPC inside camera? what would simulating the NPC allow you to do?

A solution that takes a tremendous amount of work is creating a system similar to Chickynoid. Essentially, you’d replicate the positional data of the zombies every set “tick” (1 / 20th of a second, for example; you should play around with the time until you get something you like), and the client sets the positions locally. This solves the issues where sending only the goal position of the pathfinding operation can cause synchronization issues if there’s objects that the agent collides with, as you have mentioned.

To update positions server-side, either you’d write your own physics simulation or rely on the default Roblox simulation and read positional data every frame (e.g. using MoveTo to move the zombies while a separate RunService loop gets the zombie’s position). I’d choose the latter option, even though you’d probably need to use humanoids, because it’s much simpler. Also, yes, you should parent the zombie agents the server uses in its camera to stop anything from replicating, especially if they use humanoids (humanoids will seriously bloat the network receive, and don’t forget to disable most of the humanoid states if they’re not needed to save the server some performance).

Associate each zombie with a unique ID; because you said there could be as many as 50, one byte suffices (range of 0-255, so you can have up to 256). Then, in another loop that runs every server tick, get the position data of all the zombies and write everything into a buffer: one byte for the ID, and the next few bytes for positional data (12 bytes for a Vector3, for example).

The client deserializes the buffer into a map of zombie IDs to positions (apply deltas when applicable). For any zombie ID that has not existed, the client spawns a new zombie with that ID at that position. For any zombie ID that exists on the client but not on the server, the client removes that zombie. For every other zombie, move them to the specified position (e.g. using BulkMoveTo,).

You can optimize the data sent by using delta compression – sending a delta position vector (i.e. the change in position) instead of a full position vector, although this requires the use of a bit buffer and will be far more complex. The first bit indicates whether the following data is a full or a delta vector; if it’s a full vector, read three 32-bit floats, but if it’s a delta, read three 16-bit floats instead and add the resulting components to the last corresponding position on the client. The maximum value of a 16-bit float before its precision becomes terrible is 1024, so unless your zombies decide to move faster than 1024 studs in any direction in one server tick, you won’t have to worry about any inaccuracy or floating point precision error.

Implementing delta compression reduces the amount of positional data sent per zombie from 3 * 32 = 96 bits to 1 + 3 * 16 = 49 bits. If you also need to send rotational data, serialize a CFrame instead of a Vector3, encoding the rotation matrix into a quaternion (4 numbers instead of 9). It’s possible to pack the quaternion into only 29 bits.

Delta compression requires the server to keep a history buffer of past position snapshots (in the buffer, a server tick would map to a snapshot, which can be a dictionary mapping zombie IDs to their corresponding positions). Old snapshots in the history buffer should be cleared (e.g. ones older than 1 second).

The reason is that not all the clients are guaranteed to be on the same server tick. The server could be on tick 100, but the client is lagging and has only received the snapshot at tick 90. Thus, you cannot send deltas between tick 100 and 99 – the client will receive completely incorrect information. Instead, the server needs to send deltas between 100 and 90. For the server to know which tick the client is on, the client has to additionally send acknowledgement packets to the server every time it receives a snapshot containing that snapshot’s tick number.

If the client’s last acknowledged tick is not present in the server history buffer, send a full snapshot – the absence indicates that the client has either just connected (in that case, the client’s last acknowledged tick should be set to something like -1, which is guaranteed to be invalid) or is lagging badly.

Ideally, you’d use unreliable remote events for all of this, but the 900 byte payload limit might be annoying.

2 Likes

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.