A lightweight frame-budget coroutine scheduler for Roblox. This module allows you to run large workloads across multiple frames without freezing the game by enforcing a strict execution time budget per frame.
- procedural generation
- large data processing
- AI systems
- ECS systems
- pathfinding batches
- chunk streaming
Running large loops in a single frame can cause frame drops or server stalls. Example of a problematic pattern:
for i = 1, 100000 do
heavyWork()
endThis module spreads the work across frames:
scheduler:Add(function()
for i = 1, 100000 do
heavyWork()
scheduler:Yield()
end
end)It uses ~85% of the frame duration and leaves headroom for the rest of the game.
When removing the first element, Luau must shift every element in the array, this means every element is moved in memory. For large queues this becomes expensive. {A, B, C, D} -> {B, C, D} (after table.remove())
This scheduler uses a ring queue instead. Instead of shifting memory, it tracks two indices: head and tail. {A, B, C, D} -> {nil, B, C, D}; head = 2; tail = 4
A simple benchmark was run comparing: baseline execution (single-frame workload) scheduler execution (frame-budgeted tasks)
Executing:
local function heavy(i)
return math.sqrt(i) * math.sin(i)
endTotal time: 0.180 s
Frames used: 21
Average frame time: 0.0083 s
Max frame time: 0.0083 s
All work is executed in large chunks, causing long frame spikes.
Total time: 0.256 s
Frames used: 61
Average frame time: 0.0041 s
Max frame time: 0.0065 s
Min frame time: 0.0001 s
Work is distributed across frames using the scheduler's adaptive frame budget.
- 50% lower average frame time.
- Heavy tasks are spread across 3x more frames, preventing sudden spikes.
- Instead of blocking frames with large bursts of work, the scheduler maintains a consistent execution budget.
Chunk generation example:
local scheduler = Scheduler.new()
scheduler:Add(function()
for x = 1, 100 do
for y = 1, 100 do
generateTile(x, y)
scheduler:Yield()
end
end
end)
RunService.Heartbeat:Connect(function()
scheduler:Step()
end)