Log inGet started

scopes

Updated October 20, 2026

Scopes: work you start together and that ends together, tied to what started it (an entity, a component, a scene, another scope), and the owning contexts every live resource is registered under. -- Load two things at once; neither outlives the load. task.spawn(function() scopes.run(function(s) s:spawn("terrain", function() task.wait(2) end) s:spawn("props", function() task.wait(1) end) end) print("both are done") end) -- What is live, and what will never be reached by a seam? for _, r in scopes.list() do if not r.endsAtASeam then print(r.kind, r.handle, r.scope, r.detail) end end

Start several pieces of work that must not outlive the thing that started them, and wait for all of them: scopes.run(function(s) s:spawn("load-terrain", function() ... end) s:spawn("load-props", function() ... end) end) -- returns once both are finished or cancelled; the first error either -- raised is raised again here, after the other one was cancelled Start work tied to an entity, without waiting for it: local s = scopes.open({ entity = npc }) s:spawn("patrol", function() while true do ... task.wait(1) end end) s:spawn("bark", function() ... end) -- despawning npc cancels both; so does s:cancel() Everything a task in a scope starts belongs to the scope too: a task.spawn inside it, a job it registers, a scope it opens. A scope is lived inside what the code opening it belongs to, so a scope opened in a component's hook ends with the component (destroyed or hot-reloaded), one opened by a scene's entrypoint ends with that load of the scene, and one opened in another scope's work ends with that scope. When a scope ends, every task still running in it is cancelled, and unwinds: the cancellation is raised at the wait it is suspended in, so a pcall or cleanup around that wait runs (task.isCancellation(err) recognises it). A scope ends when:

  • its scopes.run is over;
  • work in it raises: the rest of its work is cancelled, and join (and so scopes.run) raises that first error again where it is called. s.onError = function(name, err) end observes every error as it is raised. The VM-wide task.onError is not told while a scopes.run or a join waits for the scope or onError observes it; an error in a scope nothing waits for or observes is reported as any task's error is;
  • s:cancel(), scopes.release(s.tag), or cancelling the task waiting in scopes.run;
  • what it is lived inside ends: the entity it is tied to despawns, the component is destroyed or hot-reloaded, the scene load ends, the enclosing scope ends. s:why() says which ended it first, and is nil while the scope is open. A scopes.run whose scope something else ended returns once the work it started is cancelled, and s:why() names what ended it. The registry: a resource reached through a handle (a task, a substrate job, a camera feed, an input-map activation, an input subscription) outlives the call that made it. Each one records the context it was registered from, and the engine ends what a context left behind once that context's run is over. The contexts, by scope tag:
  • scope:<n>: a task scope, as above.
  • entity:<id>: an entity. Ends when it is despawned.
  • scene:<guid>#<n>: one load of a scene layer. Ends when the load ends: the layer is reloaded or unloaded, the scene is swapped, the mode flips.
  • chunk:<identity>: a module. Ends when the module runs again (a VFS write to its source) or is dropped from the require cache (vfs.reload). The next run registers its own.
  • component:<instanceId>: one component instance. Ends when that instance is destroyed: its entity despawned, its scene cleared, the component removed. A hot reload of the component ends its tasks and the resources Luau code holds for it (listed with kind luau), and the new life's awake takes its own.
  • exec:<chunk>: a chunk submitted for a single run, such as an execute call. It is never re-entered, so nothing ends it on its own. A luau resource (an input-map activation, an input subscription) taken by work in a task scope, such as a future.spawn body or a future's handler, follows what that scope runs inside rather than the scope: the holder keeps it after the work is over. list() shows every live resource by the context it follows, and release(scope) ends everything under one. That last is what reaches a resource no variable names any more: a task an execute call started and lost the handle of is listed under exec:<chunk>, and releasing that tag cancels it.
require("@builtin::modules.scopes") -- scopes (also available as global 'scopes')

The scopes namespace: 15 functions.

globals/scopes/current

scopes.current() -> string?

The scope the calling code registers a resource under right now: the module whose body is running, the task scope its work runs in, the component instance whose lifecycle hook is on the stack, or the chunk of this call. Nil when the caller registers under no context.

Returns string? — The scope tag, or nil.

print("resources I register follow", scopes.current())

globals/scopes/list

scopes.list() -> { LiveResource }

Every live resource that follows an owning context, across every subsystem holding them. A resource registered with no context above it, the engine's own, is not listed, because no scope reaches it.

Returns { LiveResource } — Array of live resources.

for _, r in scopes.list() do print(r.scope, r.kind, r.detail) end

globals/scopes/open

scopes.open(options: ScopeOptions?) -> Scope

Open a task scope without waiting for it: start work in it with s:spawn, end it with s:cancel(), or let what it is lived inside end it ({ entity = id } ties it to an entity). Wait for its work with s:join() from a task.

Parameters

  • options ScopeOptions (optional) — { entity = id } ties the scope to an entity; { name = "..." } names it.

Returns Scope — The scope.

local s = scopes.open({ entity = npc }); s:spawn("patrol", patrol)

globals/scopes/release

scopes.release(scope: string) -> { Released }

