Keeping less threads is better for performance.
Now, I realize that it’s a server script. This should be handled on the client instead. It seems really easy to switch over as well.
Now, after further inspecting your code, you’re rotating the original part and not the new one:
The reason why it’s lagging is because you repeatedly print 0
in the number of parts, which is a bad practice.
Not only that, but there’s another issue:
In addition, the max parts function does not work at all. I ended up combining the functions:
local function NamePart(t): string
local maxNum
for i=1, maxNoParts do
if t:FindFirstChild(tostring(i)) then
if i+1 > maxNoParts then
return
end
maxNum = tostring(i+1)
end
end
return maxNum or "1"
end
If the function does not return anything, the part will not be created.
In addition, the partsFound function does not work. I fixed this by implementing attributes:
local function PartsFound(t): number
local PartsFound = 0
for i, v in t:GetChildren() do
if v:GetAttribute("isPart") then
PartsFound += 1
end
end
return PartsFound
end
Now, it returns a number.
Oh, the parts also go to the same spot because it’s calculated by the total of the parts and not the number of the parts. I did tonumber(name)
instead:
local nameNumber = tonumber(name)
RunService.Heartbeat:Connect(function()
--local NumberOfParts = PartsFound(Character)
--print(NumberOfParts)
--local Angle = NumberOfParts*2*math.pi/maxNoParts
local Angle = nameNumber*2*math.pi/maxNoParts
local Radius = 5
local PositionOnCircle = Vector3.new(math.sin(Angle), 0, math.cos(Angle))*Radius
local position = (hrp.CFrame * CFrame.new(PositionOnCircle)).Position -- .p gets the position part of the CFrame as a Vector3
newPart.CFrame = CFrame.new(position)
end)
That means we no longer need the PartsFound function.
I also made it compatible with redetecting the character if, for example, the player resets their character:
if not Character or not Character.Parent then
Character = player.Character
if Character then
hrp = Character:FindFirstChild("HumanoidRootPart")
if not hrp then return end
else
return
end
end
That’s not all. Since it is now a wonderful local script, we need to take into account the performance benefits of single-thread operations. So, we will add these parts to a table and update them all at once every frame, instead of making multiple connections to heartbeat
.
So, what I did, was I have a table called parts
, and I insert the part when it is “interacted with”. Then, each frame, I iterate over all the parts in parts
and update them.
RunService.Heartbeat:Connect(function(deltaTime)
if not Character or not Character.Parent then
Character = player.Character
if Character then
hrp = Character:FindFirstChild("HumanoidRootPart")
if not hrp then return end
else
return
end
end
for num, part in parts do
local Angle = num*2*math.pi/maxNoParts
local Radius = 5
local PositionOnCircle = Vector3.new(math.sin(Angle), 0, math.cos(Angle))*Radius
local position = (hrp.CFrame * CFrame.new(PositionOnCircle)).Position -- .p gets the position part of the CFrame as a Vector3
part.CFrame = CFrame.new(position)
end
end)
This will make nameNumber
, which was defined earlier, obsolete. Now, we can just use the number provided by the for loop!
But what if the player wants to replace the part? They can’t do that yet. So, I implemented a method for them to remove the part.
First, we need to update the NamePart function to something like this:
local function NamePart(t): string
return tostring(#parts+1)
end
This will take the length of the table plus one and turn it into a string. Now, we need to remove the section of the script where it returns when there isn’t a name for the part:
if not name then return end --new limit to max parts
And replace it with:
if #parts >= maxNoParts then return end
But, if we want the player to be able to replace the oldest part in the list, we would do this:
if #parts >= maxNoParts then
table.remove(parts, 1)
end
This will most likely remove the last part in the table. I tested this out with two parts of different colors, and it worked. Well, it worked. The problem is that it doesn’t delete the oldest part. We can fix that though:
if #parts >= maxNoParts then
local oldestPart = parts[1]
if not oldestPart then
warn('unexpected error')
return
end
oldestPart:Destroy()
table.remove(parts, 1)
end
Is it working now? Of course it is! Are we done yet? No!
Now, we need to solve something which probably was not thought of before:
Now, this is one of the easiest fixes.
local loadedParts = {}
function loadPart(part)
if not part:IsA("BasePart") then return end
if not loadedParts[part] then
newPart(part)
end
end
for i, part in partsFolder:GetChildren() do
loadPart(part)
end
partsFolder.ChildAdded:Connect(loadPart)
partsFolder is assigned at the top of the script
Now we’re done? Nope. Replication.
Replicating this sort of stuff is not easy, but it is possible. Well, it’s quite harder than what you’d imagine. How should we replicate this? Do we display the updates on the server? Of course not!
We’ll tell the server that the client obtained a new part or removed the part. We need to implement server validation as well.
First, we need a server script preferably in ServerScriptService. Then, a remote event. The event in my case will be located in ReplicatedStorage.ReplicationEvents.PartUpdateEvent
. It is defined in the LocalScript
as:
local event = game:GetService("ReplicatedStorage"):WaitForChild("ReplicationEvents"):WaitForChild('PartUpdateEvent')
You can name it whatever you want though.
Now, we’re going to use the same event for both sending and receiving data. It might sound like a handful, but it’s quite straightforward.
First, in the local script, we will move that for loop described earlier to a function, with hrp
and customParts
as the parameter:
function updateParts(hrp, customParts)
for num, part in customParts or parts do
local Angle = num*2*math.pi/maxNoParts
local Radius = 5
local PositionOnCircle = Vector3.new(math.sin(Angle), 0, math.cos(Angle))*Radius
local position = (hrp.CFrame * CFrame.new(PositionOnCircle)).Position
part.CFrame = CFrame.new(position)
end
end
This will allow us to update parts for a specific root part, and with a custom parts table. Speaking of which, more tables are needed for this to function:
local otherPlayerParts = {}
Now, before I forget what I’m doing, let’s move on to the server before continuing on the local script.
On the server, we must define the remote event as well:
local event = game:GetService("ReplicatedStorage").ReplicationEvents.PartUpdateEvent
However, we do not use Instance.WaitForChild
because it is only meant to be used on the client. The dot operator in this case is faster.
Now, we can hook up a function when the client sends data:
event.OnServerEvent:Connect(function(playerFrom, part)
end)
What I’m planning to do here is make part
the part that the player is attempting to add to their character. But first, let’s define a few more variables:
local maxNoParts = 4
We define this in the server script to validate that the player does not exceed this number of parts.
Also, remember that now-old “PartsFound” function? Yeah, we can’t use that on the server because the part does not exist on the server. We will instead store a table of all the parts each player has, indexed by the player instance.
Now, we have this:
local partsTable = {}
event.OnServerEvent:Connect(function(playerFrom, part)
local character = playerFrom.Character
if not character then return end
local existingParts = partsTable[playerFrom] or 0
end)
Now, we need to send data back to every other player:
event.OnServerEvent:Connect(function(playerFrom, part)
local character = playerFrom.Character
if not character then return end
local hrp = character:FindFirstChild("HumanoidRootPart")
if not hrp then return end
local existingParts = partsTable[playerFrom] or 0
for _, player in players:GetPlayers() do
if player == playerFrom then continue end
event:FireClient(playerFrom, character, part)
end
partsTable[playerFrom] = math.clamp(partsTable[playerFrom] + 1, 0, maxNoParts)
end)
(I also added hrp
)
Additionally, I send existingParts == maxNoParts
to indicate if the player is “replacing” a part. It turns out we don’t need that, so I removed it. The event will also skip the playerFrom
to avoid self-replication (for lack of a better word).
Now, we’re going back to the client side of things.
Now, we’ll handle replication to the server first, since that’s more straightforward.
Simply adding this line:
event:FireServer(part)
after this one:
table.insert(parts, newPart)
will allow replication to take effect.
So, what it currently does, is sends a part instance to the server (not the cloned one) and then the server sends it back to every other client.
Now, it’s receiving time:
event.OnClientEvent:Connect(function(playerFrom, character, part, hrp)
end)
And, before we move further, we’ll make the part creation function generic:
function createNewPart(part, character, customPartsTable)
character = character or Character
local parts = customPartsTable or parts
if #parts >= maxNoParts then
local oldestPart = parts[1]
if not oldestPart then
warn('unexpected error')
return
end
oldestPart:Destroy()
table.remove(parts, 1)
end
local name = NamePart(character)
local newPart = part:Clone()
newPart.Anchored = true
newPart.CanCollide = false
newPart.Name = name
newPart:SetAttribute('isPart', true)
newPart.ProximityPrompt:Destroy()
--print(newPart:GetChildren())
newPart.Parent = character
return newPart
end
Now, we modify the “newPart” function:
function newPart(part)
local Prompt: ProximityPrompt = part:FindFirstChild("ProximityPrompt")
if not Prompt then return end
Prompt.Triggered:Connect(function()
local newPart = createNewPart(part)
table.insert(parts, newPart)
event:FireServer(part)
end)
end
What the “createNewPart” function does is it will create a part regardless of if the proximity prompt was activated or not, and it will also parse a custom list of parts for replacing one.
What is this custom list of parts you ask? Well, it’s the “otherPlayerParts” table we described a half hour ago!
So, we can add to it in that receiving function. But first, we must actually create the part for the other player:
event.OnClientEvent:Connect(function(playerFrom, character, part)
local playerParts = otherPlayerParts[playerFrom] or {}
local newPart = createNewPart(part, character, playerParts)
end)
Then, insert it to the playerParts table in which we redefine otherPlayerParts[playerFrom]
to:
event.OnClientEvent:Connect(function(playerFrom, character, part)
local playerParts = otherPlayerParts[playerFrom] or {}
local newPart = createNewPart(part, character, playerParts)
table.insert(playerParts, newPart)
otherPlayerParts[playerFrom] = playerParts
end)
Okay, this is good, but we aren’t really replicating it just yet. We need to update it each frame, just like how we update the local players’ parts. A simple loop can fix that. Also, did I say we needed to replicate hrp
’s variable? We do not! Another table can fix that. Now, our function is this:
local humanoidRootPartCache = {}
function updateOtherPlayerParts()
for player, customParts in otherPlayerParts do
if not player.Character then continue end
local hrp = humanoidRootPartCache[player.Character]
if not hrp then
hrp = player.Character:FindFirstChild("HumanoidRootPart")
if not hrp then continue end
humanoidRootPartCache[player.Character] = hrp
end
updateParts(hrp, customParts)
end
end
And we can call it by itself in the heartbeat update function.
Now, there’s a problem with our current system. Can you spot it?
I couldn’t for two minutes, don’t feel bad.
Okay, there’s two problems. The system doesn’t respond to the proximity prompt being activated. Why? Because we forgot to use WaitForChild
:
local Prompt: ProximityPrompt = part:WaitForChild("ProximityPrompt", 1)
Then, there’s a problem on the server, where it attempts to add to a value that does not exist here:
partsTable[playerFrom] = math.clamp(partsTable[playerFrom] + 1, 0, maxNoParts)
The corrected code is:
partsTable[playerFrom] = math.clamp(existingParts + 1, 0, maxNoParts)
Also, to prevent further issues, we will replace this:
newPart.ProximityPrompt:Destroy()
with this:
local oldPrompt = newPart:FindFirstChild("ProximityPrompt")
if oldPrompt then oldPrompt:Destroy() end
Now, since I made this code first try, I expected there to be at least one more bug after this. Yep:

This is happening because the server is somehow sending the character and not the playerFrom
as the first parameter.
Ah, I’m sending the event to the playerFrom
:
event:FireClient(playerFrom, character, part)
it should be:
event:FireClient(player, playerFrom, character, part)
But now, finally, it works!
There’s just one more thing we need to solve. That is, if a player joints after one player already collected some parts, they will not see it. I’m going to provide the updated server script code below:
local event = game:GetService("ReplicatedStorage").ReplicationEvents.PartUpdateEvent
local players = game:GetService("Players")
local maxNoParts = 4
local partsTable = {}
function addPart(playerFrom, part, specificPlayer)
local character = playerFrom.Character
if not character then return end
local hrp = character:FindFirstChild("HumanoidRootPart")
if not hrp then return end
if specificPlayer then
event:FireClient(specificPlayer, playerFrom, character, part)
else
local existingParts = partsTable[playerFrom] or {}
for _, player in players:GetPlayers() do
if player == playerFrom then continue end
event:FireClient(player, playerFrom, character, part)
end
table.insert(existingParts, part)
partsTable[playerFrom] = existingParts
end
end
event.OnServerEvent:Connect(addPart)
players.PlayerAdded:Connect(function(player)
task.wait(3) --wait for player to load in
for playerFrom, parts in partsTable do
for _, part in parts do
addPart(playerFrom, part, player)
end
end
end)
Also, I missed a line in the update parts section:
part.Parent = hrp.Parent
This will allow parts to continue to focus on the player, even if they reset.
Now, we’re finally done with the system.
TL;DR
I fixed your code. It is provided in this place file:
partOrbit.rbxl (42.3 KB)
Also, what shall you call this system you made?