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.
Mental model
Section titled “Mental model”ActorPool.new builds the machinery once:
- A
FoldernamedCKActorPool(parented toServerScriptServiceby default) holdingSizeActorinstances,Worker1…WorkerN. - Inside each actor: an
ObjectValuenamedWorkerModulepointing at your module, aBindableEventnamedCKResultfor replies, and a clone of the pool’s worker script template (the template shipsDisabledviaWorker.meta.jsonand aGetActor() == nilguard, 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:
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) andCKReady(the handler is bound — actors silently drop messages sent beforeBindToMessageParallelruns, so the pool spin-waits readiness up to the timeout).- The calling coroutine is parked in a
Pendingmap keyed by job id, atask.delaytimeout is armed, andActor:SendMessage("CKJob", jobId, ...)crosses the boundary.dispatchyields. - On the actor, the handler runs
pcall(WorkerFn, ...)in the parallel phase — this is where the actual parallelism happens — thentask.synchronize()back to serial and firesCKResultwith(jobId, ok, result). - 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 stateendBuild a pool and dispatch:
local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal ActorPool = require(Root.ActorPool)
local Pool = ActorPool.new({ Size = 4, WorkerModule = script.PathWorker })
local Ok, Waypoints = Pool:dispatch(NpcPosition, TargetPosition) -- yields until the worker repliesif not Ok then warn("path worker failed:", Waypoints)endFan out a batch — dispatch from separate coroutines so the four actors actually run concurrently:
local Results = table.create(#Requests)local Remaining = #Requestslocal 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)endDone.Event:Wait()Writing a worker
Section titled “Writing a worker”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 TotalendThe 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 likeworkspace:Raycastare 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)fromdispatch, never a crashed actor. - A failed
requiremarks the worker dead. The template pcalls the require and stamps the error into aCKDeadattribute — every dispatch to that worker fails immediately withworker module failed to require: <err>instead of timing out. - Readiness is stamped last.
CKReadyflips only after the message handler is bound, because pre-binding messages are silently dropped.
Crossing the actor boundary
Section titled “Crossing the actor boundary”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.
Actors vs the scheduler
Section titled “Actors vs the scheduler”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.
API reference
Section titled “API reference”| 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 |
Bus topics
Section titled “Bus topics”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.