Open Asterune
Script execution and scheduling

Script execution and scheduling

Trace Luau loading, coroutine suspension, frame phases, authority propagation, and the parallel execution barrier.

Asterune runs Luau through a host that owns virtual machines, coroutine queues, signal connections, and bindings into world state. A dependency-ordered frame schedule decides when that host starts scripts, delivers events, resumes suspended work, and returns control to simulation.

A coroutine is a suspended computation inside a Luau virtual machine (VM). Scheduling another coroutine does not inherently create a CPU thread. Actual concurrent script execution occurs through the engine's parallel lanes and their barrier, with separate rules for shared state.

Engine phases and host roles

The engine frame schedule orders built-in script, world, and host systems. The same table serves client, server, and solo roles. A role selects eligible systems; a frame also selects its stage range and whether it includes a world step. This lets an editor shell advance scripts without running the physical world, while a headless simulation can run the complete shared schedule.

The main execution boundaries are:

BoundaryState made available to the following work
Input and eventsIncoming device or network work, component definitions, queued instance events, and newly eligible scripts
AnimationPre-animation callbacks, animation evaluation, tweens, and pose-related state
TimersAdvanced scheduler time, expired debris, due sleepers, and a deferred batch
Pre-simulationSerial callbacks and systems that prepare the next simulation
Parallel and synchronizationParallel work completes, recorded component writes merge, and synchronized coroutines resume
Simulation and post-simulationFixed callbacks surround due world ticks; contacts and post-simulation work follow
Interaction and replicationInteraction events, remote and host requests, and outgoing state
Pre-renderClient render callbacks, camera work, and presentation preparation

The built-in schedule computes a topological order from explicit dependencies. Its declared read and write sets support consistency checks; they do not turn every independent built-in system into a concurrent job. Most phase work runs serially, and the parallel phase is an explicit transition.

The fixed block is nested within this schedule. When fixed-tick listeners are active, callbacks, controllers, and physics run for each due tick. Otherwise, controllers and physics receive the frame's world-step duration and manage their own stepping. Frame time, fixed simulation time, and rendered presentation remain separate clocks. See Physics stepping and spatial queries for the physical accumulator and interpolation rules.

Eligibility, compilation, and environments

Script discovery is revision-driven. The host tracks hierarchy topology and a separate eligibility revision. If neither changes, it skips another full eligibility scan. A changed scan considers script class, disabled state, nonempty source, ancestry, and the host's client or server role.

An auto-started ID is recorded before its first execution attempt. Encountering that object during another scan does not launch an additional copy of its initial coroutine. Systems take a separate registration path because their source produces a definition that the engine subsequently schedules.

Loading first checks for precompiled bytecode associated with the source. Otherwise, the Luau compiler produces bytecode using the host's mutable-global set. The loader supplies a per-script environment whose fallback is the VM's global environment. The script binding identifies the owning instance; parallel group environments also supply that group's _G and shared tables.

The loader separates environment values that can be bound safely during loading from globals that must remain mutable. This preserves Luau's optimized import behavior without treating script, _G, or host-provided dynamic objects as immutable compile-time values.

Native code generation follows bytecode loading when the host platform supports it. A host can defer native compilation across later frames while the same functions continue running from bytecode. Attaching the debugger changes the loading path: it retains additional debug information, bypasses the precompiled bytecode cache, and avoids native compilation for those loaded chunks.

A module cache is keyed by execution group and module ID, with the originating VM state checked before reuse. Requiring the same module in another group does not imply shared ordinary table state. A separate set tracks modules being evaluated and rejects recursive require cycles before another evaluation can reenter the same module.

Coroutine resumptions and scheduler time

A host resumption restores the coroutine's execution identity and opens a fresh execution-budget scope. It resumes only a coroutine whose VM still exists and whose status is resumable. A completed coroutine leaves no new scheduled work; an error is reported through the script log and script-context error delivery.

Engine yielding operations return internal markers that identify what the host must wait for. The host parks the coroutine in the matching structure rather than polling every suspended coroutine each frame:

Suspension reasonPending state
Timed wait or delayA heap entry containing a wake time, start time, and arguments
Signal waitA waiter list attached to that signal hub
Child waitParent and child name, with a deadline or delayed warning
Remote or host operationA request-ID map and a deadline entry
Enter parallel executionWork queued for the owning execution group
Return to serial executionThe resynchronization queue

Scheduler time advances from frame dt. The timer phase removes every sleeper whose wake time is at or before that time, releases the scheduler borrow, and then resumes the collected coroutines. A timed wait receives elapsed scheduler time rather than a promise that it resumed at an exact wall-clock instant. Long frames can therefore increase the elapsed time observed by a waiter.

A deferred drain takes the whole pending batch before invoking it. Work deferred by a callback enters the next batch rather than extending the batch that is already being iterated. The frame contains both an early and a late deferred drain, so the next batch is not necessarily the next rendered frame.

