Systems#
A system runs one function in one phase. Build its query once inside a plugin, then close over that query:
local Transform2D = tecs.ecs.Transform2D
local Spin = tecs.ecs.newScalarComponent({name = "Spin", kind = "number", default = 0})
local function spinPlugin(exclusive world: tecs.ecs.World): nil
local spinning = world:newQuery({
include = {Transform2D, Spin},
exclude = {tecs.ecs.Disabled, tecs.ecs.Paused},
})
world:addSystem({
name = "game.Spin",
phase = tecs.ecs.phases.Update,
run = function(dt: number)
for archetype, length in spinning:iter() do
local transforms = assert(archetype:getMut(Transform2D))
local speeds = assert(archetype:get(Spin))
unsafe do
for row = 1, length as integer do
transforms[row].rotation =
transforms[row].rotation + speeds[row] * dt
end
end
end
end,
})
end
spinPlugin(world)The pipeline calls run(dt, world, scope), with a Nupp task scope. Fixed phases supply the fixed timestep; variable phases supply the frame delta.
Asynchronous work#
Every frame system dispatched by world:update is resumable. Call a cooperative function and use its returned value directly. Nupp owns files, networking, tasks, and workers.
When the value is already available, the call returns inline. When it must wait, the host parks the world update at that exact call. Events and I/O continue to pump, and the application may render the last completed frame. The next system and phase do not run early.
The coroutine preserves query iterators and locals. Structural mutations stay staged while the system is suspended because the system has not returned, so a spawn after a wait remains ordered and commits at the next declared barrier. The same mechanism works in fixed phases: the fixed step resumes without replaying its earlier systems or advancing its clock twice.
Frame placement#
Application drives three groups:
| Call | Work |
|---|---|
world:startup() |
Runs startup phases after plugin registration. |
world:update(dt) |
Runs fixed and variable frame phases. |
world:shutdown() |
Runs teardown phases before subsystem destruction. |
Engine systems share the same schedule. The frame runs Ingress, First and PreUpdate, then zero or more FixedFirst through FixedLast groups, then Update, PostUpdate, RenderFirst through RenderLast, and Last. The host extracts the render packet after the completed update returns.
world:update clears dirty bits after the pipeline. Dirty-gated consumers must run in the same update as the writes they consume.
Structural barriers#
Systems in one phase share a structural transaction by default. The pipeline publishes it after the phase, so they normally see the same committed archetypes while the next phase sees their combined changes.
Declare commitBefore = true when a system must consume structural output from an earlier system in the same phase. Declare commitAfter = true when a later system in that phase must consume this system's structural output. These declarations make unconditional dependencies visible in system configuration.
world:commit() publishes synchronously. Finish any query traversal before calling it. Prefer declared phase barriers for systems so publication cannot invalidate a loop's current row addresses.
Prefer moving the consumer to a later phase when that is the natural frame dependency. Additional barriers reduce batching and make more archetype moves observable within one phase.
System failures#
Under an application, the crash guard records a system error and stops simulation while the host continues to drain events and serve the debug connection. A system may have updated only part of a query before it threw. Neither component writes nor pending structural changes are rolled back.
Development code may resume through app:clearCrash() after inspection. The next update publishes any pending structural work at its opening barrier, so inspect the world before resuming a partially completed operation.
Names and ordering#
Give every system that participates in removal or useful debug output an explicit, stable name:
world:addSystem({
name = "game.ResolveDamage",
phase = tecs.ecs.phases.PostUpdate,
run = resolveDamage,
})The pipeline visits phases in their fixed order and systems in registration order within each phase. Place producers before consumers or select a later phase for the consumer. Duplicate names are rejected.
world:removeSystem(name) returns whether a registered system was removed.
Listing and stopping systems#
world:listSystems() reports every system the world runs, ordered by phase and by run order within each phase. Each row names the system and its phase, gives its position in that phase, and says whether the system is enabled and whether it declares a runIf of its own:
for _, info in ipairs(world:listSystems()) do
print(info.phase, info.position, info.name, info.enabled)
endThe list is a fresh copy, so a later enable or disable does not reach a list already handed out.
world:setSystemEnabled(name, enabled) stops one system or starts it again. A disabled system stays registered: it keeps its name, its position and its ordering constraints, contributes to world:getStats().systems, and simply does not run from the next world:update. Enabling restores the runIf it declared for itself, so a gated system comes back gated:
local stopped, reason = world:setSystemEnabled("game.Spin", false)
if not stopped then
print(reason)
endA name no system carries returns false and a reason rather than raising, because that name usually comes from a person or a debugger rather than from code. Disabling a system that is already disabled reports success and changes nothing.
Use removeSystem when the system is never to run again, and setSystemEnabled when it is a pause. The debug server exposes the same pair as the systems command.
Conditional execution#
runIf(dt, world, systemName) gates run. Any function with that shape may serve as a predicate:
world:addSystem({
name = "game.Update",
phase = tecs.ecs.phases.Update,
runIf = function(_dt: number, exclusive world: tecs.ecs.World): boolean
return world:peekState() == "game"
end,
run = updateGame,
})Keep stateful timing in the predicate's closure when a system needs an interval or cooldown. Operand order matters for combined predicates: a gate evaluated first can pause a later stateful timer.