On this page
  1. Dirty tracking
  2. Declare write intent
  3. Automatic marks
  4. Batch initialization

Dirty tracking

Tecs tracks dirty state per archetype and component. One mark means that some row in the column may have changed. It does not track individual rows.

Rendering and other incremental consumers use that signal to skip unchanged columns. A write that Tecs cannot see leaves those consumers with stale data.

Declare write intent

Read through get and write through getMut:

for archetype, length in movers:iter() do
    local transforms <const> = archetype:getMut(tecs.Transform2D)
    local velocities <const> = archetype:get(Velocity)

    for row = 1, length do
        local transform <const> = transforms[row]
        local velocity <const> = velocities[row]

        transform.x = transform.x + velocity.x * dt
        transform.y = transform.y + velocity.y * dt
    end
end

getMut marks the entire component column dirty before returning it. Never call it speculatively in a loop that might not write.

For a conditional write, read first and mark only when the condition succeeds:

local transforms <const> = archetype:get(tecs.Transform2D)
local changed = false

for row = 1, length do
    if needsCorrection(transforms[row]) then
        correct(transforms[row])
        changed = true
    end
end

if changed then
    archetype:markComponentDirty(tecs.Transform2D)
end

LuaJIT cannot enforce read-only cdata. A field assignment through world:get or archetype:get changes memory without marking it. Prefer getMut; otherwise call world:markComponentDirty or the archetype marker after the write.

Automatic marks

These paths maintain dirty state:

  • getMut, world:set, and archetype:set
  • spawn placement
  • movement into another archetype
  • swap-pop after removal

Spawn and structural movement mark every component on the archetype, because a row moving in has every column newly written at that row.

Tecs records those marks as one archetype-wide structural flag rather than as every column's bit, and every reader above composes the flag in. getMut, markComponentDirty and set still set the bit for the one column they name. The distinction is not observable through these readers, and it is what lets the renderer's partialRewrites option rewrite the rows a spawn wrote instead of the archetype's whole run: a value write names a column and says nothing about which rows changed, while a structural change carries its rows.

world:update clears marks after the pipeline runs. A consumer can iterate dirty archetypes, test one component, test any component, or iterate dirty components. Do not structurally mutate the world while walking dirtyArchetypes.

Batch initialization

batchSpawn reserves rows and calls the fill callback without running component constructors. FFI defaults do not run. Initialize every field the batch will use:

world:batchSpawn(
    1000,
    {Position},
    function(archetype, firstRow, lastRow)
        local positions <const> = archetype:getMut(Position)

        for row = firstRow, lastRow do
            positions[row].x = 0
            positions[row].y = 0
        end
    end
)

Placement already marks the destination columns. The callback still uses getMut to state its write intent clearly.

The mutation model defines the normative marking rules.