Request deadlines use the same scheduler clock. Expiry removes the pending request before resuming the caller with the operation's timeout result. A subsequent response cannot resume that removed pending entry again. Signal waits and child waits use their own completion conditions instead of ordinary sleep deadlines.

World borrows end before the host invokes Luau. A callback can therefore perform another legal world operation without reentering an outstanding mutable borrow held by the dispatcher. This separation also applies when a callback yields: the host stores the coroutine and its wait condition, rather than retaining a world borrow across suspension.

Authority follows work across suspension

The runtime assigns execution identity separately from VM placement. Callback functions and coroutines carry identity tags; starting a callback inherits its registered function's identity or the calling coroutine's identity. Resumption restores that identity before entering the binding layer.

Sharing a VM consequently does not give every coroutine the authority of the host's most privileged code. Game clients, servers, core interface code, Studio, and plugins can use different capability sets even when their state is managed by the same host. A plugin's editor access uses the capability object provided to it rather than the editor's unrestricted bridge.

Instance origin is also distinct from ancestry. The engine assigns origin when creating an instance; moving an object into another container does not rewrite that origin. Host code cannot acquire a trusted module merely by requiring an authored module from a privileged coroutine: module loading checks the allowed origin and identity relationship.

Each VM starts with a sandboxed binding surface and a memory limit. The default state limit is 512 MiB. The host also checks an execution budget during VM interrupts, resetting the scope for each resumption or callback. A configured wall-time limit participates in the same check. Exceeding the active execution limit raises Game script timeout; these checks are not a fixed real-time quantum that automatically distributes long callbacks across frames.

Parallel lanes and virtual-machine ownership

Parallel execution groups are assigned to a bounded set of lanes. The lane count follows available CPU parallelism, clamped between 1 and 8. Placement chooses a least-occupied lane when a group first needs one, then keeps that assignment. A lane opens its VM lazily.

Worker tasks
Before dispatchResolve lazy cachesComponent-family indexesWorld queriesGUI layout
Lane 1Lane VMGroup A, then group B
Lane 2Lane VMGroup C
BarrierJoin the lanesMerge recorded component writes
SyncDrain the sync queueResume synchronized coroutines
SimulationReads committed state

Before dispatch, the engine resolves lazy caches. Each lane runs its execution groups in its own VM, and groups that share a lane run one after another. The lanes join at a barrier that merges recorded component writes. The Sync stage then resumes coroutines that requested serial execution, and simulation reads the committed state.

With fewer than 2 active lanes, the engine runs the same work inline instead of on worker tasks.

Groups sharing a lane share a VM and run one after another. Groups in different lanes can run concurrently. Group-local globals and module cache keys preserve execution-group separation even though a lane is not a dedicated VM for every group. Failure to open a lane VM falls back to the main state and reports the loss of concurrency.

Before dispatch, the engine resolves the lazy caches that parallel readers can reach: component-family indexes, world-query data, water membership, humanoid hints, and GUI layout. Worker callbacks then read an established world view instead of racing to create those caches during ordinary reads.

The parallel phase collects queued resumptions, parallel signal calls, and due parallel Systems. It groups work by VM state so one state is never entered concurrently by 2 lanes. With fewer than 2 active lanes, the engine runs the work inline; otherwise, worker tasks execute the lanes and join at the barrier. A CPU thread count therefore does not establish that a particular frame has parallel work.

Member bindings check their declared thread-safety rules on entry. Ordinary DataModel structural mutations require serial execution. Game-defined component field writes have a separate recorded path, described in Component storage and system queries. The barrier merges those writes before serial synchronization resumes.

A coroutine that requests synchronization enters a queue rather than switching an executing worker directly into serial world mutation. The subsequent Sync stage drains that queue after all lanes complete. Physics therefore receives both committed component state and the results of synchronized serial work.

Shared values and message boundaries

Execution-group messages collect arguments before delivery. Values that cross VM states are converted into the supported shared representation; ordinary functions, coroutines, and arbitrary VM-owned object graphs cannot be transferred as if they belonged to the receiving state. Message handlers reconstruct their arguments in the destination VM.

SharedTable stores mutable entries behind synchronized shared state. A normal assignment serializes the individual update, but a read followed by a write is still 2 operations. The dedicated increment operation holds the entry lock through its read-modify-write sequence.

The transform-based update operation uses optimistic version checking. It reads the value and version, releases the lock while calling the transform, and commits only if the version still matches. A concurrent write causes another attempt. After 64 unsuccessful attempts, it raises an error. The transform can therefore execute more than once for a single successful update.

Frozen shared tables use an immutable shared representation. Nested-table assignment checks for cycles before committing, preserving a graph that can cross group boundaries without recursive cycles. These shared-value mechanisms have different synchronization behavior from the component overlay committed at the frame barrier.

The timing guide covers task APIs and callback clocks. Systems and scheduling covers authoring Systems and choosing execution groups.