End every resource registered under scope, across every subsystem: tasks are cancelled, jobs dropped. Reaches contexts no seam does, such as the chunk of an execute call that started something and ended without ending it. An execute call runs as a task listed under its own exec: scope, so releasing that scope from the same call cancels the call too: release it from a later call.

Parameters

  • scope string — A scope tag, as the scope field of a list() row carries it.

Returns { Released } — One row per subsystem that ended something, with how many it ended.

local ended = scopes.release("exec:__exec_12")

globals/scopes/run

scopes.run(fn: (Scope) -> ...any, options: ScopeOptions?) -> ...any

Run fn(s) in a new task scope and wait until every piece of work started in it is finished or cancelled. Returns what fn returned. The first error raised by fn or by any work in the scope cancels the rest, and is raised again here, as it was raised, once their cleanup has run. Waits, so it must be called from a task: in code that cannot wait (a component hook called directly, a callback) it raises, naming that.

Parameters

  • fn (Scope) -> ...any — The function to run; it receives the scope, and starts work with s:spawn.
  • options ScopeOptions (optional) — { entity = id } ties the scope to an entity; { name = "..." } names it.

Returns ...any — What fn returned.

scopes.run(function(s) s:spawn("a", fa); s:spawn("b", fb) end)

modules/scopes/cancel

cancel(self: Scope, name: string?): number

Cancel the work named name, or end the whole scope when name is omitted. Cancelled work unwinds: its cleanup runs at the wait it is suspended in. Returns how many tasks this cancelled.

Parameters

  • self Scope
  • name string? (optional) — A child's name; omit to end the scope.
s:cancel("patrol")

modules/scopes/children

children(self: Scope): { ScopeChild }

What this scope started with spawn, in the order started, each with its current task.status: every child still running, and the 32 that ended most recently.

Parameters

  • self Scope
for _, c in s:children() do print(c.name, c.status) end

modules/scopes/current

current(): string?

The scope the calling code registers a resource under right now: the module whose body is running, the task scope its work runs in, the component instance whose lifecycle hook is on the stack, or the chunk of this call. Nil when the caller registers under no context.

print("resources I register follow", scopes.current())

modules/scopes/join

join(self: Scope, name: string?)

Wait until the work named name (all of the scope's work when omitted) is finished or cancelled, its cleanup included. Raises the first error that work raised, as it was raised. Called from work in the scope (the function scopes.run runs, say), it waits for the rest of the work and not for the calling task. Waits, so call it from a task.

Parameters

  • self Scope
  • name string? (optional) — A child's name; omit to wait for everything in the scope.
s:join()

modules/scopes/list

list(): { LiveResource }

Every live resource that follows an owning context, across every subsystem holding them. A resource registered with no context above it, the engine's own, is not listed, because no scope reaches it.

for _, r in scopes.list() do print(r.scope, r.kind, r.detail) end

modules/scopes/open

open(options: ScopeOptions?): Scope

Open a task scope without waiting for it: start work in it with s:spawn, end it with s:cancel(), or let what it is lived inside end it ({ entity = id } ties it to an entity). Wait for its work with s:join() from a task.

Parameters

  • options ScopeOptions? (optional) — { entity = id } ties the scope to an entity; { name = "..." } names it.
local s = scopes.open({ entity = npc }); s:spawn("patrol", patrol)

modules/scopes/release

release(scope: string): { Released }

End every resource registered under scope, across every subsystem: tasks are cancelled, jobs dropped. Reaches contexts no seam does, such as the chunk of an execute call that started something and ended without ending it. An execute call runs as a task listed under its own exec: scope, so releasing that scope from the same call cancels the call too: release it from a later call.

Parameters

  • scope string — A scope tag, as the scope field of a list() row carries it.
local ended = scopes.release("exec:__exec_12")

modules/scopes/run

run(fn: (Scope) -> ...any, options: ScopeOptions?): ...any

Run fn(s) in a new task scope and wait until every piece of work started in it is finished or cancelled. Returns what fn returned. The first error raised by fn or by any work in the scope cancels the rest, and is raised again here, as it was raised, once their cleanup has run. Waits, so it must be called from a task: in code that cannot wait (a component hook called directly, a callback) it raises, naming that.

Parameters

  • fn (Scope) -> ...any — The function to run; it receives the scope, and starts work with s:spawn.
  • options ScopeOptions? (optional) — { entity = id } ties the scope to an entity; { name = "..." } names it.
scopes.run(function(s) s:spawn("a", fa); s:spawn("b", fb) end)

modules/scopes/spawn

spawn(self: Scope, name: string, fn: (...any) -> ...any, ...: any): number

Start fn(...) as a task in this scope, named name, and return its task handle. The task, and everything it starts, belongs to the scope: it is cancelled (and unwinds) when the scope ends. Raises when the scope has already ended, naming why.

Parameters

  • self Scope
  • name string — What the work is called, in children(), join(name), errors and listings.
  • fn (...any) -> ...any — The function to run.
  • args any (optional)
s:spawn("props", function() task.wait(1) end)

modules/scopes/why

why(self: Scope): string?

Why the scope ended: its run finished, it was cancelled, a named child raised, or what it was lived inside ended (entity:<id> ended, component:<id> ended, ...). Nil while the scope is open.

Parameters

  • self Scope
print(s:why())
  • api
  • reference