World
The World is the core of the Tecs entity component system. It manages entities, components, systems, and the game loop, acting as the central hub for your Tecs application.
Creating a World
Interact with World through the tecs module.
local tecs = require("tecs")Create a World that by default updates at 60 FPS using default configuration:
local world = tecs.newWorld()Create a World that updates at 30 FPS:
local world = tecs.newWorld({
timestep = 1 / 30
})newWorld Fields:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
timestep | number | No | 1/60 | The fixed timestep of the game in seconds |
pipelineFactory | function(number, function): Pipeline | No | Built-in | Custom factory for creating the system pipeline |
maxEntities | integer | No | 2^20 (~1M) | Maximum allocated entity slots for the world. Must be positive and at most 2^22 (~4M). The allocator is preallocated, so raise it only when you need more concurrent entities. |
World Lifecycle
TIP
When using tecs2d, the game loop calls update and all world lifecycle methods for you automatically.
update
Runs all phases of the game loop. Call this each frame with the time elapsed since the last update. Any pending changes are committed before the frame begins.
function World:update(dt: number)Parameters:
dt: Time since the last update in seconds.
Example:
-- In a custom game loop
while running do
local dt = computeDeltaTime()
world:update(dt)
endstartup
Runs all systems in the Startup phase group. Call this once before the main game loop begins.
function World:startup()shutdown
Runs all systems in the Shutdown phase group. Call this when the game is exiting.
function World:shutdown()Entity Management
Entity IDs
Entity IDs are numeric handles returned by world:spawn and accepted by entity APIs such as world:get, world:set, world:despawn, and world:observe. Treat them as opaque values: store them, compare them, and pass them back to the world, but don't derive gameplay meaning from the number itself.
Tecs encodes a slot and generation into each ID so it can detect stale handles after a despawned slot is reused:
local a: integer = world:spawn()
world:despawn(a)
local b: integer = world:spawn() -- may reuse storage, but gets a distinct ID
world:isAlive(a) -- false
world:isAlive(b) -- trueThe packed layout reserves 22 bits for the slot and 31 bits for the generation. That means a world can be configured for at most 2^22 allocated slots (~4M), and each slot has 2^31 generation values before wrapping. The default maxEntities is lower (2^20, roughly one million slots) to keep the preallocated entity table smaller.
Don't inspect or unpack IDs with bit.*; packed IDs can exceed 32-bit range, and LuaJIT bit operations truncate to 32 bits. If tooling needs the slot or generation, use the arithmetic layout:
local slot = id % 2^22
local generation = math.floor(id / 2^22)Configure maxEntities if you need a different entity ceiling. To react when an entity disappears, listen for OnDespawn rather than polling isAlive.
spawn
Creates a new entity in the World.
function World:spawn(...: Component): integerParameters:
...: Variable number of components to add to the entity.
Returns:
- The entity ID of the spawned entity
Notes:
- The returned ID is usable immediately regardless of whether the spawn applies instantly or stages; see Deferred Operations for when each happens.
- You can follow up with
world:set,world:remove, orworld:despawnon the returned ID. Inside a scope those calls stage in order and apply at scope close; a stageddespawncancels a staged spawn entirely. - For spawn notifications, observe the
OnSpawnevent at address 0 (world-level).OnSpawnfires inline during theworld:spawncall.
Example:
-- Spawn an entity with no components:
local id = world:spawn()
-- Spawn with multiple components:
local playerId = world:spawn(
tecs.builtins.Transform(100, 100),
tecs.builtins.Name("Player")
)
-- Get notified when any entity spawns (world-level observer).
world:observe(0, tecs.builtins.OnSpawn, function(event: tecs.builtins.OnSpawn)
print("Entity created with ID: " .. event.entity)
end)batchSpawn
Bulk-creates count entities sharing the same component signature. Instead of calling the constructor for each component per entity, batchSpawn resolves the target archetype once at call time and hands you a callback with direct column access to fill in the data.
When possible, batchSpawn returns a contiguous packed-ID range as (firstId, nil). If a contiguous range is unavailable but recycled IDs can satisfy the request, it falls back and returns (nil, ids) where ids is the explicit spawned packed-ID list.
function World:batchSpawn(
count: integer,
componentTypes: {Component},
callback: function(Archetype, integer, integer, integer)
): integer | nil, {integer} | nilParameters:
count: Number of entities to spawn.componentTypes: Array of component types defining the target archetype. All entities end up in the same archetype.callback: Called once with(archetype, firstRow, lastRow, count). Write your per-entity data by indexing into the archetype's columns. Iterate withfor i = firstRow, lastRow do ... end.
Returns:
firstId, nilwhen IDs are contiguous:firstId,firstId + 1, ...,firstId + count - 1.nil, idswhen fallback uses recycled, non-contiguous packed IDs. Iterateidsdirectly.
Example:
-- Reserve 1000 particles and fill their columns in one batch.
local cols = {Position, Velocity}
local firstId, ids = world:batchSpawn(1000, cols, function(arch, firstRow, lastRow)
-- getMut marks the written columns dirty for downstream sync.
local positions = arch:getMut(Position)
local velocities = arch:getMut(Velocity)
-- Iterate over the provided row range and mutate columns in place.
for i = firstRow, lastRow do
positions[i] = Position(math.random(0, 800), math.random(0, 600))
velocities[i] = Velocity(math.random(-50, 50), math.random(-50, 50))
end
end)
-- Outside an open deferred scope, all 1000 entities are placed and queryable.
if firstId then
-- Contiguous path: IDs are firstId, firstId + 1, ...
assert(world:isAlive(firstId))
assert(world:isAlive(firstId + 999))
else
-- Fallback path: IDs are returned explicitly.
local list = ids as {integer}
assert(world:isAlive(list[1]))
assert(world:isAlive(list[1000]))
endNotes:
- This is a deferred operation.
- IDs are reserved immediately and can be passed to
world:set,world:remove, orworld:despawnbefore the operation drains.
Sparse relationships
You can pass sparse relationship components in componentTypes. The archetype edge walk adds their wildcard container so queries match. You can't write per-entity target values through the callback because sparse columns are row-indexed read proxies. Attach targets with world:set(spawnedId, SparseRel(target)); inside a deferred scope, those sets drain alongside the batch spawn placement.
local firstId, ids = world:batchSpawn(5, {Position, ChildOf},
function(arch, firstRow, lastRow, _count)
-- Sparse relationship targets are attached below with world:set.
local positions = arch:getMut(Position)
for row = firstRow, lastRow do
local index = row - firstRow + 1
positions[row] = Position(index * 16, 0)
end
end)
local function spawnedId(index: integer): integer
if firstId then
-- Contiguous path: reconstruct the ID arithmetically.
return firstId + index - 1
end
-- Fallback path: use the explicit packed ID list.
local list = ids as {integer}
return list[index]
end
for i = 1, 5 do
-- Attach relationship targets to the reserved IDs.
world:set(spawnedId(i), ChildOf(parentId))
endbatchSpawnAt
Like batchSpawn, but uses the supplied entity IDs instead of allocating a new contiguous range. Intended for snapshot loads where each restored entity keeps its original ID.
function World:batchSpawnAt(
ids: {integer},
componentTypes: {Component},
callback: function(Archetype, integer, integer, integer)
)The archetype resolution, capacity check, and notification fan-out happen once per call regardless of how the ids are ordered.
Notes:
- This is a deferred operation.
spawnAt
Spawn an entity at a specific packed ID rather than auto-allocating one. This is mainly for snapshot loading, where relationship targets need to resolve to the same restored entity IDs. The caller is responsible for ensuring the ID is not already live.
function World:spawnAt(id: integer, ...: Component)See Save games for a complete walkthrough.
forEachArchetype
Iterate every archetype in the world. Intended for debugging and save-game tools; use query for gameplay-level iteration.
function World:forEachArchetype(callback: function(Archetype))despawn
Removes an entity from the World.
function World:despawn(entity: integer)Parameters:
entity: The entity ID to remove.
Despawn lifecycle
When you call world:despawn(entity), the following happens inline:
- Cleans up relationships targeting this entity (cascade-delete, reverse-index unlink)
- Emits an
OnDespawnevent to the entity's and world's address - Clears all observers registered on the entity's address
- Notifies query observers via
onEntitiesRemovedon the entity's current archetype
The physical removal of the entity from its archetype (the swap-pop and column writes) is a deferred operation.
To react to a component leaving an entity, attach onEntitiesRemoved to a query that includes that component.
Example:
-- Listen for despawn events
world:observe(entity, tecs.builtins.OnDespawn, function(e: tecs.builtins.OnDespawn)
print("Entity " .. e.entity .. " is being despawned")
end)
-- Remove an entity from the world
world:despawn(entity)batchDespawn
Bulk-removes every entity matching a query. Much faster than looping world:despawn when clearing out a whole archetype: when none of the matched archetypes have dense relationships or entities that are targets of reverse-indexed relationships, the entire archetype's entity data is wiped in one pass instead of processing each entity individually.
function World:batchDespawn(query: Query)Parameters:
query: AQueryobject built viaworld:query(...). Both persistent andtemp = truequeries are accepted.batchDespawndoes not accept raw component arrays or descriptors; build the query once outside your hot loop and reuse it.
Notes:
- This is a deferred operation.
OnDespawnevents fire for every despawned entity (global and per-entity observers).- Per-entity observer subscriptions are cleared after the event fans out.
- Query observers receive a single
onEntitiesRemoved(archetype, 1, count, count)for the whole range, followed byonDeactivatedonce the archetype empties. - Archetypes with dense relationships or target-of-relationship entities fall back transparently to per-entity
despawnso cascade-delete and reverse-index unlink still run correctly.
Example:
-- Build a persistent query once, reuse it for repeated bulk passes.
local dead = world:query({include = {Health, DeadTag}})
world:batchDespawn(dead)
-- Or use a temp query for a one-shot teardown.
local bullets = world:query({include = {Bullet}, temp = true})
world:batchDespawn(bullets)batchSet
Bulk-set a component on every entity matching a query. Two forms:
Constant form: write a shared instance to every matched row:
tealworld:batchSet(query, Stunned) world:batchSet(query, Position(0, 0))If an archetype in the query lacks the component, entities are bulk-moved to the archetype reached by adding it, then the new column is filled.
Callback form: ensure the component is present, then let the caller write the column directly:
tealworld:batchSet(query, Position, function(arch, firstRow, lastRow, count) local positions = arch:getMut(Position) for row = firstRow, lastRow do positions[row] = Position(math.random(), math.random()) end end)
function World:batchSet(
query: Query,
componentOrInstance: Component,
callback?: function(Archetype, integer, integer, integer)
)Notes:
- This is a deferred operation.
batchRemove
Bulk-remove a component from every entity matching query whose archetype currently carries it. Archetypes in the query that lack the component are skipped silently (no-op).
function World:batchRemove(query: Query, componentType: Component)Notes:
- This is a deferred operation.
compact
Prune unreachable empty archetypes (those whose relationship targets have been despawned) and shrink overallocated archetype storage. Must be called on a quiet world (no pending mutations); call world:commit() first if unsure.
function World:compact(): integer, integerReturns:
archetypesPruned: Number of dead archetypes removed.archetypesCompacted: Number of surviving archetypes whose column storage was shrunk.
Call compact on level transitions or other natural "quiet points"; it's cheap to skip and expensive if called every frame while entities churn.
clearEntities
Wipes all entity data from the world while preserving structural state: the pipeline, registered systems, queries (and their observers), bundles, and archetype column capacity all survive. Useful for per-test reuse, benchmark setup, and save/load "clear before load" flows.
function World:clearEntities()Clears:
- All entities (entity index + archetype rows).
- Pending transaction state (queued spawns, mutations, despawns, sparse relationship writes).
- Queued events and per-entity event observers, i.e. those registered on an entity's address via
world:observe(entityId, ...). Their entities are gone.
Preserves:
- Registered systems and the pipeline they run in.
- Queries, including the global subscription each one uses to track archetypes. A query keeps matching entities spawned after the clear, even into archetypes that did not exist before it, with no rebuild.
- Global (address
0) event observers registered viaworld:observe(0, ...). - Archetype columns (chunks stay allocated, the next batch doesn't pay the re-grow-from-zero cost).
- Bundle registrations.
- Global component registrations.
Example:
-- In a test or bench, reset the state between iterations without
-- rebuilding the pipeline or re-registering queries.
local q = world:query({include = {Position}})
for _ = 1, iterations do
world:clearEntities()
world:spawn(Position(10, 10))
world:commit()
local n = 0
for _arch, len in q:iter() do n = n + len end
assert(n == 1)
endWhen you want a fully-fresh world
If you need to drop systems and queries too (the "post-construction" state), just call tecs.newWorld(); it's the same code path and makes the intent obvious at the call site.
isAlive
Checks if an entity is alive.
function World:isAlive(entity: integer): booleanParameters:
entity: The entity ID to check.
Returns:
trueif the entity exists
Despawning entities
When an entity begins despawning, isAlive still returns true since the despawn is not yet committed.
Example:
if world:isAlive(entity) then
world:despawn(entity)
endComponent Management
World component methods read, write, and remove components on individual entities. See Components for component kinds and access patterns, and Dirty tracking for getMut and explicit dirty marking.
| Method | Description |
|---|---|
world:get | Return one component from an entity. |
world:getMut | Return a component for in-place mutation and mark its column dirty. |
world:getFirstRelationship | Return the first relationship instance for a relationship container. |
world:has | Check whether an entity has a component or relationship target. |
world:set | Attach or replace a component on an entity. |
world:remove | Remove a component from an entity. |
world:markComponentDirty | Mark a component column dirty for one entity's archetype. |
Bundles
Bundles are reusable templates for spawning entities with predefined components. See Component bundles for full documentation.
| Method | Description |
|---|---|
world:newBundle | Create and register a bundle. |
world:spawnBundle | Spawn an entity from a registered bundle by name. |
world:getBundle | Return one registered bundle by name. |
world:getBundles | Return all registered bundles. |
Queries
World query methods find matching archetypes and entities. See Queries for descriptors, iteration, grouping, callbacks, and mutation rules.
| Method | Description |
|---|---|
world:query | Create a persistent or temporary query from a descriptor. |
world:findArchetypes | Iterate archetypes that contain one component. |
Hierarchy Traversal
Relationships with reverseIndex = true (such as the builtin ChildOf) maintain an inverse index for efficient reverse lookups. Works for both sparse and dense relationships. See Relationships for details.
For relationships with reverseIndex = true (e.g. the builtin ChildOf), the world exposes three traversal methods. See Relationships → Hierarchy traversal for full signatures, semantics, and the context-passing performance pattern.
world:targets(entity, relationship, callback, context?): invokescallback(sourceId, context)for each direct source entity targeting the given entity. Use this to iterate a parent's direct children, an entity's followers, etc.world:traverse(root, relationship): DFS iterator yielding(depth, entityId)for the full subtree underroot.world:walkUp(entity, relationship, callback, context?, maxDepth?): invokescallback(ancestorId, depth, context)for each ancestor up the parent chain. Returnfalsefrom the callback to stop early.maxDepthdefaults to 100 and errors if exceeded.
Deferred Operations
Tecs uses a scope depth counter to decide whether mutations apply instantly or stage.
When the depth is zero and you call a mutating API, the change applies before the call returns:
set/remove/spawn/despawngo through a fast instant path.batchSpawn/batchSpawnAt/batchDespawn/batchSet/batchRemoveinternally open a scope, stage their work, and drain before returning.
When the depth is greater than zero, every one of those calls stages into a pending transaction and applies only after the scope closes. From the caller's perspective, the rule is simply: outside a scope a mutation is visible as soon as the call returns; inside a scope it isn't.
Scopes are opened automatically by:
- Iterating a query (the iterator pushes a scope on its first step and pops it on exhaustion or
break). - Query callbacks (
onEntitiesAdded/onEntitiesRemoved) while the drain that triggered them is running. - Each batch call, for the duration of the call (including
batchSpawn's usercallback).
You can also open and close scopes explicitly with world:defer() and world:commit().
Systems do not auto-commit between each other
world:update calls world:commit() once at the start of the frame to flush anything still pending from the previous tick, then dispatches the pipeline. Phases do not insert a commit between individual systems: iterating a query inside a system opens and closes a scope inline, but two consecutive plain world:set(id, …) statements in different systems each apply instantly on their own. If one system needs to see changes another system staged earlier in the same phase, it has to call world:commit() itself.
defer
Opens a deferred scope. All subsequent mutations stage instead of applying instantly, until a matching commit closes it. Calls nest: each defer increments a depth counter; mutations stage while the counter is above zero.
function World:defer()Use defer when you want a block of mutations to appear atomically; for example, if a helper wants to avoid partial archetype transitions being visible to observers mid-block.
local function killEntity(world: tecs.World, entity: integer)
world:defer()
world:set(entity, HitPoints(0))
world:set(entity, RagdollState())
world:remove(entity, AIController)
world:commit() -- drain is issued here
endcommit
Closes one deferred scope level. When the scope counter reaches zero and the world has pending staged mutations, the transaction drains: spawns are placed, component moves execute, query observers fire, and sparse relationship writes apply.
function World:commit()Notes:
commitis the matching counterpart todefer; calls nest symmetrically.world:update(dt)callscommitonce at the very start as a safety net for any mutations left pending by prior host-code paths. It does not callcommitbetween individual systems in the pipeline.- Outside any scope,
world:commit()is harmless; the depth is already zero and there's nothing to drain. commitnever discards staged work. If you open an explicit scope, closing it always applies the pending mutations once the outermost scope finishes.
Example:
-- Force pending changes to be applied.
local id: integer = world:spawn(Transform(10, 20))
world:commit()Systems Management
World system methods add and remove work from the pipeline. See Systems for system configuration, ordering, conditional execution, and removal rules.
| Method | Description |
|---|---|
world:addSystem | Add a system to the world's pipeline. |
world:removeSystem | Remove a named system from the world's pipeline. |
Plugins
Use plugins to add systems, components, states, and more to a World. Tecs builds everything around plugins.
addPlugin
Adds a plugin to the world.
function World:addPlugin(plugin: function(world: World))Parameters:
plugin: Function that configures the world.
Example:
local PHYSICS: tecs.Key<PhysicsConfig> = tecs.newKey()
-- Define a plugin
local function physicsPlugin(world: tecs.World)
-- Add physics systems
world:addSystem({
name = "PhysicsSystem",
phase = tecs.phases.FixedUpdate,
run = function(dt: number, world: tecs.World)
-- Physics simulation logic
end
})
-- Add physics resources
world.resources[PHYSICS] = { gravity = 9.8 }
end
-- Add the plugin to the world
world:addPlugin(physicsPlugin)Resources
Resources store globally shared data that systems and plugins can access.
-- Define a resource type
local record GameSettings
difficulty: string
volume: number
end
-- Define a resource
local gameSettings: GameSettings = {
difficulty = "normal",
volume = 0.8
}
-- Define key for the resource.
local GAME_SETTINGS: tecs.Key<GameSettings> = tecs.newKey()
-- Add a resource to the world
world.resources[GAME_SETTINGS] = gameSettings
-- Get a resource
local settings = world.resources[GAME_SETTINGS]
print("Difficulty:", settings.difficulty)You can define resource keys for numbers, strings, and any other type too.
local GAME_UUID: tecs.Key<string> = tecs.newKey()
world.resources[GAME_UUID] = "abc"Phase Management
World phase methods control the pipeline's phase tree. See Phases for phase groups, fixed vs variable timing, custom phases, and examples.
| Method | Description |
|---|---|
world:enablePhase | Enable a phase or phase group. |
world:disablePhase | Disable a phase or phase group. |
world:registerPhase | Register a custom phase with the world's pipeline. |
world:runPhase | Run a phase or phase group explicitly. |
State Management
The state stack manages game states with automatic entity lifecycle. See States for full documentation.
| Method | Description |
|---|---|
world:createState | Create a named state and return its tag component. |
world:pushState | Push a state onto the stack. |
world:popState | Pop the current state from the stack. |
world:peekState | Return the current top state name. |
Events
World event methods use the address-based event system. See Events for event types, addresses, constructor behavior, and MessageBus details.
| Method | Description |
|---|---|
world:observe | Subscribe to an event at a world or entity address. |
world:emit | Emit an event instance or construct-and-emit an event type. |
world:hasObservers | Check whether any observer exists for an address and event type. |
world:stopObserving | Remove a callback or named observer. |
world:clearObservers | Remove all observers at one address. |
Stats
getStats
Get statistics about the World.
function World:getStats(fill?: world.Stats): world.StatsParameters:
fill: Optional stats table to fill instead of allocating a new one (for reducing garbage collection pressure)
Returns:
- Stats object with the following fields:
| Field | Type | Description |
|---|---|---|
entities | integer | The number of active entities in the world |
archetypes | integer | The number of archetypes |
components | integer | The number of unique component types in use |
systems | integer | The number of registered systems |
Example:
-- Create a new stats table
local stats = world:getStats()
print("Entities:", stats.entities)
print("Archetypes:", stats.archetypes)
print("Components:", stats.components)
print("Systems:", stats.systems)
-- Reuse an existing stats table (reduces allocations)
local myStats = {}
world:getStats(myStats)
print("Entities:", myStats.entities)