Skip to content

Loop

local Loop = require("@game/ReplicatedStorage/Twill").Loop
Loop.Every(1, onTick, Scope.Player(player))
Loop.After(5, onTimeout, bag)
Loop.Stagger(pets, 20, updateOnePet)

Every function here hands back a handle carrying Destroy, so a bag holds it like anything else.

export type Handle = {
Destroy: (self: Handle) -> (),
}

The last argument is the bag the work belongs to. Left out, it goes to Scope.Framework(), because nothing in Twill starts a connection nobody owns.

Passing the right bag is what makes a loop belonging to a player stop when that player leaves, with no teardown written by hand.

Every accumulates the time since the last run and fires once when that passes the interval, reporting what actually passed. A frame that overshoots does not fire twice to catch up.

Rate work out of elapsed rather than assuming the interval, and a stuttering server produces correct totals instead of a burst of catch-up work.

[Server] | [Client]

Runs a callback on an interval for as long as its bag stays open.

function Loop.Every(
interval: number,
callback: (elapsed: number) -> (),
owner: Scope.Bag?
): Handle

Parameters

NameTypeDescription
intervalnumberSeconds between runs. Above zero.
callback(elapsed: number) -> ()Receives how long actually passed, which is not exactly the interval.
ownerScope.Bag?The bag it belongs to. The framework’s own when left out.

Returns

Handle - Stops the repeating early.

Throws when the interval is not a number above zero, or the callback is not a function.

Runs on Heartbeat, so the callback runs after physics rather than before.

[Server] | [Client]

Runs a callback once, later, unless its bag closes first.

function Loop.After(delay: number, callback: () -> (), owner: Scope.Bag?): Handle

Parameters

NameTypeDescription
delaynumberSeconds to wait. Zero or more.
callback() -> ()What to run when the wait is over.
ownerScope.Bag?The bag it belongs to. The framework’s own when left out.

Returns

Handle - Cancels the wait early.

Throws when the delay is not a number of zero or more, or the callback is not a function.

A bag that closes during the wait cancels it, which is what makes this safe to start for a player who may leave before it comes due. Use it over task.delay for anything tied to a lifetime.

The entry leaves its bag once the callback runs, so a long session that starts many of them does not collect them.

[Server] | [Client]

Walks an array a few entries per frame, wrapping around forever.

function Loop.Stagger<T>(
items: { T },
perFrame: number,
step: (item: T, index: number) -> (),
owner: Scope.Bag?
): Handle

Parameters

NameTypeDescription
items{ T }The array to walk. Read live, so entries may be added or removed while it runs.
perFramenumberHow many entries one frame may carry. At least one.
step(item: T, index: number) -> ()Receives one entry and where it sat.
ownerScope.Bag?The bag it belongs to. The framework’s own when left out.

Returns

Handle - Stops the walk early.

Throws when given a non-table, fewer than one entry per frame, or no step function.

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 it. Pick perFrame from how stale an entry may be, not from how many there are.

An empty array costs a frame check and nothing else. A frame never carries more entries than the array holds, so a short array is not walked twice in one frame.

[Server] | [Client]

Yields until a predicate comes true, or until the waiting runs out.

function Loop.Until(predicate: () -> boolean, timeout: number?, poll: number?): boolean

Parameters

NameTypeDescription
predicate() -> booleanAsked repeatedly, and once before any waiting.
timeoutnumber?Seconds to keep asking before giving up. Ten when left out.
pollnumber?Seconds between asks. A tenth of a second when left out.

Returns

boolean - true when it came true, false when the time ran out. Yields.

Throws when the predicate is not a function.

This takes no bag, because it yields the calling thread rather than owning a connection.

Reach for it only where the state offers no signal to wait on. An attribute has GetAttributeChangedSignal, a property has GetPropertyChangedSignal, a tag has its own signals, and replicated state has Replication.WaitFor. Each of those costs nothing while it waits, and this costs a check per poll.

LimitValue
Default Until timeout10 seconds
Default Until poll0.1 seconds
Smallest Stagger batch1 entry per frame