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.