Skip to content

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.

teal
local tecs = require("tecs")

Create a World that by default updates at 60 FPS using default configuration:

teal
local world = tecs.newWorld()

Create a World that updates at 30 FPS:

teal
local world = tecs.newWorld({
    timestep = 1 / 30
})

newWorld Fields:

ParameterTypeRequiredDefaultDescription
timestepnumberNo1/60The fixed timestep of the game in seconds
pipelineFactoryfunction(number, function): PipelineNoBuilt-inCustom factory for creating the system pipeline
maxEntitiesintegerNo2^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.

teal
function World:update(dt: number)

Parameters:

  • dt: Time since the last update in seconds.

Example:

teal
-- In a custom game loop
while running do
    local dt = computeDeltaTime()
    world:update(dt)
end

startup

Runs all systems in the Startup phase group. Call this once before the main game loop begins.

teal
function World:startup()

shutdown

Runs all systems in the Shutdown phase group. Call this when the game is exiting.

teal
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:

teal
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)        -- true

The 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:

teal
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.

teal
function World:spawn(...: Component): integer

Parameters:

  • ...: 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, or world:despawn on the returned ID. Inside a scope those calls stage in order and apply at scope close; a staged despawn cancels a staged spawn entirely.
  • For spawn notifications, observe the OnSpawn event at address 0 (world-level). OnSpawn fires inline during the world:spawn call.

Example:

teal
-- 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.

teal
function World:batchSpawn(
    count: integer,
    componentTypes: {Component},
    callback: function(Archetype, integer, integer, integer)
): integer | nil, {integer} | nil

Parameters:

  • 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 with for i = firstRow, lastRow do ... end.

Returns:

  • firstId, nil when IDs are contiguous: firstId, firstId + 1, ..., firstId + count - 1.
  • nil, ids when fallback uses recycled, non-contiguous packed IDs. Iterate ids directly.

Example:

teal
-- 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]))
end

Notes:

  • This is a deferred operation.
  • IDs are reserved immediately and can be passed to world:set, world:remove, or world:despawn before 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.

teal
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))
end

batchSpawnAt

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.

teal
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:

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.

teal
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.

teal
function World:forEachArchetype(callback: function(Archetype))

despawn

Removes an entity from the World.

teal
function World:despawn(entity: integer)

Parameters:

  • entity: The entity ID to remove.

Despawn lifecycle

When you call world:despawn(entity), the following happens inline:

  1. Cleans up relationships targeting this entity (cascade-delete, reverse-index unlink)
  2. Emits an OnDespawn event to the entity's and world's address
  3. Clears all observers registered on the entity's address
  4. Notifies query observers via onEntitiesRemoved on 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:

teal
-- 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.

teal
function World:batchDespawn(query: Query)

Parameters:

  • query: A Query object built via world:query(...). Both persistent and temp = true queries are accepted. batchDespawn does not accept raw component arrays or descriptors; build the query once outside your hot loop and reuse it.

Notes:

  • This is a deferred operation.
  • OnDespawn events 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 by onDeactivated once the archetype empties.
  • Archetypes with dense relationships or target-of-relationship entities fall back transparently to per-entity despawn so cascade-delete and reverse-index unlink still run correctly.

Example:

teal
-- 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:

    teal
    world: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:

    teal
    world: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)
teal
function World:batchSet(
    query: Query,
    componentOrInstance: Component,
    callback?: function(Archetype, integer, integer, integer)
)

Notes:

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).

teal
function World:batchRemove(query: Query, componentType: Component)

Notes:

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.

teal
function World:compact(): integer, integer

Returns:

  • 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.

teal
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 via world: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:

teal
-- 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)
end

When 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.

teal
function World:isAlive(entity: integer): boolean

Parameters:

  • entity: The entity ID to check.

Returns:

  • true if the entity exists

Despawning entities

When an entity begins despawning, isAlive still returns true since the despawn is not yet committed.

Example:

teal
if world:isAlive(entity) then
    world:despawn(entity)
end

Component 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.

MethodDescription
world:getReturn one component from an entity.
world:getMutReturn a component for in-place mutation and mark its column dirty.
world:getFirstRelationshipReturn the first relationship instance for a relationship container.
world:hasCheck whether an entity has a component or relationship target.
world:setAttach or replace a component on an entity.
world:removeRemove a component from an entity.
world:markComponentDirtyMark 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.

MethodDescription
world:newBundleCreate and register a bundle.
world:spawnBundleSpawn an entity from a registered bundle by name.
world:getBundleReturn one registered bundle by name.
world:getBundlesReturn all registered bundles.

Queries

World query methods find matching archetypes and entities. See Queries for descriptors, iteration, grouping, callbacks, and mutation rules.

MethodDescription
world:queryCreate a persistent or temporary query from a descriptor.
world:findArchetypesIterate 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?): invokes callback(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 under root.
  • world:walkUp(entity, relationship, callback, context?, maxDepth?): invokes callback(ancestorId, depth, context) for each ancestor up the parent chain. Return false from the callback to stop early. maxDepth defaults 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 / despawn go through a fast instant path.
  • batchSpawn / batchSpawnAt / batchDespawn / batchSet / batchRemove internally 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 user callback).

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.

teal
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.

teal
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
end

commit

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.

teal
function World:commit()

Notes:

  • commit is the matching counterpart to defer; calls nest symmetrically.
  • world:update(dt) calls commit once at the very start as a safety net for any mutations left pending by prior host-code paths. It does not call commit between individual systems in the pipeline.
  • Outside any scope, world:commit() is harmless; the depth is already zero and there's nothing to drain.
  • commit never discards staged work. If you open an explicit scope, closing it always applies the pending mutations once the outermost scope finishes.

Example:

teal
-- 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.

MethodDescription
world:addSystemAdd a system to the world's pipeline.
world:removeSystemRemove 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.

teal
function World:addPlugin(plugin: function(world: World))

Parameters:

  • plugin: Function that configures the world.

Example:

teal
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.

teal
-- 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.

teal
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.

MethodDescription
world:enablePhaseEnable a phase or phase group.
world:disablePhaseDisable a phase or phase group.
world:registerPhaseRegister a custom phase with the world's pipeline.
world:runPhaseRun a phase or phase group explicitly.

State Management

The state stack manages game states with automatic entity lifecycle. See States for full documentation.

MethodDescription
world:createStateCreate a named state and return its tag component.
world:pushStatePush a state onto the stack.
world:popStatePop the current state from the stack.
world:peekStateReturn 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.

MethodDescription
world:observeSubscribe to an event at a world or entity address.
world:emitEmit an event instance or construct-and-emit an event type.
world:hasObserversCheck whether any observer exists for an address and event type.
world:stopObservingRemove a callback or named observer.
world:clearObserversRemove all observers at one address.

Stats

getStats

Get statistics about the World.

teal
function World:getStats(fill?: world.Stats): world.Stats

Parameters:

  • fill: Optional stats table to fill instead of allocating a new one (for reducing garbage collection pressure)

Returns:

  • Stats object with the following fields:
FieldTypeDescription
entitiesintegerThe number of active entities in the world
archetypesintegerThe number of archetypes
componentsintegerThe number of unique component types in use
systemsintegerThe number of registered systems

Example:

teal
-- 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)