Skip to content

ActorPool

ActorPool runs heavy CPU work on real parallel threads — Parallel Luau actors — behind one yielding call: pool:dispatch(...) → (ok, result). The defining constraint is in the module header: workers are pure functions with no shared state. Pathfinding batches, terrain and loot generation, bulk validation — anything that takes plain data in and returns plain data out. Never anything that mutates state the rest of the server can see; parallelism without that rule is a data race generator.

ActorPool.new builds the machinery once:

  • A Folder named CKActorPool (parented to ServerScriptService by default) holding Size Actor instances, Worker1WorkerN.
  • Inside each actor: an ObjectValue named WorkerModule pointing at your module, a BindableEvent named CKResult for replies, and a clone of the pool’s worker script template (the template ships Disabled via Worker.meta.json and a GetActor() == nil guard, so only the enabled clones inside actors ever run).

Each worker script requires your module once, then binds a parallel message handler. A dispatch then flows:

  1. dispatch(...) assigns a job id, picks the next actor round-robin, and checks two attributes on it: CKDead (the worker module failed to require — fail fast with the error instead of burning a timeout per dispatch) and CKReady (the handler is bound — actors silently drop messages sent before BindToMessageParallel runs, so the pool spin-waits readiness up to the timeout).
  2. The calling coroutine is parked in a Pending map keyed by job id, a task.delay timeout is armed, and Actor:SendMessage("CKJob", jobId, ...) crosses the boundary. dispatch yields.
  3. On the actor, the handler runs pcall(WorkerFn, ...) in the parallel phase — this is where the actual parallelism happens — then task.synchronize() back to serial and fires CKResult with (jobId, ok, result).
  4. The pool’s listener looks up the parked coroutine by job id and resumes it with (ok, result). If the timeout fired first, the entry is already gone and the late reply is dropped on the floor.

Results route by job id, not by arrival order — concurrent dispatches from different coroutines never receive each other’s answers.

Write a worker module that returns one pure function:

-- ServerScriptService.PathWorker (ModuleScript)
return function(startPos: Vector3, goalPos: Vector3)
return computePathWaypoints(startPos, goalPos) -- pure CPU: no workspace writes, no upvalue state
end

Build a pool and dispatch:

local Root = game:GetService("ServerScriptService").ChloeKernelServer
local ActorPool = require(Root.ActorPool)
local Pool = ActorPool.new({ Size = 4, WorkerModule = script.PathWorker })
local Ok, Waypoints = Pool:dispatch(NpcPosition, TargetPosition) -- yields until the worker replies
if not Ok then
warn("path worker failed:", Waypoints)
end

Fan out a batch — dispatch from separate coroutines so the four actors actually run concurrently:

local Results = table.create(#Requests)
local Remaining = #Requests
local Done = Instance.new("BindableEvent")
for Index, Request in Requests do
task.spawn(function()
local Ok, Result = Pool:dispatch(Request.Start, Request.Goal)
Results[Index] = if Ok then Result else nil
Remaining -= 1
if Remaining == 0 then
Done:Fire()
end
end)
end
Done.Event:Wait()

ExampleWorker.luau is the whole pattern — it doubles as the pool’s smoke test:

--!strict
-- Example worker: pure CPU work with no shared state.
return function(n: number): number
local Total = 0
for Index = 1, n do
Total += math.sqrt(Index)
end
return Total
end

The contract, enforced by the worker template (Worker.server.luau):

  • Return one function. It receives the dispatch arguments and its return value becomes the dispatch result.
  • It runs in the parallel phase (BindToMessageParallel). Only thread-safe engine APIs are legal there — pure math and table work always is; reads like workspace:Raycast are safe, but writing instances, firing remotes, or touching most services desynchronizes or throws. The template synchronizes only to report the result.
  • Errors are contained. The function runs under pcall; a throw comes back as (false, errorMessage) from dispatch, never a crashed actor.
  • A failed require marks the worker dead. The template pcalls the require and stamps the error into a CKDead attribute — every dispatch to that worker fails immediately with worker module failed to require: <err> instead of timing out.
  • Readiness is stamped last. CKReady flips only after the message handler is bound, because pre-binding messages are silently dropped.

Arguments and results are copied across the boundary (SendMessage in, BindableEvent out) — actors share no Luau heap. That means:

Crosses cleanly Does not cross
Numbers, strings, booleans Functions and threads
Roblox data types — Vector3, CFrame, Color3, … Metatables — tables arrive as plain data, so no OOP objects
Tables of the above (deep-copied per crossing) Anything self-referential or holding the above
Instance references Mutations to a passed table — the worker gets a copy; the caller never sees writes

Design worker payloads as plain data going in and plain data coming out. Big tables copy on every crossing — for a grid the worker needs every job, bake it into the worker module (or its own required data module) so it loads once per actor instead of shipping per dispatch.

The Scheduler and ActorPool solve different problems and complement each other:

Scheduler ActorPool
Concurrency Cooperative slicing on the main thread, under a frame budget Real parallelism on separate VMs
Best for Work that touches game state — it runs serially, so no races by construction Pure functions over plain data — pathfinding math, generation, validation
Cost model Spreads work across frames; total CPU unchanged Multiplies throughput by core count; pays copy costs at the boundary
Yields? Jobs must not yield dispatch yields — call it from a spawned task, never from inside a scheduler job directly

Rule of thumb: if the work reads or writes anything another system owns, it belongs on the scheduler. If it’s a function you could unit-test with no DataModel, it belongs on actors. Pathfinding searches over an injected-walkability grid are the canonical actor workload; grid searches that rasterize from the live workspace are not.

Member Description
ActorPool.new(options) → pool Build the pool. Asserts Size >= 1 and a ModuleScript WorkerModule
pool:dispatch(...) → (ok, result) Yields until the round-robin worker replies or the timeout fires. (true, result) or (false, errorMessage)
pool:destroy() Resume every pending dispatch with (false, "pool destroyed"), destroy the container and actors

ActorPool.new options:

Option Default Description
Size required Actor count. Size to your workload and core budget — more actors than cores just queues
WorkerModule required The ModuleScript returning your worker function. Required once per actor
Parent ServerScriptService Where the CKActorPool folder lives
TimeoutSeconds 10 Per-dispatch deadline — also the readiness wait cap

Dispatch failure messages:

(false, …) message Meaning
pool destroyed Dispatch after destroy(), or destroy() ran while this dispatch waited
worker module failed to require: <err> The module errored at require — fix the module; every job to that actor fails fast
worker never became ready The handler never bound within TimeoutSeconds
worker timed out No reply within TimeoutSeconds — the job may still be running (see below)
anything else Your worker function threw; this is its error message

ActorPool publishes and consumes nothing on the Bus — it is a call-and-return primitive. Wrap it in your own service if you want events around job completion.