Skip to content

Spread work across frames

A hundred pets updated in one frame is a hundred times the work in that frame. The same hundred at twenty a frame is five frames of a fifth of the work, and no player can tell the difference. The total is identical; only the shape of the spike changes, and the spike is what players feel.

Loop covers the three shapes this takes: work that repeats, work that happens later, and work that is too big for one frame.

Each call returns a handle carrying Destroy, and takes the bag it belongs to as its last argument.

-- Ends when the player leaves.
Loop.Every(1, onTick, Twill.Scope.Player(player))
-- Ends when the session does, because nothing in Twill starts a connection
-- nobody owns.
Loop.Every(1, onTick)

Passing the right bag is the whole of cleanup. A loop started for a player and owned by the framework keeps running after they leave, and the only symptom is a server that gets slower the longer it stays up.

function IncomeService.Start()
-- The callback receives how long actually passed, not the interval you
-- asked for.
Loop.Every(1, function(elapsed)
for _, player in Players:GetPlayers() do
local data = Twill.Data.Get(player)
if data then
data.Coins += math.floor(INCOME_PER_SECOND * elapsed)
end
end
end)
end

Player data already saves on its own, so there is no need for a save loop.

A frame that overshoots the interval reports the time that actually passed rather than firing twice to catch up. Work out rates from elapsed instead of assuming the interval, and a stuttering server produces correct totals rather than a burst of catch-up work at the worst possible moment.

function RoundService.Start()
-- Cancelled if the bag closes first, so this is safe to start for a player
-- who may leave before it comes due.
Loop.After(5, function()
beginRound()
end, Twill.Scope.Framework())
end

Stagger is the one worth reading twice. It walks an array a few entries per frame, over and over, so every entry is touched once per lap and no single frame carries the whole set.

local pets: { Model } = {}
function PetService.Start()
-- Twenty a frame. With a hundred pets, each one updates every five frames.
Loop.Stagger(pets, 20, function(pet, index)
followOwner(pet)
end)
end

The array is read live each frame, so entries may be added or removed while it runs. There is no need to stop and restart the walk when the set changes.

Choosing the number is a trade between smoothness and latency:

Per frameEffect
HigherEach entry updates sooner, each frame costs more.
LowerCheaper frames, longer until an entry is revisited.

Pick from how stale an entry may be, not from how many there are. Something that must react within about a tenth of a second needs the whole set covered in a handful of frames; a background hum can take a second.

Position updates, distance checks, and idle animations tolerate it well. Anything a player triggers directly does not, and belongs on the event that triggered it.

Until is the last resort, and the reference says so plainly. Where a signal exists, wait on the signal.

-- Asked once before any waiting, so an already-true condition costs nothing.
local ready = Loop.Until(function()
return roundHasStarted
end, 30)
if not ready then
logger:Warn("the round never started")
return
end

Waiting for the network is not one of these cases. Net reports readiness directly, and a call made before then is held rather than lost:

Net.OnReady(function()
remote:Fire("ready")
end)

It answers false when the time runs out rather than yielding forever, which is what makes it safe to put in a boot path.

SituationUse
Something on a scheduleLoop.Every
Something once, later, cancellableLoop.After
A set too large for one frameLoop.Stagger
A condition with a signalThe signal
A condition with no signal at allLoop.Until
A per-frame reaction to stateThe event that changed the state