This is the same problem that Slither .io Simulator tries to solve, and it is hard. The max I can CFrame is about 2,500 parts per heartbeat before it drops below 30fps and there is a lot of tricks I used to tweak the performance.
Suggest to read this post on Lag Prevention and one key bit of information there is in the 3rd-to-last paragraph where it mentions that parts parented to Cameras, among other things, will not replicate any of the usual properties. Slither uses this technique to parent thousands of spheres to folders inside the Camera, which short circuits a lot of replication.
Careful what you wish for, because then without replication in place, you need to take over the positioning of the parts yourself. Each client will need to render hundreds of heads for themselves and all of the other players, every frame. Each client will need to upload their stream of heads position data to the server by firing a RemoteEvent. Server then aggregates all these feeds and publishes a feed of all of each players’ heads positions and fire a remote 20x per second. All clients will need to connect to that and reposition all of each players heads based on that feed. If the exact position isn’t important then you’ll be able to transmit much less information, perhaps just the initial position and velocity and size and theme for the heads might get you pretty far. Just send the minimum properties in your packets, and don’t ever send Part instances or references to huge Player or Character structures in your events.
Once you set up a heartbeat callback to process the positioning each frame, you can optimize it by careful use of task.defer()
to separate the heartbeat into a prepare and present phase. Do all the maths in your Heartbeat callback and use task.defer for your CFraming. Get familiar with the microprofiler and you’ll be able to tell that the CPU and GPU can optimize a large batch of only CFrame operations versus having to do maths CFrame maths CFrame maths CFrame which is how us human beings mostly like to write code.
One more trick I can give you is if you reach a threshold of say a thousand parts you can start skipping the rendering of certain parts on certain frames. Simple use of distance formula (Vector3.Magnitude) to filter out some parts from being rendered in some frames, can go a long way.