Parallel Luau Character Sync: Moving Joint Math Off the Main Thread
How Bard’s Fighting Arena poses every nearby character’s neck, torso, hips, and shoulders in parallel under an Actor, then commits the results in one synchronized step.
Every character in Bard’s Fighting Arena leans into its movement and turns its head and torso toward where the player is looking. That means rewriting six joint offsets per character, every frame, for everyone near the camera. This post covers how that work runs in parallel with Parallel Luau.
I built this to give characters and movement more life. Having characters strafe and look where their player is looking makes the game feel far more immersive.
The shape of the problem
For each visible character, the script needs to:
- read its velocity and compare it against the root’s right and look vectors,
- read where its camera is pointing (the local camera for you, a replicated CFrame for everyone else),
- compute new
C0/C1offsets for the neck, root joint, hips, and shoulders, - and smooth toward them so nothing snaps.
All of that is math on values that are already there. Only the final step, writing to the Motor6Ds, has to touch the DataModel. That split is what makes it a good fit for Parallel Luau.
Running in parallel
The script lives under an Actor and connects with ConnectParallel, so the per-character work runs off the main thread. It also refreshes which characters are nearby only every 3 seconds, using a spatial query around the camera focus instead of looping over every entity:
local FixedC0s, Bounds = {}, {}
local MotorList : {[Motor6D | typeof(ReplicatedStorage.Aligns)] : CFrame | {CFrame} | number} = {}
RunService.PostSimulation:ConnectParallel(function(DeltaTime)
if PerformanceMode.Value then
return
end
local LerpDT = 1-math.exp(-12*DeltaTime)
local Radius = 150 do
BoundsDelta += DeltaTime
if BoundsDelta >= 3 then
if Bounds then
table.clear(Bounds)
end
Bounds = workspace:GetPartBoundsInRadius(Camera.Focus.Position, Radius, HitboxParams)
if PlayerHurtbox and not table.find(Bounds, PlayerHurtbox) then
Bounds[#Bounds+1] = PlayerHurtbox
end
BoundsDelta = 0
end
end
Two details here:
- Culling.
GetPartBoundsInRadiusfinds hurtboxes within 150 studs of the camera focus. Characters outside that radius never get processed. - Frame-rate-independent smoothing.
local LerpDT = 1-math.exp(-12*DeltaTime)turns a fixed smoothing speed into a per-frame blend amount. More on that below.
Compute, then commit
Inside the loop, nothing writes to a joint. Each target is stored in a MotorList table instead. Once every character has been processed, task.synchronize() switches back to serial execution and applies everything in one pass:
task.synchronize() do
for Motor, Value in next, MotorList do
local MotorName = Motor.Name
if MotorName == "RootJoint" then
for Index, Coordinate in next, Value do
Motor[Index == 1 and "C0" or "C1"] = Coordinate
Value[Index] = nil
end
table.clear(Value)
elseif MotorName == "NilNetworkAligns" then
Motor.SmoothTime = Value
else
Motor.C0 = Value
end
MotorList[Motor] = nil
end
table.clear(MotorList)
end
end)
The root joint stores both its C0 and C1, so it gets a two-item table. NilNetworkAligns receives a SmoothTime instead of an offset. Everything else is a single C0. Clearing the tables after each frame lets them be reused without allocating new ones.
Why the smoothing uses 1 - math.exp
Every joint eases toward its target with Lerp instead of snapping. The common way to do that is a fixed blend every frame:
Value = Value:Lerp(Target, 0.2)
The catch is that “every frame” means different things on different machines. At 144 FPS that line runs almost five times as often as at 30 FPS, so characters would move faster on high refresh rate monitors.
Instead, the blend amount comes from how much time actually passed:
local LerpDT = 1-math.exp(-12*DeltaTime)
With a fixed blend a, the remaining distance shrinks by (1 - a) each frame, so after one second what is left depends on how many frames ran. With a = 1 - e^(-k·dt), each frame shrinks it by e^(-k·dt), and over one second those multiply to e^(-k) no matter how the second was split up. The result is the same motion at 30, 60, or 144 FPS.
The 12 sets the speed: it covers about 70% of the remaining distance in a tenth of a second, which keeps the joints responsive without looking twitchy.
Results
Here is the same work profiled before and after the move. Running serially, the sync script took 66.586 ms in the script profiler:
Running in parallel under the Actor, the same work took 37.765 ms:
That is about a 43% drop, for the same poses on the same characters.
Nothing broke along the way. Because the compute phase only reads values and the commit phase is the only place that writes, the move to parallel was completely smooth.
Try it yourself
If you have per-frame work that is mostly reading and math, try splitting it the same way: compute into a plain table in parallel, then write everything after task.synchronize().