--- url: /tecs.md --- # Getting Started Tecs is a typed, archetype-based ECS for [LuaJIT](https://luajit.org) and [Teal](https://teal-language.org). Tecs is the core of [Tecs2D](/tecs2d/), a Love2D engine. ::: warning Tecs is in preview Tecs is not yet stable and may change as development progresses. ::: ## Installation First, install LuaJIT and LuaRocks if you haven't already: ::: code-group ```bash [macOS] brew install luajit luarocks ``` ```bash [Debian/Ubuntu] sudo apt install luajit libluajit-5.1-dev luarocks ``` ```bash [Arch] sudo pacman -S luajit luarocks ``` ```powershell [Windows] scoop install luajit luarocks ``` ```bash [Source] # LuaJIT build guide: https://luajit.org/install.html git clone https://github.com/LuaJIT/LuaJIT.git cd LuaJIT && make && sudo make install # Then install LuaRocks: # https://github.com/luarocks/luarocks/blob/main/docs/download.md ``` ::: Install Tecs via [LuaRocks](https://luarocks.org). For typical gamedev, you'll want a self-contained build: ```bash luarocks install --dev --tree=vendor --lua-version=5.1 tecs ``` *While Tecs is in preview, `--dev` is required. There are no tagged releases yet.* Require Tecs in your code: ```teal local tecs = require("tecs") ``` ::: tip Building a game? If you are building a Love2D game and want the engine layer as well, install `tecs2d` instead. It depends on `tecs` automatically and provides rendering, audio, input, physics, UI, and the Love2D loop integration. See [Tecs2D Getting Started](/tecs2d/) for the starter template and build commands. ::: ## Tecs in a nutshell ### World A `World` contains all the entities, components, systems, plugins, and resources of a game. ```teal local world = tecs.newWorld() ``` > See the [World reference](/tecs/world) for more information ### Entity A unique ID that represents an object in the game world. Entities themselves have no data or behavior; only the components attached to them define what they are. ```teal -- Create an entity with two components and get the entity ID local entityId = world:spawn( tecs.builtins.Name("Hello Tecs"), tecs.builtins.Transform(100, 100) ) ``` ### Component Components describe traits like position, velocity, or health, and are the building blocks of game state. ```teal -- Get the Name component of the entity local name = world:get(entityId, tecs.builtins.Name) print(name.value) ``` ::: tip Tecs is strongly typed By passing in the **type** of the component to `get`, the Teal type system knows you are getting back a component of the same type. ::: #### Creating a component You can create a new component by defining a Teal record: ```teal local record Sprite is tecs.Component texture: love.graphics.Texture metamethod __call: function(self, love.graphics.Texture): self end ``` Next, pass a configuration table to `tecs.newComponent` to wire up the necessary metatables to make it a component. ```teal tecs.newComponent({ name = "Sprite", container = Sprite, fields = {"texture"}, init = function(instance: Sprite, texture: love.graphics.Texture) instance.texture = texture end }) -- Set the Sprite component on the entity local image = love.graphics.newImage("cactus.png") world:set(entityId, Sprite(image)) ``` > See the [Components reference](/tecs/components/) for more information ### System A system is a function that runs game logic by operating on entities with specific components. Add behavior through systems. Add systems to [phases](/tecs/phases) to run at specific parts of the game loop. ```teal world:addSystem({ phase = tecs.phases.Update, run = function(dt: number) print("Time since last frame: " .. dt) end }) ``` > See the [Systems reference](/tecs/systems) for more information ### Plugin Use plugins to configure the world: add systems, register components, set up resources, and hook up event observers. Plugins bundle parts of a game into modular units. ```teal world:addPlugin(function(world: tecs.World) -- register components, systems, spawn entities, add resources, ... end) ``` :::tip Plugins power everything You'll use plugins extensively to write your game logic, include plugins from other libraries like [tecs2d](/tecs2d/), and install systems. ::: ### Queries Create systems inside plugins. For systems to be useful, they typically use a **query** to find entities in the world. Systems can use zero or more queries. Create queries in plugins outside the system scope, then reuse them over the system's lifetime. ```teal local Transform = tecs.builtins.Transform local spritePlugin = function(world: tecs.World) -- Find entities with Transform and Sprite components local spriteQuery = world:query({ include = {Transform, Sprite} }) -- Draw the sprites in the render phase world:addSystem({ phase = tecs.phases.Render, run = function() -- Iterate over entities with both Transform and Sprite. for archetype, len, entities in spriteQuery:iter() do local transforms = archetype:get(Transform) local sprites = archetype:get(Sprite) for row = 1, len do local id = entities[row] local tx = transforms[row] local sprite = sprites[row] love.graphics.draw(sprite.texture, tx.x, tx.y) end end end }) end -- Register the plugin with the world world:addPlugin(spritePlugin) ``` > See the [Query reference](/tecs/queries/) for more information ### Archetypes Every unique combination of components applied to entities forms an *archetype*. An entity belongs to exactly one archetype. Archetypes give you fast access to entity IDs and components of the entities stored in the archetype. You'll interact with archetypes primarily through queries. ```teal -- Grab "columns" using archetype:get (read) or archetype:getMut (mark dirty) local transforms = archetype:get(Transform) local sprites = archetype:get(Sprite) for row = 1, len do -- Index into the archetype columns by row local tx = transforms[row] local sprite = sprites[row] end ``` > See the [Archetype reference](/tecs/archetype) for more information ::: tip Archetypes and queries Archetypes organize entities into groups based on their components. Queries find matching archetypes, then iterate their component columns by row. This is why query examples bind columns once per archetype instead of fetching components one entity at a time. ::: ### Phases To progress the game, call `world:update(dt)` with the time since last update. This invokes every phase of the Tecs game loop and runs each system in those phases. When you add a system to a world, you specify its phase. Access phases with `tecs.phases.`: ```teal world:addSystem({ phase = tecs.phases.Startup, run = function() print("The game is starting up!") end }) ``` > See the [Phases reference](/tecs/phases) for a list of phases ::: tip tecs2d handles phase execution timing automatically when using Love2D. See [Love2D integration](/tecs2d/love2d). ::: ### Resources *Resources* in Tecs are the built-in way to share variables globally across your game, but **without globals**. To add resources to a world, you first need to create a strongly typed key. ```teal local FONT: tecs.Key = tecs.newKey() ``` This tells the Teal type system that `FONT` contains a `love.graphics.Font`. Now you can assign a value to the resource: ```teal world.resources[FONT] = love.graphics.newFont(filename, glyphs) ``` You can access the resource using the key too: ```teal local font = world.resources[FONT] ``` ::: tip Store keys in modules Store your keys in a module because you need to refer to the exact same key when trying to access the resource. ```teal local record MyModule FONT: tecs.Key end MyModule.FONT = tecs.newKey() ``` ::: --- --- url: /tecs/world.md --- # 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:** | 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. ```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`](#creating-a-world) if you need a different entity ceiling. To react when an entity disappears, listen for [`OnDespawn`](/tecs/builtins#ondespawn-event) 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](#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](#deferred-operations). * 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:** * This is a [deferred operation](#deferred-operations). ### 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](/tecs/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. ::: info 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](/tecs/builtins#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](#deferred-operations). To react to a component leaving an entity, attach [`onEntitiesRemoved`](/tecs/queries/callbacks) 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](#deferred-operations). * `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:** * This is a [deferred operation](#deferred-operations). ### 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:** * This is a [deferred operation](#deferred-operations). ### 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 ``` ::: tip 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 ::: info 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](/tecs/components/) for component kinds and access patterns, and [Dirty tracking](/tecs/components/dirty-tracking) for `getMut` and explicit dirty marking. | Method | Description | | ------ | ----------- | | [`world:get`](/tecs/components/#world-get) | Return one component from an entity. | | [`world:getMut`](/tecs/components/#world-get-mut) | Return a component for in-place mutation and mark its column dirty. | | [`world:getFirstRelationship`](/tecs/components/#world-get-first-relationship) | Return the first relationship instance for a relationship container. | | [`world:has`](/tecs/components/#world-has) | Check whether an entity has a component or relationship target. | | [`world:set`](/tecs/components/#world-set) | Attach or replace a component on an entity. | | [`world:remove`](/tecs/components/#world-remove) | Remove a component from an entity. | | [`world:markComponentDirty`](/tecs/components/#world-mark-component-dirty) | Mark a component column dirty for one entity's archetype. | ## Bundles Bundles are reusable templates for spawning entities with predefined components. See [Component bundles](/tecs/components/bundles) for full documentation. | Method | Description | | ------ | ----------- | | [`world:newBundle`](/tecs/components/bundles#world-new-bundle) | Create and register a bundle. | | [`world:spawnBundle`](/tecs/components/bundles#world-spawn-bundle) | Spawn an entity from a registered bundle by name. | | [`world:getBundle`](/tecs/components/bundles#world-get-bundle) | Return one registered bundle by name. | | [`world:getBundles`](/tecs/components/bundles#world-get-bundles) | Return all registered bundles. | ## Queries World query methods find matching archetypes and entities. See [Queries](/tecs/queries/) for descriptors, iteration, grouping, callbacks, and mutation rules. | Method | Description | | ------ | ----------- | | [`world:query`](/tecs/queries/#world-query) | Create a persistent or temporary query from a descriptor. | | [`world:findArchetypes`](/tecs/queries/#world-find-archetypes) | 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](/tecs/relationships/#sparse-relationships) for details. For relationships with `reverseIndex = true` (e.g. the builtin `ChildOf`), the world exposes three traversal methods. See [Relationships → Hierarchy traversal](/tecs/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](/tecs/queries/) (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()`. ::: info 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`](#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](/tecs/systems) for system configuration, ordering, conditional execution, and removal rules. | Method | Description | | ------ | ----------- | | [`world:addSystem`](/tecs/systems#world-add-system) | Add a system to the world's pipeline. | | [`world:removeSystem`](/tecs/systems#world-remove-system) | 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. ```teal function World:addPlugin(plugin: function(world: World)) ``` **Parameters:** * `plugin`: Function that configures the world. **Example:** ```teal local PHYSICS: tecs.Key = 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 = 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 = tecs.newKey() world.resources[GAME_UUID] = "abc" ``` ## Phase Management World phase methods control the pipeline's phase tree. See [Phases](/tecs/phases) for phase groups, fixed vs variable timing, custom phases, and examples. | Method | Description | | ------ | ----------- | | [`world:enablePhase`](/tecs/phases#world-enable-phase) | Enable a phase or phase group. | | [`world:disablePhase`](/tecs/phases#world-disable-phase) | Disable a phase or phase group. | | [`world:registerPhase`](/tecs/phases#world-register-phase) | Register a custom phase with the world's pipeline. | | [`world:runPhase`](/tecs/phases#world-run-phase) | Run a phase or phase group explicitly. | ## State Management The state stack manages game states with automatic entity lifecycle. See [States](/tecs/states) for full documentation. | Method | Description | | ------ | ----------- | | [`world:createState`](/tecs/states#world-create-state) | Create a named state and return its tag component. | | [`world:pushState`](/tecs/states#world-push-state) | Push a state onto the stack. | | [`world:popState`](/tecs/states#world-pop-state) | Pop the current state from the stack. | | [`world:peekState`](/tecs/states#world-peek-state) | Return the current top state name. | ## Events World event methods use the address-based event system. See [Events](/tecs/events) for event types, addresses, constructor behavior, and MessageBus details. | Method | Description | | ------ | ----------- | | [`world:observe`](/tecs/events#world-observe) | Subscribe to an event at a world or entity address. | | [`world:emit`](/tecs/events#world-emit) | Emit an event instance or construct-and-emit an event type. | | [`world:hasObservers`](/tecs/events#world-has-observers) | Check whether any observer exists for an address and event type. | | [`world:stopObserving`](/tecs/events#world-stop-observing) | Remove a callback or named observer. | | [`world:clearObservers`](/tecs/events#world-clear-observers) | Remove 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: | 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:** ```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) ``` --- --- url: /tecs/phases.md --- # Phases Tecs divides its game loop into *phases*. Add [systems](/tecs/systems) to phases to run game logic at specific points: when the game starts, each frame update, frame render, and shutdown. ::: tip Love2D bindings The [Love2D integration](/tecs2d/love2d) provides these phases out of the box. ::: ## Phase groups Tecs organizes phases into hierarchical groups: ### StartupGroup One-time initialization phases run when `world:startup()` is called. * `PreStartup` - Critical initialization before main startup * `Startup` - Main startup phase * `PostStartup` - Final setup after startup ### MainGroup The main game loop that runs every frame when `world:update(dt)` is called. * `First` - Very start of each frame (typically reserved for framework code) * `PreUpdate` - Before main update * **`FixedUpdateGroup`** - Fixed timestep loop (may run 0-N times per frame) * `FixedFirst` - Start of fixed update iteration (typically reserved for framework code) * `FixedPreUpdate` - Preparation for game logic * `FixedUpdate` - Main game logic and physics * `FixedPostUpdate` - After game logic * `FixedLast` - End of fixed update iteration (typically reserved for framework code) * `Update` - Variable timestep presentation update * `PostUpdate` - After presentation, before rendering * **`RenderGroup`** - Rendering phases * `RenderFirst` - Start of rendering (typically reserved for framework code) * `PreRender` - Render preparation * `Render` - Main rendering * `Draw` - [Custom CPU draw calls](/tecs2d/rendering/custom-drawing) with lighting and depth sorting (runs inside Render) * `PostRender` - Post-processing and effects * `RenderLast` - End of rendering (typically reserved for framework code) * `Last` - Very end of each frame ### ShutdownGroup One-time cleanup phases run when `world:shutdown()` is called. * `PreShutdown` - Preparation for shutdown * `Shutdown` - Main shutdown phase * `PostShutdown` - Final cleanup ## Fixed vs variable phases * **Fixed timestep phases** (`FixedUpdate` and related): used for physics, game logic, AI, and anything affecting gameplay that should feel consistent regardless of speed of the computer. * **Variable timestep phases** (`Update` and related): used for visual presentation, animations, camera smoothing, UI effects, and generally anything else that looks or feels better the faster the computer. ## Using phases Access phases through `tecs.phases`: ```teal local tecs = require("tecs") -- Add a system to the Update phase world:addSystem({ phase = tecs.phases.Update, run = myUpdateSystem }) -- Add a physics system to the fixed timestep world:addSystem({ phase = tecs.phases.FixedUpdate, run = myPhysicsSystem }) ``` ## Managing phases These methods are available on every `World`. | Method | Description | | ------ | ----------- | | [`world:enablePhase`](#world-enable-phase) | Enable a phase or phase group. | | [`world:disablePhase`](#world-disable-phase) | Disable a phase or phase group. | | [`world:registerPhase`](#world-register-phase) | Register a custom phase with the world's pipeline. | | [`world:runPhase`](#world-run-phase) | Run a phase or phase group explicitly. | ### Enabling and disabling phases You can dynamically enable and disable phases to control which systems run: ```teal -- Disable a phase (and all its systems) world:disablePhase(tecs.phases.RenderGroup) -- Disable all rendering -- Re-enable a phase world:enablePhase(tecs.phases.RenderGroup) -- Disable fixed timestep when paused world:disablePhase(tecs.phases.FixedUpdateGroup) ``` ::: warning Disabling parent phases Disabling a parent phase (like `RenderGroup`) also disables all its children phases. ::: ### Running specific phases You can explicitly run a specific phase using `world:runPhase()`: ```teal -- Run only the Render phase world:runPhase(tecs.phases.Render) -- Run the entire RenderGroup world:runPhase(tecs.phases.RenderGroup) ``` ::: info Disabled phases behavior When you disable a phase: * It won't run during the normal game loop * You can still explicitly run it with `world:runPhase()` * If you disable a parent phase, its child phases remain disabled even when you explicitly run the parent ::: ```teal -- Disable all rendering world:disablePhase(tecs.phases.RenderGroup) -- This runs RenderGroup but NOT its children (PreRender, Render, PostRender, etc.) world:runPhase(tecs.phases.RenderGroup) -- To run a specific child phase when parent is disabled: world:runPhase(tecs.phases.Render) -- This works even though RenderGroup is disabled ``` ### world:enablePhase {#world-enable-phase} Enables a phase or phase group so it runs during normal pipeline execution. ```teal function World:enablePhase(phase: Phase) ``` **Parameters:** * `phase`: Phase or phase group to enable. ### world:disablePhase {#world-disable-phase} Disables a phase or phase group during normal pipeline execution. Disabling a parent phase also disables its child phases. ```teal function World:disablePhase(phase: Phase) ``` **Parameters:** * `phase`: Phase or phase group to disable. ### world:registerPhase {#world-register-phase} Registers a custom phase with the world's pipeline. Use this when an extension or custom pipeline adds phases outside the built-in `tecs.phases` tree. ```teal function World:registerPhase(phase: Phase) ``` **Parameters:** * `phase`: Phase to register. ### world:runPhase {#world-run-phase} Runs a phase or phase group immediately. Disabled child phases are still skipped when running a disabled parent group; run a child phase directly if you need to bypass the parent. ```teal function World:runPhase(phase: Phase, dt?: number) ``` **Parameters:** * `phase`: Phase or phase group to run. * `dt`: Optional delta time passed to systems in that phase. Defaults to `0`. --- --- url: /tecs/systems.md --- # Systems A system is a function that runs game logic in specific [phases](/tecs/phases) of the [World](/tecs/world). ## Creating a system Add systems to the world with `world:addSystem()`, passing a configuration table. The `run` function receives the current frame delta time and the world. ```teal world:addSystem({ phase = tecs.phases.Startup, run = function(dt: number, world: tecs.World) print("The game is starting up!") end }) ``` ::: tip Organize systems with plugins Systems are typically grouped into [plugins](/tecs/plugins) so related queries, resources, and systems can be installed together with a single `world:addPlugin(...)` call. `addSystem` works anywhere you have a world reference, but plugins keep related code localized. ::: ## World methods These methods are available on every `World`. | Method | Description | | ------ | ----------- | | [`world:addSystem`](#world-add-system) | Add a system to the world's pipeline. | | [`world:removeSystem`](#world-remove-system) | Remove a named system from the world's pipeline. | ### world:addSystem {#world-add-system} Adds a system to the world's pipeline. ```teal function World:addSystem(config: SystemConfig) ``` **Parameters:** * `config`: System configuration. ### world:removeSystem {#world-remove-system} Removes a named system from the world's pipeline. ```teal function World:removeSystem(systemName: string) ``` **Parameters:** * `systemName`: Name of the system to remove. ::: warning Systems need an explicit `name` to be removable. Auto-named systems cannot be removed from user code. ::: ## System configuration settings | Field | Type | Required | Description | | -------- | ------------------------------------------------------------- | -------- | ---------------------------------------------------------------------- | | `phase` | `Phase` | **Yes** | The phase the system runs in. | | `run` | `function(dt: number, world: tecs.World)` | **Yes** | The function to call each time the system runs. | | `name` | `string` | No | Name of the system, used for debugging, ordering, and `removeSystem`. | | `runIf` | `function(dt: number, world: tecs.World, name: string): boolean` | No | Predicate that determines if the system should run this frame. | | `before` | `{string}` | No | System names this one should run before (soft; ignored if missing). | | `after` | `{string}` | No | System names this one should run after (soft; ignored if missing). | Systems without an explicit `name` are auto-named on insertion; `removeSystem(name)` only works when a name was declared explicitly. Within a phase, systems run in the order they were added unless `before` / `after` constraints re-sort them. See [Deferred Operations](/tecs/world#deferred-operations) for the rules about when mutations inside a system apply instantly versus stage. ## Naming systems You can give systems a name using the `name` property. This makes it easier to debug, and also allows other systems to be added relative to the system by referencing the name. ```teal{3} world:addSystem({ phase = tecs.phases.Update, name = "MyUpdateSystem", run = function(dt: number, world: tecs.World) -- update logic... end }) ``` ## Conditionally running systems To conditionally skip a system, provide a `runIf` predicate. It receives the frame delta, the world, and the system's name, and returns `true` if the system should run this frame. ```teal runIf = function(dt: number, world: tecs.World, systemName: string): boolean return world:peekState() == "game" end ``` Tecs provides built-in scheduling helpers that cover the common cases. ### Scheduling helpers #### `tecs.runif.after(delay)` Runs a system once after a delay (in seconds), then automatically removes it from the world. ```teal world:addSystem({ phase = tecs.phases.Update, name = "DelayedMessage", runIf = tecs.runif.after(2.0), -- Run after 2 seconds run = function(_dt: number, _world: tecs.World) print("This runs 2 seconds after the world started") end }) ``` #### `tecs.runif.every(interval, jitter?)` Runs a system repeatedly at regular intervals. ```teal world:addSystem({ phase = tecs.phases.Update, name = "PeriodicUpdate", runIf = tecs.runif.every(1.0), -- Run every second run = function(_dt: number, _world: tecs.World) print("One second has passed") end }) ``` Provide optional jitter in the second argument to desynchronize systems using the same interval. Jitter is the ± number of adjusted seconds. #### `tecs.runif.cooldown(duration)` Fires immediately on the first update, then suppresses execution for the cooldown duration before firing again. ```teal world:addSystem({ phase = tecs.phases.Update, name = "HealthRegen", -- Run immediately, then every 5 seconds runIf = tecs.runif.cooldown(5.0), run = function(dt: number, world: tecs.World) -- Regenerate health for all entities end }) ``` #### `tecs.runif.inState(name)` Runs a system only when the given [state](/tecs/states) is on top of the state stack. ```teal world:addSystem({ phase = tecs.phases.Update, name = "GameplaySystem", runIf = tecs.runif.inState("game"), run = function(dt: number, world: tecs.World) -- Only runs when "game" is the current state end }) ``` #### `tecs.runif.negate(predicate)` Inverts another runIf predicate, allowing you to compose conditional logic. ```teal -- Run when NOT in game state world:addSystem({ phase = tecs.phases.Update, name = "PauseMenuSystem", runIf = tecs.runif.negate(tecs.runif.inState("game")), run = function(dt: number, world: tecs.World) -- Only runs when NOT in game state end }) ``` #### `tecs.runif.both(lhs, rhs)` Combines two runIf predicates with logical AND. The system will only run if both predicates return true. ```teal world:addSystem({ phase = tecs.phases.Update, name = "PeriodicGameplayUpdate", runIf = tecs.runif.both( tecs.runif.inState("game"), tecs.runif.every(2.0) ), run = function(dt: number, world: tecs.World) -- Runs every 2 seconds, but only when in "game" state end }) ``` #### `tecs.runif.either(lhs, rhs)` Combines two runIf predicates with logical OR. The system will run if either predicate returns true. ```teal world:addSystem({ phase = tecs.phases.FixedUpdate, name = "AnimateWater", runIf = tecs.runif.either( tecs.runif.inState("game"), tecs.runif.inState("editor") ), run = function(dt: number, world: tecs.World) -- animate water in the game or editor states end }) ``` ### Custom runIf predicates You can also write your own runIf predicates for more complex logic: ```teal local health: number = 100 world:addSystem({ phase = tecs.phases.Update, name = "LowHealthWarning", runIf = function(_dt: number, _world: tecs.World, _systemName: string): boolean return health < 25 end, run = function(_dt: number, _world: tecs.World) print("Warning: Low health!") end }) ``` ## Adding systems before or after other systems Add systems before or after other systems in the same phase. Tecs topologically sorts systems to ensure correct ordering with no cycles. These ordering constraints are **soft**: if a referenced system doesn't exist in the phase, the constraint is silently ignored. This makes plugins composable; a system can declare `after = {"physics.applyTransform"}` without requiring the physics plugin to be present. If the referenced system is added later, the pipeline automatically re-sorts to respect the constraint. Add a system before another named system: ```teal{7} world:addSystem({ phase = tecs.phases.Update, name = "MyOtherUpdateSystem", run = function(dt: number, world: tecs.World) -- update logic... end, before = {"MyUpdateSystem"} }) ``` Add a system after another named system: ```teal{7} world:addSystem({ phase = tecs.phases.Update, name = "YetAnotherUpdateSystem", run = function(dt: number, world: tecs.World) -- update logic... end, after = {"MyUpdateSystem"} }) ``` ## Removing systems Call `world:removeSystem(name)` to pull a system out of the pipeline. The system must have been registered with an explicit `name`; auto-named systems aren't removable from user code. ```teal world:removeSystem("MyUpdateSystem") ``` `tecs.runif.after(delay)` takes advantage of this to clean itself up after firing: the predicate calls `removeSystem` internally once the delay elapses. --- --- url: /tecs/queries.md --- # Queries Use queries to find entities with specific [components](/tecs/components/). Most game logic creates queries inside [plugins](/tecs/plugins), then reuses them from [systems](/tecs/systems). ## World methods These methods are available on every `World`. | Method | Description | | ------ | ----------- | | [`world:query`](#world-query) | Create a persistent or temporary query from a descriptor. | | [`world:findArchetypes`](#world-find-archetypes) | Iterate archetypes that contain one component. | ### world:query {#world-query} Creates a query to find entities with specific components. ```teal function World:query(descriptor: queries.QueryDescriptor): Query ``` **Parameters:** * `descriptor`: Description of the components to query for. **Returns:** * A query object you can iterate to access matching entities. ## Creating queries Create queries with `world:query()`, passing a `QueryDescriptor`. The `include` property is a list of components an entity must have to match the query. ```teal{3} -- Find all entities that have the `tecs.builtins.Name` component: world:query({ include = {tecs.builtins.Name} }) ``` The `exclude` property is a list of components an entity must not have to match the query. ```teal{5} -- Find all entities that have the `tecs.builtins.Name` component and -- don't have the `tecs.builtins.Ttl` component. world:query({ include = {tecs.builtins.Name}, exclude = {tecs.builtins.Ttl} }) ``` The `includeAny` property is a list of components where an entity must have at least one to match the query. This acts as an OR condition combined with the AND condition of `include`. ```teal{4} -- Find all entities that have Position AND either Sprite OR Circle world:query({ include = {Position}, includeAny = {Sprite, Circle} }) ``` You can give queries a name using the `name` property. This makes it easier to identify queries in debug logs. ```teal{2} world:query({ name = "NameQuery", include = {tecs.builtins.Name} }) ``` ### Descriptor reference The full set of fields accepted by `world:query()`: | Field | Type | Description | | ------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `include` | `{Component}` | Archetype must contain **all** of these components. | | `exclude` | `{Component}` | Archetype must contain **none** of these components. | | `includeAny` | `{Component}` | Archetype must contain **at least one** of these components. Combined with `include` as AND (`include`) + OR (`includeAny`). | | `name` | `string` | Human-readable name shown in debug logs and the MCP inspector. Optional. | | `temp` | `boolean` | If `true`, the query is a one-shot snapshot of the currently-matching archetypes. Skips observer registration. Cannot be combined with `onEntitiesAdded` / `onEntitiesRemoved`. See [Temporary queries](#temporary-queries). | | `groupBy` | `function` | Groups matching archetypes by an integer key for sorted iteration. See [Grouping](/tecs/queries/grouping). | | `onEntitiesAdded` | `function` | Fires once per contiguous range of entities when they first match the query. See [Callbacks](/tecs/queries/callbacks#onentitiesadded-callback). | | `onEntitiesRemoved` | `function` | Fires once per contiguous range of entities when they stop matching. See [Callbacks](/tecs/queries/callbacks#onentitiesremoved-callback). | ## Iterating over queries Iterate a query by calling `query:iter()` in a generic `for` loop. Each step returns `(archetype, length, entities)` for the next non-empty archetype (empty archetypes are skipped). Inside the loop, bind component columns once per archetype and index them by row. Consider the following query: ```teal local Name = tecs.builtins.Name local Transform = tecs.builtins.Transform local query = world:query({ include = { Name, Transform } }) ``` Iterate it: ```teal:line-numbers for archetype, len, entities in query:iter() do local names = archetype:get(Name) local transforms = archetype:get(Transform) for row = 1, len do local name = names[row] local transform = transforms[row] love.graphics.print(name.value, transform.x, transform.y) end end ``` * Line `1`: gets each non-empty archetype, the number of entities in the archetype, and an array of entity IDs. * Line `2` and `3`: bind component columns with `archetype:get(Component)`. Teal types `names` as `{Name}`. * Line `4`: iterates over the entities in the archetype. Each value is the entity's "row", which you use to index into component columns. * Line `5` and `6`: grab components for an entity by indexing into the columns. ### Mutating component columns Use `archetype:get(Component)` when you only read a column. Use `archetype:getMut(Component)` when you will mutate values through the returned column. `getMut` returns the same row-indexed column and marks that component dirty on the archetype, so dirty-tracked consumers such as rendering and snapshots can resync. ```teal local movementQuery = world:query({ include = {Position, Velocity} }) for archetype, len in movementQuery:iter() do local positions = archetype:getMut(Position) -- mutated below local velocities = archetype:get(Velocity) -- read-only for row = 1, len do positions[row].x = positions[row].x + velocities[row].x * dt positions[row].y = positions[row].y + velocities[row].y * dt end end ``` `get` does not protect the column from writes; it simply does not mark the component dirty. Treat `getMut` as the write-intent API for direct field changes. Use `world:set` when you need to replace a component value or add a component to an entity. ### Mutations during iteration Structural changes while iterating a query need special handling. `world:set`, `world:remove`, `world:spawn`, `world:despawn`, and the `batch*` APIs can move entities between archetypes or resize columns, which would otherwise invalidate the loop. To make this safe, Tecs defers mutations issued inside a query loop. When `for … in query:iter()` takes its first step, the world enters a **deferred scope**; `world:set`, `world:remove`, `world:spawn`, `world:despawn`, and the `batch*` APIs all stage during iteration and apply in a drain phase when the loop exits. You can therefore stage structural changes inside the loop body: ```teal for archetype, len, entities in query:iter() do local healths = archetype:get(Health) for row = 1, len do if healths[row].current <= 0 then world:despawn(entities[row]) -- staged; applies after the loop end end end ``` #### Breaking out early Normal exhaustion of `for … in query:iter()` drains staged mutations automatically. An early `break` does not; the deferred scope stays open, and your staged writes stay invisible until the next flush point. Call `world:commit()` after the break to drain right away: ```teal for archetype, len, entities in query:iter() do if shouldStop then world:set(entities[1], SomeFlag) -- entities are 1-indexed break end end world:commit() -- drains the leaked scope ``` `world:commit()` is safe to call unconditionally: it's a no-op when no scope was leaked and nothing was dirtied. If you don't need same-frame visibility, you can skip it; the mutations flush at the next `world:update()`. Only the archetype-level query loop holds the deferred scope. Breaking out of an inner `for row = 1, len` row loop has no effect on it. See [Deferred scope](/tecs/queries/callbacks#deferred-scope) on the callbacks page for the full drain and wave model that applies to any deferred region. ## Using queries with systems Create queries outside of systems (usually in plugins), then use them within systems to process entities. ### Creating queries in plugins Create the query once in a plugin and close over it from systems: ```teal -- Create a movement plugin local function movementPlugin(world: tecs.World) -- Create the query once in the plugin local movableQuery = world:query({ name = "MovableEntities", include = {Position, Velocity} }) -- Add a system that uses the query world:addSystem({ name = "MovementSystem", phase = tecs.phases.FixedUpdate, run = function(dt: number) -- Use the query created in the plugin for arch, len, entities in movableQuery:iter() do local positions = arch:getMut(Position) local velocities = arch:get(Velocity) for row = 1, len do local id = entities[row] local pos = positions[row] local vel = velocities[row] pos.x = pos.x + vel.vx * dt pos.y = pos.y + vel.vy * dt end end end }) end -- Add the plugin to the world world:addPlugin(movementPlugin) ``` ### Temporary queries By default, queries are persistent: they register as archetype observers and subscribe to new archetypes. For one-shot iteration where you don't need live updates, use `temp = true` to skip observer registration. Temp queries cannot use `onEntitiesAdded` or `onEntitiesRemoved` callbacks. ```teal for archetype, len in world:query({include = {Health}, temp = true}):iter() do -- iterate once and discard query end ``` ## Disabled entities By default, all queries automatically exclude entities that have the [`tecs.builtins.Disabled`](/tecs/builtins#disabled-component) component. This behavior makes it easy to temporarily hide entities without despawning them. ```teal -- This entity won't appear in queries by default world:spawn(tecs.builtins.Disabled) ``` To find disabled entities in your queries, explicitly include the `Disabled` component: ```teal -- Find all entities with Position, including disabled ones local allPositionQuery = world:query({ include = {Position, tecs.builtins.Disabled} }) ``` ## Ad-hoc archetype lookup Use `world:findArchetypes(component)` for simple one-component scans when you do not need a persistent query object. It uses the world's component-to-archetype index and returns an iterator over matching archetypes. ### world:findArchetypes {#world-find-archetypes} Finds all archetypes that have a specific component. ```teal function World:findArchetypes(component: Component): function(): (Archetype, integer, {integer}) ``` **Parameters:** * `component`: Component to find. **Returns:** * An iterator over matching archetypes. ```teal for archetype, len, entities in world:findArchetypes(tecs.builtins.Name) do local names = archetype:get(tecs.builtins.Name) for row = 1, len do print(entities[row] .. " has name " .. names[row].value) end end ``` --- --- url: /tecs/queries/callbacks.md --- # Query callbacks Query callbacks let you react when entities **match** or **stop matching** a query. `onEntitiesAdded` and `onEntitiesRemoved` provide batch-friendly hooks that enable use cases like registering and deregistering entities from external systems (e.g., physics, GPU, etc). ## onEntitiesAdded callback The `onEntitiesAdded` callback fires once per contiguous range of entities when they *first* match the query. For example, a newly-spawned batch of entities, a single spawn, an entity that gained a required component, or lost an excluded component. This callback *does not* fire when an entity moves between archetypes that both match the query. ```teal{3} world:query({ include = {tecs.builtins.Transform, MyPhysicsComponent}, onEntitiesAdded = function( archetype: tecs.Archetype, firstRow: integer, lastRow: integer, count: integer ) local transforms = archetype:get(tecs.builtins.Transform) local comps = archetype:get(MyPhysicsComponent) for row = firstRow, lastRow do local transform = transforms[row] local comp = comps[row] -- add to your physics world... end end }) ``` The callback receives the archetype and 1-based `firstRow` / `lastRow` (inclusive) bounds plus the `count`. ## onEntitiesRemoved callback `onEntitiesRemoved` fires once per contiguous range of entities that no longer match the query. It's triggered by removing a required component, adding an excluded component, or a despawn. This callback is fired *before* entities are removed from archetypes. ```teal{9} world:query({ include = {tecs.builtins.Transform, MyPhysicsComponent}, onEntitiesRemoved = function( archetype: tecs.Archetype, firstRow: integer, lastRow: integer, count: integer ) local transforms = archetype:get(tecs.builtins.Transform) local comps = archetype:get(MyPhysicsComponent) for row = firstRow, lastRow do local transform = transforms[row] local comp = comps[row] -- remove comps[row] from external system... end end, }) ``` ## Callback signature Callbacks receive a range of rows as 1-based inclusive bounds: `firstRow`, `lastRow`, and a `count`. Count is `1` for single spawns and archetype transitions, `N` for batch spawns (`world:batchSpawn`) and batch despawns. This lets you amortize per-entity work (slot allocation, buffer sizing, dirty marking) across a whole batch in one call. Iterate the range directly with `for row = firstRow, lastRow do`. For auto-attaching companion components (e.g. `Velocity` should imply `Position`), use [`requires`](/tecs/components/#auto-dependencies-with-requires) on the component declaration rather than query callbacks. ## Reacting to a single component To run code when a specific component appears on or leaves an entity, build a query whose `include` contains just that component. `onEntitiesAdded` fires when the entity first gains the component (via spawn, `world:set`, or an archetype transition that pulls it in); `onEntitiesRemoved` fires when the entity loses it (via `world:remove`, despawn, or an exclusion flipping). ```teal -- React when Health appears or goes away, regardless of whatever -- other components the entity carries. world:query({ include = {Health}, onEntitiesAdded = function( arch: tecs.Archetype, firstRow: integer, lastRow: integer, count: integer ) local healths = arch:get(Health) local entities = arch.entities for row = firstRow, lastRow do print("gained Health:", entities[row], healths[row].current) end end, onEntitiesRemoved = function( arch: tecs.Archetype, firstRow: integer, lastRow: integer, count: integer ) local entities = arch.entities for row = firstRow, lastRow do print("lost Health:", entities[row]) end end, }) ``` Combine `include` with `exclude` to react to more specific transitions. For example, to fire exactly when an entity enters a "stunned" state and again when it leaves: ```teal world:query({ include = {Enemy, Stunned}, onEntitiesAdded = function( arch: tecs.Archetype, firstRow: integer, lastRow: integer, count: integer ) -- Entity acquired Stunned (and was already Enemy). end, onEntitiesRemoved = function( arch: tecs.Archetype, firstRow: integer, lastRow: integer, count: integer ) -- Entity lost Stunned or Enemy (or was despawned). end, }) ``` ## Deferred scope Query callbacks run inside the world's **drain**, the phase that commits staged mutations after a system or batch finishes. The drain proceeds in **waves**: a wave snapshots the currently-dirty archetype list, applies despawns, spawns, and moves across those archetypes, and fires matching callbacks along the way. If a callback stages more mutations, the newly-dirtied archetypes form the next wave. The drain loops until no archetypes are dirty. Inside a callback: * `world:set`, `world:remove`, `world:spawn`, `world:despawn`, and the `batch*` APIs all stage; they apply in the next wave, not immediately. * `world:get` and `world:has` still see the **pre-callback** archetype state. Your staged writes are invisible until the drain finishes. * Reading the passed `archetype`'s columns is safe and reflects the current state of the entities in the range. Don't add or remove components on entities you're iterating within the callback; if you must, read all the data you need out of the archetype first, then stage the mutations. Finite cascades settle automatically. Unbounded cascades (hook A adds a component that fires hook B which re-adds what hook A removed, etc.) trip a wave-count safety cap and raise an error. --- --- url: /tecs/queries/grouping.md --- # Query grouping Use the `groupBy` option to group matching archetypes by an integer key. Archetypes with the same group are iterated contiguously, so systems can switch state once per group and process a whole batch before moving to the next one. For example, a renderer can draw all alpha-blended sprites, then all additive sprites, without sorting entities every frame. ## Basic usage The `groupBy` function receives an archetype and returns an integer. Tecs stores that key with the archetype as it matches the query, so iteration can keep same-key archetypes together. ```teal local BlendMode = { Alpha = 1, Additive = 2, Multiply = 3 } local blendQuery = world:query({ include = {Sprite, Transform}, includeAny = {AlphaBlend, AdditiveBlend, MultiplyBlend}, groupBy = function(archetype: tecs.Archetype): integer if archetype:get(AlphaBlend) then return BlendMode.Alpha end if archetype:get(AdditiveBlend) then return BlendMode.Additive end if archetype:get(MultiplyBlend) then return BlendMode.Multiply end return 0 end, }) ``` ## Iterating by group Use `groups()` to iterate active group IDs in sorted order, and `group(id)` to iterate archetypes within a specific group. This pattern keeps per-group setup outside the inner entity loop: ```teal for blendMode in blendQuery:groups() do love.graphics.setBlendMode(blendModeToLove[blendMode]) for archetype, len, entities in blendQuery:group(blendMode) do -- Draw all sprites with this blend mode for row = 1, len do -- ... end end end ``` ## Getting an archetype's group Use `getGroup(archetype)` to retrieve the cached group ID for an archetype: ```teal for archetype, len, entities in blendQuery:iter() do local blendMode = blendQuery:getGroup(archetype) -- blendMode is the integer returned by groupBy for this archetype end ``` ## Getting group entity counts Use `getGroupCount(groupId)` to get the total number of entities in a group without iterating. This is useful for pre-allocating buffers or computing memory layouts: ```teal -- Pre-calculate buffer offsets for each group local offsets = {} local currentOffset = 0 for groupId in query:groups() do offsets[groupId] = currentOffset currentOffset = currentOffset + query:getGroupCount(groupId) end -- Now stream data using pre-calculated offsets for groupId in query:groups() do local baseOffset = offsets[groupId] for archetype, len in query:group(groupId) do -- Write to buffer at baseOffset baseOffset = baseOffset + len end end ``` This two-pass pattern calculates offsets per group, then streams each group's contiguous run of entities in order. Note that `groups()`, `group()`, `getGroup()`, and `getGroupCount()` return `nil`, empty iterators, or 0 when `groupBy` is not specified on the query. --- --- url: /tecs/components.md --- # Components A component is a plain data object attached to an entity. Components describe traits like position, velocity, or health, and are the building blocks of game state. ## Component types Tecs provides several component kinds for different use cases. * [Table component](/tecs/components/table-components): backed by a Lua table. Use this when the data can't fit a fixed C struct: strings, nested tables, Love2D handles, or any value needing Lua reference semantics. * [Tag component](/tecs/components/tag-components): carries no data; presence is the whole signal. * [FFI component](/tecs/components/ffi): backed by an FFI struct. Use this for numeric and primitive data that maps cleanly to fixed-size C fields. * [Scalar component](/tecs/components/scalar-components): a single string, number, or boolean value. Use this when a component is really just one value (e.g., `Health`). ## World methods These methods are available on every `World`. | Method | Description | | ------ | ----------- | | [`world:get`](#world-get) | Return one component from an entity. | | [`world:getMut`](#world-get-mut) | Return a component for in-place mutation and mark its column dirty. | | [`world:getFirstRelationship`](#world-get-first-relationship) | Return the first relationship instance for a relationship container. | | [`world:has`](#world-has) | Check whether an entity has a component or relationship target. | | [`world:set`](#world-set) | Attach or replace a component on an entity. | | [`world:remove`](#world-remove) | Remove a component from an entity. | | [`world:markComponentDirty`](#world-mark-component-dirty) | Mark a component column dirty for one entity's archetype. | ### world:get {#world-get} Retrieves a component from an entity. ```teal function World:get(entity: integer, component: T): T ``` **Parameters:** * `entity`: Entity ID. * `component`: Component type or relationship instance to retrieve. **Returns:** * The component instance, or `nil` if not found. ### world:getMut {#world-get-mut} Mutable counterpart to `world:get`. It returns the component and marks that component dirty on the entity's archetype. Use this whenever you intend to mutate the returned reference in place. ```teal function World:getMut(entity: integer, component: T): T ``` **Parameters:** * `entity`: Entity ID. * `component`: Component type or relationship instance to get and mark dirty. **Returns:** * The component instance, or `nil` if not found. See [Dirty tracking](/tecs/components/dirty-tracking) for when dirty marks are needed. ### world:getFirstRelationship {#world-get-first-relationship} Returns the first relationship instance for a relationship container on an entity. For exclusive relationships, this is the single instance. ```teal function World:getFirstRelationship(entity: integer, relationship: T): T ``` **Parameters:** * `entity`: Entity ID. * `relationship`: Relationship container type. **Returns:** * The relationship instance, or `nil` if not found. ### world:has {#world-has} Checks whether an entity currently has a component. ```teal function World:has(entity: integer, component: Component): boolean ``` For sparse relationships, passing the relationship container checks whether the entity has any target for that relationship; passing a relationship instance checks for that specific target. ```teal world:has(entity, Health) world:has(entity, ChildOf) world:has(entity, ChildOf(specificParent)) ``` ### world:set {#world-set} Attaches or replaces a component on an entity. ```teal function World:set(entity: integer, component: Component, value?: any) ``` **Parameters:** * `entity`: Entity ID. * `component`: Component instance to attach, or a component type when using the optional scalar value form. * `value`: Optional raw value for scalar component writes. This is a [deferred operation](/tecs/world#deferred-operations) inside query iteration, callbacks, explicit defer scopes, and batch callbacks. ### world:remove {#world-remove} Removes a component from an entity. ```teal function World:remove(entity: integer, component: Component) ``` **Parameters:** * `entity`: Entity ID. * `component`: Component type or relationship instance to remove. This is a [deferred operation](/tecs/world#deferred-operations) inside query iteration, callbacks, explicit defer scopes, and batch callbacks. ### world:markComponentDirty {#world-mark-component-dirty} Marks a component dirty on the entity's archetype. Prefer `world:getMut(entity, component)` when you are fetching and then mutating the component; use this when you already have a reference from another path. ```teal function World:markComponentDirty(entity: integer, component: Component) ``` **Parameters:** * `entity`: Entity ID. * `component`: Component type whose column was mutated. See [Dirty tracking](/tecs/components/dirty-tracking) for the full dirty-bit model. ## Getting components Access an entity's components with `world:get`. ```teal local name = world:get(entityId, tecs.builtins.Name) ``` ::: details Component access is typed Tecs is built from the ground-up to be strongly typed with [Teal](https://teal-language.org); the `get` method is generic over the provided component type. So in the above example, the return value of `get` is an instance of `tecs.builtins.Name` or `nil` if not found. ::: ## Setting components Set components on entities with `world:set`. ```teal world:set(entityId, tecs.builtins.Name("Frank")) ``` You can also set components when spawning an entity. ```teal world:spawn( tecs.builtins.Name("Frank"), tecs.builtins.Position(100, 200) ) ``` ## Removing components Remove components from entities with `world:remove`. ```teal world:remove(entityId, tecs.builtins.Name) ``` ## Getting components from archetypes When iterating entities in a system, the query gives you the archetype directly. You can bind the component's column once and then index by row, avoiding per-entity lookups: ```teal local query: tecs.Query = world:query({include = {Position, Velocity}}) world:addSystem({ name = "Movement", phase = tecs.phases.Update, run = function(dt: number, _world: tecs.World) for archetype, length in query:iter() do local positions = archetype:getMut(Position) -- bind column, mark dirty local velocities = archetype:get(Velocity) -- read-only column for row = 1, length do positions[row].x = positions[row].x + velocities[row].x * dt positions[row].y = positions[row].y + velocities[row].y * dt end end end }) ``` This is significantly faster than calling `world:get` per entity because the archetype and column are already known: each access is just an array index. ## Auto-dependencies with `requires` Declare components that must accompany another component using `requires`. When a component with `requires` is added to an entity (via `world:set`, `world:spawn`, or any other path), every listed component that is not already present is added in the same archetype transition, so the entity skips intermediate archetypes. Entries may be either component **types** (the container is called with no args to produce a default instance) or instance values (shared by every entity that auto-adds the dependency). The closure is transitive: if a required component itself declares `requires`, those are pulled in too. ```teal local record Position is tecs.Component x: number y: number metamethod __call: function(self, x?: number, y?: number): Position end local record Velocity is tecs.Component vx: number vy: number metamethod __call: function(self, vx?: number, vy?: number): Velocity end tecs.newComponent({ name = "Position", container = Position, fields = {"x", "y"}, defaults = {0, 0}, }) tecs.newComponent({ name = "Velocity", container = Velocity, fields = {"vx", "vy"}, defaults = {0, 0}, requires = {Position}, -- Velocity implies Position }) -- Spawning Velocity auto-adds a default Position in the same transition. local entity: integer = world:spawn(Velocity(10, 20)) assert(world:get(entity, Position) ~= nil) ``` For lifecycle reactions ("run code when an entity gains or loses this component"), use [query callbacks](/tecs/queries/callbacks): `onEntitiesAdded` and `onEntitiesRemoved` on `world:query(...)`. ## Transient components Set `transient = true` on any component or relationship whose value is runtime projection state rather than durable world state (e.g., renderer caches, GPU/bucket routing tags, per-frame scratch). [Snapshot saves](/tecs/save-games) skips transient component columns while keeping the entity itself. ```teal tecs.newFFIComponent({ name = "SpriteData", container = SpriteData, transient = true, fields = { {"width", "float"}, {"height", "float"}, } }) ``` `transient = true` is mutually exclusive with `serialize`. The option is accepted by `newComponent`, `newFFIComponent`, `newTagComponent`, `newScalarComponent`, `newRelationship`, and `newFFIRelationship`. See [Serialization](/tecs/components/serialization) for how components round-trip through save games, networking, and the MCP server. --- --- url: /tecs/components/construction.md --- # Component Construction All component kinds in Tecs share the same high-level construction model: * Positional `__call(...)` is the hot path. * `.new(data)` is the named/table form. * `fields` define positional order. * `defaults` fill omitted positional values in field order. * `init(instance, ...)` runs after the base instance has been allocated and populated. * optional config `__call(instance, ...)` replaces the default positional field-mapping path. The storage backend changes how the instance is stored; it does not change the basic construction rules. This page documents the shared component model. For backend and category-specific details, see [Table Components](/tecs/components/table-components), [FFI Components](/tecs/components/ffi), [Scalar Components](/tecs/components/scalar-components), [Tag Components](/tecs/components/tag-components), [Relationships](/tecs/relationships/), and [FFI Relationships](/tecs/relationships/ffi). ## Positional construction Call the component container directly to construct an instance: ```teal local record Health is tecs.Component current: integer maximum: integer metamethod __call: function(self, current?: integer, maximum?: integer): Health end tecs.newComponent({ name = "Health", container = Health, fields = {"current", "maximum"}, defaults = {100, 100}, }) local a: Health = Health() local b: Health = Health(80, 120) ``` When `fields` are present, positional arguments map to those fields in order. For the concrete table-backed and FFI-backed forms of this pattern, see [Table Components](/tecs/components/table-components) and [FFI Components](/tecs/components/ffi). ## Table construction Every component also exposes `.new(data)`: ```teal local h: Health = Health.new({ current = 80, maximum = 120, }) ``` When `fields` are present, Tecs generates `.new(data)` automatically by unpacking field names in order and routing through `__call`. Use an explicit `new = function(data) ... end` only when the table form should not map directly to the positional form. This named construction path matters most for [table components](/tecs/components/table-components), [FFI components](/tecs/components/ffi), and default [component serialization](/tecs/components/serialization). ## `fields` `fields` define the positional argument order and the generated base shape. Table components use: ```teal fields = {"x", "y", "z"} ``` FFI components use: ```teal fields = { {"x", "float"}, {"y", "float"}, {"z", "float"}, } ``` Relationships follow the same rule, except the relationship target is always the first positional argument and is not included in the public `fields` list. For relationship-specific construction and target semantics, see [Relationships](/tecs/relationships/) and [FFI Relationships](/tecs/relationships/ffi). ## `defaults` `defaults` are positional and line up with `fields`. ```teal defaults = {0, 0, 1} ``` That means: * field 1 defaults to `0` * field 2 defaults to `0` * field 3 defaults to `1` Use `nil` for "no default". ```teal defaults = {nil, nil, 1} ``` For FFI-backed components and relationships, omitted fields that still have no default remain zero-initialized by the allocator. For examples of defaults on plain Lua payloads versus FFI-backed payloads, see [Table Components](/tecs/components/table-components), [FFI Components](/tecs/components/ffi), and [Relationships](/tecs/relationships/) plus [FFI Relationships](/tecs/relationships/ffi). ## `init(instance, ...)` `init` is a post-allocation hook. The split is: * generated/base construction owns allocation, field mapping, and defaults * `init` owns validation, normalization, and derived state Example: ```teal tecs.newFFIComponent({ name = "Transform", container = Transform, fields = { {"x", "float"}, {"y", "float"}, {"layer", "int32_t"}, }, defaults = {0, 0, 1}, init = function(instance: Transform) if instance.layer < 1 then error("Transform layer must be greater than 0") end end, }) ``` By the time `init` runs, `instance.layer` already reflects the positional args plus any defaults. If you provide `init`, you must also provide `fields` or `new`. Otherwise Tecs would have no clear way to implement `.new(data)`. `init` is most relevant for structured payload components and relationships. For concrete usage patterns, compare [Table Components](/tecs/components/table-components), [FFI Components](/tecs/components/ffi), [Relationships](/tecs/relationships/), and [FFI Relationships](/tecs/relationships/ffi). Scalar and tag components have narrower creation APIs; see [Scalar Components](/tecs/components/scalar-components) and [Tag Components](/tecs/components/tag-components). ## Custom constructor `__call(instance, ...)` Supply config `__call` when the public constructor arguments are **not** the same as the stored fields. On this path, Tecs: * allocates the base instance * applies declarative `defaults` * calls your custom `__call(instance, ...)` * does **not** auto-run `init` That last point is intentional: if you want to share logic, call `Component.init(instance, ...)` explicitly from the custom `__call`. ```teal tecs.newFFIComponent({ name = "Text", container = Text, fields = { {"slabOffset", "uint32_t"}, {"charCount", "uint16_t"}, {"fontId", "uint16_t"}, }, __call = function(instance: Text, fontPath: string, value: string) Text.init(instance, fontPath, value) end, init = function(instance: Text, fontPath: string, value: string) -- custom semantic construction here end, }) ``` Use this only when the default positional field mapping is the wrong model. If your constructor arguments already line up with `fields`, prefer the normal generated path and `init`. --- --- url: /tecs/components/table-components.md --- # Table Components A **table component** is a component whose instances are plain Lua tables. Each instance is a distinct table with the fields you declared, stored in an archetype column alongside the other components on its entity. Use table components when your data doesn't fit a fixed C struct layout: Love2D handles (textures, fonts, sounds), variable-length strings, nested Lua tables, or any value that needs Lua reference semantics. For data that *does* fit a C struct, reach for [FFI components](/tecs/components/ffi) instead; they share the same call-site API but back the instance with a C struct in contiguous memory. Shared constructor rules live in [Component Construction](/tecs/components/construction). This page focuses on table-specific behavior. ## Creating a table component Pass a configuration table to `tecs.newComponent` to wire up the metatables and register the component with the world. | Property | Description | | --------------| -----------------------------------------------------------------------------------------------------| | `name` | (**required**) The component name | | `container` | (**required**) The component type/record | | `fields` | Ordered field names. Codegens the positional base shape and the table-form `.new`. | | `defaults` | Default values for `fields`, in matching order (`nil` = no default). Requires `fields`. | | `requires` | Array of components to auto-add alongside this one. See [Auto-dependencies](/tecs/components/#auto-dependencies-with-requires). | | `init` | Custom positional init hook. Runs after allocation. Must be paired with `fields` or `new`; otherwise registration errors (so `.new` is never ambiguous). | | `__call` | Custom constructor hook. Receives an allocated instance plus the call args. `defaults` are already applied. `init` is not auto-run on this path. | | `new` | Custom table-form constructor (`function(data: {string: any}): Component`), called via `Component.new({...})`. Defaults to codegen from `fields` when present. | | `serialize` | Custom function to convert the component to a serializable table | | `deserialize` | Custom function to reconstruct the component from serialized data (receives `world` and data table) | | `transient` | If `true`, omit this component from snapshots. Mutually exclusive with `serialize`. | ## Using `fields` and `defaults` The recommended path for table components is to declare field names and let Tecs codegen both the positional `__call` *and* the `.new` table form. Optional `defaults` fill in static defaults for any field the caller omits; use `nil` for fields that have no default. ```teal local record Health is tecs.Component value: number max: number metamethod __call: function(self, value?: number, max?: number): Health end tecs.newComponent({ name = "Health", container = Health, fields = {"value", "max"}, defaults = {100, 100}, -- Health() -> {value = 100, max = 100} }) -- Both forms work local a: Health = Health(80, 100) local b: Health = Health.new({ value = 80, max = 100 }) ``` ::: details Typing `new` config `Health.new` in the above example is inherited from `tecs.Component`'s base signature `function(data: {string: any}): self`. Override it with a nested config record when you want field-by-field type checking on callers: ```teal local record Health is tecs.Component value: number max: number record HealthConfig value: number max: number end metamethod __call: function(self, value?: number, max?: number): Health new: function(config: HealthConfig): Health end local h: Health = Health.new({ value = 80, max = 100 }) -- checked against HealthConfig ``` The runtime behavior is unchanged. Tecs still codegens `.new` from `fields`. The override only tightens what the type checker accepts at call sites. ::: :::details Teal metamethods Teal records and interfaces define Lua metatable methods using `metamethod`. The `__call` metamethod lets you invoke the record like a function, as in `Position(10, 20)`, while the `new` method is a regular static field accessed as `Position.new({x = 10})`. ::: ## Using `init` Supply `init` when the positional form needs custom logic `fields` / `defaults` can't express. Table components are the natural home for this because they routinely wrap non-POD values: a Love2D handle, a validated range, or a field derived from another. Because the framework wouldn't know how to unpack a config table into your custom init hook's positional args, `init` must be paired with either `fields` (Tecs codegens `.new` from the field list) or an explicit `new`. Registering an `init` without one of those errors immediately: the broken-`.new` footgun is closed by design. The common case: `fields` alongside an `init` hook that adds validation or derived fields. `fields` defines the base shape and `.new` unpacking; your `init` refines the allocated instance. ```teal local record Sprite is tecs.Component texture: love.graphics.Texture metamethod __call: function(self, texture: love.graphics.Texture): Sprite end tecs.newComponent({ name = "Sprite", container = Sprite, fields = {"texture"}, init = function(instance: Sprite, texture: love.graphics.Texture) assert(texture, "Sprite requires a texture") instance.texture = texture end, }) local a: Sprite = Sprite(img) -- positional, runs init local b: Sprite = Sprite.new({ texture = img }) -- table form, unpacks then runs init ``` Reach for an explicit `new` (next section) when the table shape doesn't map cleanly to positional args, for example a config with many optional fields where positional calls would be unergonomic. ## Custom `__call` Use config `__call` when the call-site arguments are semantic inputs rather than a direct field list. Tecs allocates the table instance, applies `defaults`, then invokes your hook as `__call(instance, ...)`. It does not auto-run `init` after that; call `Component.init(...)` yourself if you want to share logic. ## Overriding `.new` Supply `new` when you want callers to have an ergonomic `Component.new({...})` form with named fields and defaults. You'll typically pair it with a custom `init` so both shapes share the same defaults and validation. ```teal local record Light is tecs.Component radius: number intensity: number cookie: integer record LightConfig radius: number intensity: number cookie: integer end metamethod __call: function(self, radius?: number, intensity?: number): Light new: function(config: LightConfig): Light end tecs.newComponent({ name = "Light", container = Light, fields = {"radius", "intensity"}, defaults = {200, 1.0}, init = function(instance: Light) instance.cookie = 0 end, new = function(c: {string: any}): Light local cfg = c as LightConfig return { radius = cfg.radius or 200, intensity = cfg.intensity or 1.0, cookie = cfg.cookie or 0, } end, }) local a: Light = Light(200, 1.5) -- positional local b: Light = Light.new({ cookie = 3 }) -- table form, named fields ``` ## Where are component hooks? ::: details You're looking for query callbacks... If you're coming from a [Flecs](https://www.flecs.dev/flecs/) background, you might wonder why Tecs doesn't offer component hooks that fire when a component is added, removed, or replaced on an entity. [Query callbacks](/tecs/queries/callbacks) cover the same use cases and fit the mutation model better. 1. **Query callbacks batch; component hooks can't.** A component hook fires once per entity, even in bulk paths like `world:batchSpawn`. Query callbacks fire once per contiguous row range, and the common bulk work (allocating GPU slots, sizing external buffers, registering with a physics world) amortizes cleanly across the batch. 2. **Query callbacks match on signatures, not single components.** "Fire when an entity has both `Sprite` and `Transform`" is one query with `include = {Sprite, Transform}`. With component hooks you'd need to register on both components and manually coordinate a flag to reach the same behavior. 3. **`onReplace` style hooks are incompatible with the mutation model.** Tecs components are mutable in place (both table and FFI). The hot write pattern is direct column access: ```teal positions[row].x = positions[row].x + velocities[row].vx * dt ``` That's one or two cycles per field in a tight loop over SoA columns. Hooking value changes would either force every write through a setter (defeating the purpose of exposing the column), or insert a branch on every column assignment. Tecs doesn't track interior mutability for that reason. 4. **Dirty tracking tells you when things change.** When you do need "tell me when `Health` changed," use [dirty tracking](/tecs/components/dirty-tracking). It's the batching answer to `onReplace`: a write through `archetype:getMut(Health)` flips a per-archetype, per-component bit (idempotent, so N writes collapse to one mark) and a sync system drains the set once per frame. What an `onReplace` hook would spread across N handler calls becomes one pass over dirty columns, at the consumer's own cadence. 5. **One abstraction, not two.** Query callbacks already exist, are run in a deferred state, already handle the matching and scheduling story, and already thread into the world's commit process. Adding component hooks would duplicate the observer spine with a second, weaker mechanism. ::: --- --- url: /tecs/components/tag-components.md --- # Tag Components A **tag component** is a component with no data. Its presence on an entity is the entire signal. Tecs stores tags in a per-archetype bitset rather than allocating a value per entity, so membership costs one bit. If you need a refresher on the shared component model first, start with [Component Construction](/tecs/components/construction). For the broader component taxonomy, see the [Components overview](/tecs/components/). If you want the same presence-only idea but scoped to a relationship target, see [Relationships](/tecs/relationships/) (a `newRelationship` with just a name is the presence-only, target-only form) and [FFI Relationships](/tecs/relationships/ffi). Use tags for flags, markers, and classification: "this entity is `Selected`", "this mob is `Stunned`", "this node is a `SpawnPoint`". Anything that reduces to "is this entity part of group X?" is a good fit. ## Creating a tag component Create a tag with `tecs.newTagComponent`: ```teal local Selected = tecs.newTagComponent({name = "Selected"}) local Stunned = tecs.newTagComponent({name = "Stunned"}) ``` `tecs.newTagComponent` accepts the following properties: | Property | Description | | ---------- | --------------------------------------------------------------------------------------------- | | `name` | (**required**) The component name. | | `requires` | Array of components to auto-add alongside this tag. See [Auto-dependencies](/tecs/components/#auto-dependencies-with-requires). | | `container`| Optional pre-declared container to register as the tag. Rarely needed. | | `transient`| If `true`, omit this tag from snapshots. | ## Adding, removing, and testing tags Tags use the standard component API: ```teal world:set(entityId, Selected) -- add world:remove(entityId, Disabled) -- remove world:has(entityId, Selected) -- presence check (boolean) ``` You can also spawn an entity directly with tags: ```teal world:spawn(Position(0, 0), Enemy, Hostile) ``` ## Using tags in queries Tags slot into query descriptors like any other component: ```teal -- All selected enemies: world:query({include = {Enemy, Selected}}) -- All enemies that aren't stunned: world:query({include = {Enemy}, exclude = {Stunned}}) ``` Because tags have no column, there's nothing useful to bind inside the archetype loop. Filter on presence via `include` / `exclude` in the query descriptor, then iterate the components that do carry data: ```teal for archetype, len, entities in query:iter() do local positions = archetype:get(Position) -- archetype:get(Selected) returns nil: tag has no data column for row = 1, len do -- Every row is Selected by construction (query's include list guaranteed it). end end ``` ## Performance Tag components are highly compact and fast to query. For workloads like "mark every visible entity this frame" across tens of thousands of entities, the difference vs a zero-field [table component](/tecs/components/table-components) matters. If you need a single primitive value instead of pure presence, compare them with [scalar components](/tecs/components/scalar-components) before reaching for a table or FFI payload component. Two performance properties worth knowing: * **Presence checks are free**. A query matching on a tag is a bitmask test against the archetype's component set, not a column walk. * **Add/remove triggers an archetype transition**, just like any component. Adding `Selected` to a million entities shuffles them all into new archetypes. For bulk paths use `world:batchSet` / `world:batchRemove` against a query rather than a per-entity loop: ```teal local enemiesInBlast = world:query({ include = {Enemy, InBlastRadius}, temp = true }) world:batchSet(enemiesInBlast, Stunned) -- tag all at once -- ...later... world:batchRemove(stunnedQuery, Stunned) -- untag all at once ``` ## Built-in tags Tecs ships a few tag components you'll interact with directly: * [`tecs.builtins.Disabled`](/tecs/builtins#disabled-component): auto-excluded from queries unless the query explicitly includes it. See [Disabled entities](/tecs/queries/#disabled-entities). * [`tecs.builtins.Paused`](/tecs/builtins#paused-component): not auto-excluded; used by the state stack's `"pause"` policy to freeze gameplay entities while a paused state is on top. The [state stack](/tecs/states) also creates tag components at runtime. `world:createState("game")` returns a tag component that the stack auto-adds to entities spawned while the state is on top. --- --- url: /tecs/components/scalar-components.md --- # Scalar Components Scalar components store a single primitive value per entity (`number`, `boolean`, or `string`) in a direct column. Use them when you want tight SoA iteration for simple values without per-row table allocation. If you need the shared constructor model first, see [Component Construction](/tecs/components/construction). For the bigger picture of when scalar components fit, start from the [Components overview](/tecs/components/) and compare them with [table components](/tecs/components/table-components), [FFI components](/tecs/components/ffi), and [tag components](/tecs/components/tag-components). ## Creating a scalar component ```teal local tecs = require("tecs") local Health = tecs.newScalarComponent({ name = "Health", kind = "number", default = 100, }) ``` ### Options | Option | Type | Required | Description | | ----------- | ----------------------------- | --------- | ----------- | | `name` | `string` | yes | Component name. | | `kind` | `"number" \| "boolean" \| "string"` | yes | Lua type of the value the column holds. | | `default` | `T` | no | Value used when the component is added without a value. If omitted, defaults to the zero value for the kind: `0`, `false`, or `""`. | | `requires` | `{Component}` | no | Declarative auto-dependencies. When this component is added to an entity, any listed components not already present are added in the same archetype transition. Entries may be component TYPES (constructed with no args) or INSTANCES. Transitive. | | `transient` | `boolean` | no | If `true`, omit this scalar component from snapshots. | ### Typed exports To export a scalar from a module with a precise Teal type, declare the field type on the module record and assign the registration result to it: ```teal local tecs = require("tecs") local record mymodule Health: tecs.ScalarComponent end mymodule.Health = tecs.newScalarComponent({ name = "Health", kind = "number", default = 100, }) return mymodule ``` ## Set and get scalar values The 3-arg `world:set(entity, componentType, value)` form is the fast path for scalar writes: ```teal local id = world:spawn() world:set(id, Health, 75) local hp = world:get(id, Health) -- 75 ``` You can also set a scalar without passing a value to write its default: ```teal world:set(id, Health) local hp = world:get(id, Health) -- 100 ``` `world:get` always returns the raw scalar value, never a wrapper: ```teal local hp = world:get(id, Health) print(type(hp)) -- "number" print(hp == 75) -- true ``` ## Spawn with scalar components `Health(75)` produces a scalar instance you can pass directly to `world:spawn` alongside other components. The instance is unwrapped at the spawn boundary, so the column still stores the raw value. ```teal local id = world:spawn( Transform(0, 0), Health(75), Mana(10) ) ``` The 2-arg `world:set(entity, instance)` form works the same way: ```teal world:set(id, Health(50)) ``` ::: warning Scalar instances are wrappers, not raw values `Health(75)` returns a small wrapper carrying both the component type and the value, so the spawn dispatcher can route it. That means `Health(75) == 75` is **false**. Treat scalar instances as opaque tokens for `world:spawn` / `world:set`; for everything else, read the raw value back via `world:get`. For string-kind scalars, repeated calls with the same value reuse a cached wrapper, so `Name("Frank")` does not allocate after the first call with `"Frank"`. ::: ## Query scalar columns Scalar columns are regular archetype columns, so query iteration is the same pattern as other components. ```teal local query = world:query({ include = {Health} }) for archetype, len in query:iter() do local health = archetype:getMut(Health) for row = 1, len do health[row] = health[row] - 1 end end ``` ## Serialization Scalar components serialize as an object with a single `value` field: ```json { "value": 75 } ``` On deserialize, missing `value` falls back to the scalar default. ## When to use scalar vs table/FFI * scalar components: use when the component is exactly one primitive value and you want simple, fast SoA column access. * table components: use when you need structured Lua fields and flexible shape. See [Table Components](/tecs/components/table-components). * FFI components: use when you need packed structs and FFI interop. See [FFI Components](/tecs/components/ffi). * tag components: use when presence alone is the signal and there is no payload at all. See [Tag Components](/tecs/components/tag-components). ::: tip Don't overdo scalar components Scalar components are usually faster than table or FFI components for their narrow use case. But don't over-apply them and lose abstraction. For example, in most code, a single `Position` component with `x` and `y` is preferable to splitting into separate `PositionX` and `PositionY` components. ::: --- --- url: /tecs/components/ffi.md --- # FFI Components FFI (Foreign Function Interface) components leverage LuaJIT's FFI capabilities to provide high-performance, cache-friendly component storage using C structs instead of Lua tables. Use FFI components if your data is mostly made up of numbers, booleans, and other primitive types that map cleanly to C structs. Otherwise, use normal table-based components. Shared constructor rules live in [Component Construction](/tecs/components/construction). This page focuses on FFI-specific storage behavior and field types. ## Basic usage Define FFI components using `tecs.newFFIComponent`: ```teal local tecs = require("tecs") local record Velocity is tecs.Component x: number y: number end tecs.newFFIComponent({ name = "Velocity", container = Velocity, fields = { {"x", "float"}, {"y", "float"} } }) ``` ## Field types FFI components support all standard C types: ### Numeric types * **Integers**: `int8_t`, `int16_t`, `int32_t`, `int64_t`, `uint8_t`, `uint16_t`, `uint32_t`, `uint64_t` * **Floating Point**: `float`, `double`, `long double` * **Standard C**: `char`, `short`, `int`, `long`, `size_t`, `ptrdiff_t` * **Boolean**: `bool`, `_Bool` ### Pointer types * **Generic**: `void*` * **String**: `char*`, `const char*` * **Numeric**: `int*`, `float*`, `double*` ### Fixed-size arrays You can define fixed-size arrays by appending `[size]` to any type: * **Numeric Arrays**: `float[16]`, `int32_t[4]`, `uint8_t[256]` * **Matrix/Vector**: `float[3]` for vec3, `float[16]` for mat4 * **Buffers**: `char[256]` for string buffers ## Constructor support FFI components use the same `__call(...)` / `.new(data)` / `fields` / `defaults` / `init` model as table components. The FFI-specific difference is that the base instance is an FFI struct instead of a Lua table. ```teal -- Define component local record Position is tecs.Component x: number y: number z: number metamethod __call: function(self, x?: number, y?: number, z?: number): Position end tecs.newFFIComponent({ name = "Position", container = Position, fields = { {"x", "float"}, {"y", "float"}, {"z", "float"} } }) -- Use with positional arguments local pos1: Position = Position(10, 20) -- z defaults to 0 local pos2: Position = Position(10, 20, 30) -- all values provided ``` ### Default values FFI components support explicit `defaults`, just like table components: ```teal tecs.newFFIComponent({ name = "Position", container = Position, fields = { {"x", "float"}, {"y", "float"}, {"z", "float"} }, defaults = {0, 0, 0}, }) ``` Fields with no explicit default remain zero-initialized by the FFI allocator. That means omitted values fall back to the underlying FFI zero value: * Numeric types: `0` or `0.0` * Pointers: `nil` * Booleans: `false` * See https://luajit.org/ext\_ffi\_semantics.html#init\_table ## API compatibility FFI components share the same API as table-based components. Field access, construction, and mutation work identically regardless of the underlying storage: ```teal local vel: Velocity = Velocity(10, 20) vel.x = vel.x + 5 print(vel.x, vel.y) -- 15, 20 ``` ## Limitations For more details on LuaJIT FFI limitations and semantics, see the [official LuaJIT FFI documentation](https://luajit.org/ext_ffi.html) and [FFI semantics](https://luajit.org/ext_ffi_semantics.html). ## API reference ### tecs.newFFIComponent(options) Creates an FFI-based component with optimized memory layout and optional recycling. **Parameters**: * `options` The `options` table supports the following properties: | Parameter | Type | Description | Required | | ------------------ | --------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------- | | `name` | `string` | Component name | Yes | | `container` | `Component` | Component container/type | Yes | | `fields` | `{ {string, string} }` | Array of field tuples `{name, type}` | Yes | | `metatable` | `table` | Metatable to apply to FFI instances for adding instance methods | No | | `defaults` | `{any}` | Default positional values, in the same order as `fields` | No | | `init` | `function` | Validation and initialization hook (positional args only; `.new` routes through it after unpacking) | No | | `__call` | `function` | Custom constructor hook. Receives an allocated instance plus the call args after `defaults` are applied. `init` is not auto-run on this path. | No | | `new` | `function(data: {string: any}): Component` | Override the auto-codegenned table-form constructor. Defaults to a field-name unpacker through `__call`. | No | | `requires` | `{Component}` | Components to auto-add alongside this one (see [Auto-dependencies](/tecs/components/#auto-dependencies-with-requires)) | No | | `serialize` | `function(instance: Component): {string: any}` | Custom serializer for durable data. Mutually exclusive with `transient`. | No | | `deserialize` | `function(world: tecs.World, data: {string: any}): Component` | Custom deserializer | No | | `transient` | `boolean` | If `true`, omit this component from snapshots. Mutually exclusive with `serialize`. | No | To run code when the component is added to or removed from an entity, attach [query callbacks](/tecs/queries/callbacks) (`onEntitiesAdded` / `onEntitiesRemoved`) to a query that includes the component. **Returns:** * The created FFI `Component` **Example:** ```teal local record Velocity is tecs.Component x: number y: number speed: {number, number} end tecs.newFFIComponent({ name = "Velocity", container = Velocity, fields = { {"x", "float"}, {"y", "float"}, {"speed", "float[2]"} } }) ``` ### Init hooks FFI components can include an optional `init` hook for validation and derived state: ```teal init = function(instance: Component, ...: any) ``` The init hook receives the allocated instance plus the **positional** arguments. When a caller uses `Component.new({...})`, the framework unpacks the table by field name into positional args *before* calling `init`, so the hook never sees a table in its first slot and doesn't need a `type(x) == "table"` fork. ### Custom `__call` For unusual FFI components whose public constructor arguments do not line up with their raw struct fields, provide config `__call(instance, ...)`. On that path, Tecs allocates the FFI instance, applies `defaults`, and then calls your hook. It does **not** auto-run `init` afterwards, so call `Component.init(...)` explicitly if you want to reuse init logic. For example: ```teal local record Health is tecs.Component current: integer maximum: integer end tecs.newFFIComponent({ name = "Health", container = Health, fields = { {"current", "int32_t"}, {"maximum", "int32_t"} }, init = function( instance: Health, current: integer, maximum: integer ) if current < 0 then error("Health current cannot be negative") end if maximum <= 0 then error("Health maximum must be positive") end end }) ``` Use `defaults` for static default values; use `init` for validation, normalization, and derived state. --- --- url: /tecs/components/bundles.md --- # Component Bundles Bundles are reusable templates for spawning entities with a predefined set of components. They provide: * **Consistent entity creation**: define once, spawn anywhere * **Default values**: optional components can have default factories * **Required components**: mark components that must be supplied at spawn time * **Cached spawn path**: each bundle compiles a spawn routine for its target component set, so repeated spawns avoid rebuilding the same component list ## Creating a Bundle Create bundles through the world with a declarative definition: ```teal local playerBundle: tecs.Bundle = world:newBundle("Player", { required = { Transform, Health }, with = { [Velocity] = function(): Velocity return Velocity(0, 0) end, [PlayerTag] = true, }, }) ``` `required` is an ordered array of component types. `with` is a map keyed by component type, where the value is either a factory function or `true`. Each component type can only appear once in a bundle (across both `required` and `with`). Passing components that aren't part of the bundle at spawn time is not supported. ### `required` Components listed in `required` must be supplied by the caller at spawn time. They are strictly **positional**: the order in the `required` array determines the argument order at `bundle:spawn(...)`. ```teal local enemyBundle: tecs.Bundle = world:newBundle("Enemy", { required = { Transform, Health, Damage }, }) -- Spawn: arguments match declaration order local id: integer = enemyBundle:spawn( Transform(100, 200), Health(50), Damage(10) ) ``` Use `required` when the component's value varies per entity (position, health, stats, etc.). ### `with` Components listed in `with` are automatically created at spawn time. The `with` table is a map keyed by component type; each value is either a factory function that returns an instance, or `true` for the component's default constructor. **With a factory:** ```teal local bulletBundle: tecs.Bundle = world:newBundle("Bullet", { required = { Transform }, with = { [Velocity] = function(): Velocity return Velocity(100, 0) end, [Damage] = function(): Damage return Damage(25) end, }, }) ``` The factory runs on every spawn to produce a fresh instance. Use this when with-default components need specific initial values. **With `true` (default constructor):** ```teal local treeBundle: tecs.Bundle = world:newBundle("Tree", { required = { Transform }, with = { [StaticTag] = true, [Collidable] = true, }, }) ``` `true` calls the component's default constructor (`Component()`). This is useful for tag components or components whose zero-initialized state is the desired default. With-default components cannot be overridden at spawn time. If you need a spawn-time value, move the component to `required` instead. ## World methods These methods are available on every `World`. | Method | Description | | ------ | ----------- | | [`world:newBundle`](#world-new-bundle) | Create and register a bundle. | | [`world:spawnBundle`](#world-spawn-bundle) | Spawn an entity from a registered bundle by name. | | [`world:getBundle`](#world-get-bundle) | Return one registered bundle by name. | | [`world:getBundles`](#world-get-bundles) | Return all registered bundles. | ### world:newBundle {#world-new-bundle} Creates and registers a bundle for spawning entities with a predefined set of components. ```teal function World:newBundle(name: string, def?: BundleDef): Bundle ``` **Parameters:** * `name`: Unique bundle name. * `def`: Optional bundle definition with `required` and `with` fields. **Returns:** * The registered bundle. ### world:spawnBundle {#world-spawn-bundle} Spawns an entity from a registered bundle by name. Required components are passed positionally in the order declared in the bundle. Components from `with` use their registered factory and cannot be overridden at spawn time. ```teal function World:spawnBundle(name: string, ...: Component): integer ``` **Parameters:** * `name`: Bundle name. * `...`: Required components, in declaration order. **Returns:** * The entity ID. ### world:getBundle {#world-get-bundle} Returns a registered bundle by name. ```teal function World:getBundle(name: string): Bundle ``` **Parameters:** * `name`: Bundle name. **Returns:** * The bundle, or `nil` if not found. ### world:getBundles {#world-get-bundles} Returns all registered bundles as a map keyed by bundle name. ```teal function World:getBundles(): {string: Bundle} ``` **Returns:** * Map of bundle name to bundle. ## Spawning from a Bundle There are two ways to spawn from a bundle: ### Via the bundle object ```teal -- Required components in the order they were declared. local entityId: integer = playerBundle:spawn( Transform(100, 200), Health(100) ) ``` ### Via the world by name ```teal local entityId: integer = world:spawnBundle("Player", Transform(100, 200), Health(100) ) ``` `bundle:spawn(...)` and `world:spawnBundle(name, ...)` follow the same instant-vs-staged rules as [`world:spawn`](/tecs/world#spawn): outside a deferred scope the entity is placed in its archetype before the call returns; inside a scope (query iteration, `world:defer()`, a batch op callback) the entity is staged and applies when the scope closes. The returned id is usable immediately in either case. ```teal -- Outside a scope: placed right away. local id: integer = playerBundle:spawn(Transform(0, 0), Health(100)) assert(world:isAlive(id)) -- Inside a scope (e.g. a query loop), follow-up mutations stage in order. for _archetype, _len, _entities in someQuery:iter() do local newId: integer = playerBundle:spawn(Transform(0, 0), Health(100)) world:set(newId, CustomTag) -- staged; applies when the loop exits end ``` ## Looking up Bundles Get a single bundle by name: ```teal local bundle: tecs.Bundle = world:getBundle("Player") ``` Get all registered bundles: ```teal local bundles: {string: tecs.Bundle} = world:getBundles() for name, bundle in pairs(bundles) do print(name) print("Required: " .. table.concat(bundle.required, ", ")) print("Defaulted: " .. table.concat(bundle.defaulted, ", ")) end ``` ## Bundle record Each bundle exposes these fields: | Field | Type | Description | | ------------ | ---------- | ------------------------------------------------------------------ | | `name` | `string` | The bundle's registered name. | | `required` | `{string}` | Names of components required at spawn time, in declaration order. | | `defaulted` | `{string}` | Names of components filled in by factories. | | `spawn(...)` | `function` | Spawn an entity from this bundle; returns the id. | --- --- url: /tecs/components/serialization.md --- # Component Serialization Components can be serialized and deserialized, enabling [save games](/tecs/save-games), networking, and AI integration via the [MCP server](/tecs2d/mcp/). Tecs handles most components automatically; you only provide custom `serialize`/`deserialize` functions when a component holds state that can't survive a round-trip on its own (Love2D handles, slab pointers, computed fields, etc.). If the constructor terminology here feels too implicit, read [Component Construction](/tecs/components/construction) first. For backend-specific behavior, compare [table components](/tecs/components/table-components), [FFI components](/tecs/components/ffi), [scalar components](/tecs/components/scalar-components), and [tag components](/tecs/components/tag-components). ## Automatic serialization Most components work automatically without any configuration. **Table components** serialize all user-defined fields by default, excluding framework metadata. The deserializer routes the data table through `Component.new(data)`, which means any component that registers cleanly (`fields` alone, or `init` paired with `fields`/`new`) round-trips automatically. ```teal -- This component serializes automatically. Its .new unpacks both fields. tecs.newComponent({ name = "Health", container = Health, fields = {"hp", "maxHp"}, }) -- Serializes to: {hp = 100, maxHp = 100} -- Deserializes as: Health.new({hp = 100, maxHp = 100}) ``` **FFI components** serialize via their field schema: every declared field is written as raw bytes on save and memcpy'd back on load. There's nothing to configure; the framework reads the fields you declared. If you are deciding between these two storage backends in the first place, see [Table Components](/tecs/components/table-components) and [FFI Components](/tecs/components/ffi). ```teal tecs.newFFIComponent({ name = "Velocity", container = Velocity, fields = { {"x", "float"}, {"y", "float"} } }) -- Serializes to: {x = 5.0, y = 10.0} (table format) -- Or memcpy'd as 8 bytes per entity in the binary snapshot. ``` ## Custom serialization For components whose runtime state doesn't round-trip naturally (Love2D textures, GPU handles, cached slab pointers, derived fields, etc.), provide `serialize` and `deserialize` hooks. ```teal tecs.newComponent({ name = "Sprite", container = Sprite, fields = {"path"}, init = function(instance: Sprite, path: string) instance.path = path instance.texture = love.graphics.newImage(path) end, serialize = function(sprite: Sprite): {string: any} -- Save only the path, not the GPU texture. return { path = sprite.path } end, deserialize = function(world: tecs.World, data: {string: any}): Sprite -- Reconstruct by reloading the texture through the constructor. return Sprite(data.path as string) end, }) ``` `deserialize` receives the world so it can call helpers, resolve relationship targets, or spawn side entities while reconstructing. ### When custom serialization is needed * **Love2D objects**: textures, fonts, sounds can't be serialized directly * **GPU / FFI handles**: slab pointers, buffer offsets that only make sense in the current process * **Circular references**: break cycles by storing IDs instead of references * **Computed fields**: skip derived values that can be recalculated from other state * **Version migration**: reshape old payloads into the new record shape on load * **Large payloads**: compress, or store references to external files ## Skipping a component from snapshots Set `transient = true` to omit a component from snapshots. This is the idiomatic way to mark runtime state that can be recreated after load (render caches, per-frame scratch, derived indices): ```teal tecs.newComponent({ name = "RenderCache", container = RenderCache, transient = true, }) ``` ## Schema fingerprinting & migration Every FFI component carries a canonical schema fingerprint of the form `field1:type1,field2:type2,...|sizeBytes`. The save embeds each component's fingerprint in the snapshot prelude; on load the framework compares it against the current registration. * **Match** → the bulk memcpy fast path runs (one `memcpy` per column). * **Mismatch** → the load errors. Per-entity schema migration is a future addition; today you need to either bump your game's own save version and translate offline, or switch the component to a custom `serialize` / `deserialize` pair (which opts out of the bulk path and lets you reshape the data yourself). Non-FFI components aren't fingerprinted; they round-trip through their declared `serialize` / `deserialize` every time. That includes ordinary [table components](/tecs/components/table-components), [scalar components](/tecs/components/scalar-components), and [tag components](/tecs/components/tag-components). ## Performance implications | Path | Cost per entity | When it runs | | ----------------------------------------- | --------------- | ------------------------------------------------------------------------- | | Bulk FFI memcpy | ~zero | FFI component, no custom `serialize`, schema matches on load, binary format. | | Per-entity structured codec | small | FFI component with custom `serialize`, OR non-FFI table component. | | Row-major fallback (sparse relationships) | moderate | Archetype contains a sparse container component. | Custom `serialize` opts the component out of the bulk path **even if** it's FFI-backed. The framework treats your override as a signal that raw memcpy wouldn't round-trip the runtime state correctly. That's usually the right call (e.g. `gfx.Text` holds non-portable glyph slab pointers), but it's worth knowing: a hot path of 100K FFI entities saves ~500× faster on the bulk path than the per-entity path. ## Serialization in component options | Property | Description | | ------------- | --------------------------------------------------------------------------- | | `serialize` | `function(instance: Component): {string: any}`. Convert durable component data to a plain table. Mutually exclusive with `transient`. | | `deserialize` | `function(world: tecs.World, data: {string: any}): Component`. Reconstruct a component from a plain table. Receives the world for cross-entity lookups. Defaults to `Component.new(data)`. | | `new` | `function(data: {string: any}): Component`. Table-form constructor invoked by `Component.new({...})` and the default deserialize. See [Component Construction](/tecs/components/construction#table-construction). | | `transient` | `boolean`. If `true`, omit the component from snapshots. Mutually exclusive with `serialize`. | --- --- url: /tecs/components/dirty-tracking.md --- # Dirty Tracking Tecs tracks dirty state per archetype, per component. A column whose bytes changed since the last frame is *dirty*; the dirty bit is the signal that incremental-sync consumers (renderer shadow buffers, save snapshots, reactive systems) use to decide which columns to re-upload. The model is deliberately coarse: granularity stops at the archetype + component level. There is no per-row dirty bitmap. If you need a refresher on how component values are created and replaced, start with [Component Construction](/tecs/components/construction). For the higher-level component categories this page mentions in passing, see the [Components overview](/tecs/components/). ## What sets a dirty bit A component is marked dirty on its archetype when: * **`archetype:getMut(Foo)`** is called inside a query iter (or anywhere else with an archetype handle). This is the primary signal: fetching the column for write declares mutation intent. * **`world:getMut(entity, Foo)`** is the per-entity equivalent. Use it whenever you'd normally write `local t = world:get(...); t.x = ...` but intend to mutate the returned reference; `:get` followed by cdata writes silently bypasses the dirty signal. * **`world:set(entity, Foo(...))`** writes a value, including the three-argument scalar form `world:set(entity, Foo, value)`. * **`archetype:set(row, Foo(...))`** replaces a single row's value. * **`world:markComponentDirty(entity, Foo)`** is called explicitly. Use this when a smart wrapper (e.g. a `Sprite` instance) mutates an entity's bytes through a fetched FFI cdata and there's no archetype in scope. Prefer `world:getMut` over `world:get` + this call. * **Spawn / archetype move-in / swap-pop**: every component on the destination archetype is flagged dirty in one bulk operation, since every column has new bytes for at least one row. Bits are cleared automatically at the end of each `world:update(...)`, after every system has run. ## Reading and writing columns Two access primitives, distinguished by intent: * **`archetype:get(Foo)`**: returns the column reference (or nil if the archetype doesn't carry `Foo`). Does NOT mark dirty. * **`archetype:getMut(Foo)`**: same return; also marks `Foo` dirty on the archetype. Idempotent. Use `:get` for read sites and `:getMut` at every site you intend to write into the column, regardless of whether the write goes through table assignment, FFI cdata field writes, or anything else. The dirty machinery cannot observe direct cdata writes; the `:getMut` call is the contract that tells consumers "this column may have changed." ```teal for archetype, len in query:iter() do local transforms = archetype:getMut(Transform) local velocities = archetype:get(Velocity) -- read-only for row = 1, len do local t = transforms[row] local v = velocities[row] t.x = t.x + v.x * dt t.y = t.y + v.y * dt end end ``` Both `:get` and `:getMut` return the same underlying column, so mixing them in one loop is safe; only the dirty marks differ. ## Checking dirty state * **`archetype:isComponentDirty(Foo)`**: true when `Foo` was marked dirty on this archetype since the last frame. * **`archetype:anyComponentDirty()`**: true when any component on this archetype is currently dirty. Useful for bulk re-sync paths that don't need per-component granularity. * **`world:dirtyArchetypes()`**: iterator over archetypes with at least one component dirty. Drains naturally at frame end. ```teal for archetype in world:dirtyArchetypes() do if archetype:isComponentDirty(Color) then -- Color column was rewritten this frame. end end ``` `archetype:dirtyComponents()` returns an iterator over the components currently dirty on a single archetype. ## Explicit marking When a write happens through a path the framework can't see, call the explicit marker: * **`archetype:markComponentDirty(Foo)`**: single component. * **`archetype:markAllComponentsDirty()`**: every component on the archetype (used internally for spawn/move-in/swap-pop). * **`world:markComponentDirty(entity, Foo)`**: entity-shaped wrapper for cases where you only have the entity id. `archetype:set(row, value)` and the `world:set` family all mark internally; you only need the explicit form when you bypass them. ## Lifecycle Component-dirty bits are set at write time and cleared by `world:update` after the pipeline runs. The world's `_dirtyArchetypes` set tracks which archetypes need clearing each frame, so the cost of cleanup is proportional to the archetypes that actually changed, not the total archetype count. Newly spawned entities or entities that transition between archetypes are automatically considered dirty for every component on the destination archetype. --- --- url: /tecs/relationships.md --- # Relationships Relationships model directed connections between entities, such as parent-child hierarchies, following behaviors, or targeting. Tecs manages their lifecycle automatically, preserving referential integrity when entities are despawned. ## Relationship features * **Automatic cleanup**: When you despawn a target entity, Tecs automatically removes all relationships pointing to it * **Type safety**: Relationships are strongly typed in Teal, providing compile-time guarantees * **Efficient queries**: Query for entities with specific relationships or wildcard relationships * **Flexible data**: Relationships can store just the target or include additional data about the connection * **Sparse storage**: Relationships can opt into sparse storage to avoid per-target archetype fragmentation * **Cascade delete**: Declarative cascade deletion for hierarchies like parent-child ## Kinds of relationships `newRelationship` is the single entry point. What you pass decides the storage: * **Target-only** (`newRelationship({name = "..."})`, no `fields`): the edge carries nothing but the target id. Backed by a compact FFI struct automatically. * **Data-bearing, Lua payload** (`newRelationship` with a `container` and `fields`): the edge carries fields stored in a Lua table, so they can hold any Lua value. * **Data-bearing, FFI payload** ([`newFFIRelationship`](/tecs/relationships/ffi)): the edge carries fields packed into an FFI struct for tight, cache-friendly storage. ## Creating simple relationships A *simple relationship* stores only the target entity id, with no extra data on the edge. Create one by calling `newRelationship` with just a `name`: ```teal local tecs = require("tecs") -- "entity A Likes entity B": nothing is recorded but the target. local Likes: tecs.Relationship = tecs.newRelationship({name = "Likes"}) -- Same target-only shape, but exclusive: one target at a time, so setting a -- new target replaces the old one. local Targets: tecs.Relationship = tecs.newRelationship({ name = "Targets", exclusive = true }) ``` A target-only relationship is backed by a compact FFI struct. Behavior flags still apply: `exclusive`, `sparse`, `reverseIndex`, and `cascadeDelete` all work here (see [Exclusive](#exclusive-relationships), [Sparse](#sparse-relationships), and [Cascade delete](#cascade-delete) below). Once the edge needs to carry data (a delay, a weight), add a `container` with `fields` as shown next, or use [FFI Relationships](/tecs/relationships/ffi). ## Applying relationships Apply relationships like any other component, because they are components! ```teal -- Entity A likes entities B and C (multiple targets) world:set(entityA, Likes(entityB)) world:set(entityA, Likes(entityC)) -- Entity A targets a single enemy (exclusive: one target at a time) world:set(entityA, Targets(enemy)) ``` ## Creating relationships with data Sometimes relationships need to carry additional information about the connection. For example, a "following" relationship might specify a delay. First, define a Teal record that implements `tecs.Relationship`: ```teal local record Follows is tecs.Relationship delay: number maxDistance: number --- Relationship creation. __call takes the target first; .new takes a --- config table with a target key. metamethod __call: function( self, target: integer, delay: number, maxDistance: number ): self end ``` Then create the relationship component. Tecs writes `target` onto the returned instance for you; you don't store it in `fields`. Use `fields` (with optional `defaults`) to declare the data fields. The first positional argument is always the target entity ID; the remaining arguments map to `fields` in order: ```teal tecs.newRelationship({ name = "Follows", container = Follows, fields = {"delay", "maxDistance"}, defaults = {0.5, 100}, }) ``` You now have both a positional and a table construction form: ```teal -- Positional: target first, then fields in order world:set(follower, Follows(target, 0.3, 50)) -- Table form: target is a key alongside the data world:set(follower, Follows.new({ target = target, delay = 0.3, maxDistance = 50 })) ``` See [Component Construction](/tecs/components/construction) for the shared `fields` / `defaults` / `init` / `.new` rules these inherit. ## Exclusive relationships By default, an entity can have multiple instances of the same relationship type pointing to different targets. Each unique target gets its own entry. For example, an entity can "like" multiple other entities: ```teal world:set(entity, Likes(entityA)) world:set(entity, Likes(entityB)) ``` Setting the same target again replaces the value for that target: ```teal world:set(entity, Likes(entityA)) -- replaces the previous Likes(entityA) ``` Some relationships are marked as exclusive, meaning an entity can only have one target at a time. A combat AI that `Targets` a single enemy is a natural fit: ```teal local Targets: tecs.Relationship = tecs.newRelationship({ name = "Targets", exclusive = true }) ``` When an exclusive relationship is set, any existing target is automatically replaced. Setting `Targets(enemy2)` on an entity that already has `Targets(enemy1)` replaces it. ## Sparse relationships By default, each unique target creates a distinct component type, which means entities with different targets end up in different archetypes. This is efficient for querying specific targets but causes archetype fragmentation when many distinct targets exist (e.g., a UI hierarchy with hundreds of parents). The `sparse` flag stores relationship data in entity-indexed side storage instead of archetype columns. All entities with the same sparse relationship share the same archetype regardless of their target: ```teal local ChildOf: tecs.Relationship = tecs.newRelationship({ name = "ChildOf", exclusive = true, sparse = true, reverseIndex = true, -- maintain inverse index for world:targets() cascadeDelete = true, -- despawning parent despawns children }) ``` Sparse relationships work seamlessly with queries. A dense wildcard tag is always added to the archetype, so `query({include = {ChildOf}})` matches entities with any ChildOf target. Accessing relationship data within a query uses the same row-indexed pattern as dense columns: ```teal local query: tecs.Query = world:query({include = {ChildOf, Transform}}) for archetype, len in query:iter() do local children = archetype:get(ChildOf) -- row-indexed proxy local transforms = archetype:get(Transform) -- dense column for row = 1, len do local childOf: ChildOf = children[row] -- same pattern as dense access local parentId: integer = childOf.target -- ... end end ``` You can also look up sparse relationship data per entity: ```teal -- Get the ChildOf relationship for a specific entity. local childOf: ChildOf = world:get(entity, ChildOf) if childOf then print("Parent is:", childOf.target) end -- Check a specific target. local rel: ChildOf = world:get(entity, ChildOf(someParent)) ``` ## Querying relationships Query relationships like any other component, but with additional flexibility. ### Query for any relationship (wildcard) Find all entities that have any instance of a relationship type, regardless of target. This works for both dense and sparse relationships: ```teal -- Find all entities that are children of any parent local allChildren: tecs.Query = world:query({ include = {tecs.builtins.ChildOf} }) -- Find all entities that follow something local followers: tecs.Query = world:query({ include = {Follows} }) ``` ::: details How this works When a relationship is added to an entity, Tecs adds a "wildcard" tag (the relationship container) as a dense archetype component. This enables efficient wildcard queries regardless of whether the relationship is sparse or dense. ::: ### Query for specific target (dense relationships) For dense relationships, find all entities with a relationship to a specific target using the `:targeting()` method: ```teal -- Find all followers of a specific leader local query: tecs.Query = world:query({ include = {Follows:targeting(leader)} }) ``` ::: info Sparse relationships don't support :targeting() queries Sparse relationships don't create per-target archetype components, so `:targeting()` is not available. Use `world:targets()` for reverse lookups or `world:get(entity, Rel(target))` for forward lookups. ::: ### Filtering queries by relationships You can combine relationships with other components in a single query. The query matches entities that have all the specified components, and you read both component data and relationship data row-by-row. **Example: render only entities that have a parent** ```teal local query: tecs.Query = world:query({ include = {Sprite, Transform, ChildOf} }) for archetype, len, entities in query:iter() do local sprites = archetype:get(Sprite) local transforms = archetype:get(Transform) local parents = archetype:get(ChildOf) -- works for both sparse and dense for row = 1, len do local sprite: Sprite = sprites[row] local transform: Transform = transforms[row] local parentId: integer = parents[row].target -- ... end end ``` This is the most efficient way to iterate entities with both components and relationships: the wildcard tag on the archetype narrows iteration to matching entities, and column-bound access avoids per-entity lookups. **Example: filter to a specific target (dense relationships)** For dense relationships, use `:targeting()` to narrow the archetype set to entities targeting one specific entity: ```teal -- Find all sprites that follow a specific leader local query: tecs.Query = world:query({ include = {Sprite, Transform, Follows:targeting(leader)} }) ``` For sparse relationships, this isn't supported (no per-target archetypes). Filter inside the loop instead: ```teal local query: tecs.Query = world:query({include = {Sprite, ChildOf}}) for archetype, len in query:iter() do local sprites = archetype:get(Sprite) local parents = archetype:get(ChildOf) for row = 1, len do if parents[row].target == specificParent then -- ... end end end ``` If you need fast "find all entities targeting X" lookups for a sparse relationship, use `world:targets()` instead of a query. **Example: exclude entities that have a relationship** Use the wildcard in `exclude` to find entities WITHOUT a relationship: ```teal -- Find root entities (entities with Transform but no parent) local query: tecs.Query = world:query({ include = {Transform}, exclude = {ChildOf} }) ``` ### Accessing relationship data in queries For dense relationships, access relationship data from each archetype: ```teal for archetype, len, entities in query:iter() do for row = 1, len do archetype:forEachRelationship(Follows, row, function(follow: Follows) print(string.format("Entity %d follows %d", entities[row], follow.target)) end) end end ``` For sparse relationships, use the row-indexed column proxy: ```teal for archetype, len in query:iter() do local children = archetype:get(ChildOf) for row = 1, len do local childOf: ChildOf = children[row] if childOf then print("Parent:", childOf.target) end end end ``` ## Cascade delete The `cascadeDelete` flag causes all source entities to be automatically despawned when their target is despawned. This is how `ChildOf` implements parent-child hierarchies: despawning a parent automatically despawns all children (and grandchildren, recursively). ```teal local ChildOf: tecs.Relationship = tecs.newRelationship({ name = "ChildOf", exclusive = true, sparse = true, reverseIndex = true, cascadeDelete = true, }) local parent: integer = world:spawn() local child: integer = world:spawn(ChildOf(parent)) local grandchild: integer = world:spawn(ChildOf(child)) -- Despawning parent cascades through the hierarchy. world:despawn(parent) world:update(0) -- parent, child, and grandchild are all despawned. ``` Removing a relationship (without despawning the target) does NOT trigger cascade delete. This allows safe reparenting: ```teal -- Orphan the child (remove relationship, keep both entities alive) world:remove(child, ChildOf) -- Reparent (exclusive replaces old target, no cascade) world:set(child, ChildOf(newParent)) ``` `cascadeDelete` requires `exclusive = true` and `reverseIndex = true`. It works for both sparse and dense relationships. ## Hierarchy traversal Relationships with `reverseIndex = true` maintain an inverse index that enables efficient reverse lookups. This works for both sparse and dense relationships; the only difference is where forward data is stored. ### world:targets Invokes a callback once for every source entity targeting the given entity: ```teal -- Get all children of a parent. world:targets(parent, ChildOf, function(childId: integer) print("Child:", childId) end) ``` `world:targets` accepts an optional `context` value that is forwarded to the callback as its **second** argument. This lets you hoist the visitor function to module/plugin scope and keep its accumulator state on the context table, avoiding per-call closure allocation in hot paths: ```teal -- Hoisted once at plugin scope. local visitorCtx: {count: integer} = {count = 0} local function countChild(_childId: integer, ctx: {count: integer}) ctx.count = ctx.count + 1 end -- Per call: zero new closures. visitorCtx.count = 0 world:targets(parent, ChildOf, countChild, visitorCtx) ``` Callbacks declared with a single parameter (`function(childId: integer)`) also work; Lua silently drops the extra `context` argument when the callback doesn't declare it. ### world:traverse Depth-first traversal over the full subtree of an entity: ```teal -- Walk the entire hierarchy under root. for depth, entityId in world:traverse(root, ChildOf) do local indent: string = string.rep(" ", depth) print(indent .. "Entity:", entityId) end ``` ### world:walkUp Walks **up** a relationship chain from an entity to its root, calling a callback for each ancestor. Follows the first target per level (equivalent to repeated `getFirstRelationship`), so semantics match exclusive relationships like `ChildOf` exactly: ```teal -- Print every ancestor of `entity` world:walkUp(entity, ChildOf, function(ancestorId: integer, depth: integer) print(depth, ancestorId) end) ``` Like `world:targets`, an optional `context` is forwarded to the callback (third argument): ```teal -- Sum scroll offsets up the parent chain, no closure allocation local accum = {x = 0.0, y = 0.0, offsets = scrollOffsets} local function sumOffset(ancestorId: integer, _depth: integer, ctx: typeof(accum)) local off = ctx.offsets[ancestorId] if off then ctx.x = ctx.x + off[1] ctx.y = ctx.y + off[2] end end world:walkUp(entity, ChildOf, sumOffset, accum) ``` The callback may return `false` to stop the walk early; any other return value (including `nil` or no return) continues. The optional `maxDepth` parameter (default 100) is a safety cap; exceeding it raises an error so accidental cycles surface immediately rather than silently truncating. All three methods require a relationship with `reverseIndex = true` (sparse or dense). ## Removing relationships Remove a specific target with the relationship instance: ```teal -- Remove Likes(entityB) but keep Likes(entityA) world:remove(entity, Likes(entityB)) ``` Remove all targets with the relationship container: ```teal -- Remove ALL Likes relationships from entity world:remove(entity, Likes) ``` When the last relationship of a type is removed from an entity, the entity leaves queries that include that relationship. ## Configuration reference The `tecs.newRelationship` function accepts a configuration table with these fields: | Property | Description | | --------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `name` | **Required** - The name of the relationship | | `exclusive` | Whether only one target can exist per entity (default: `false`) | | `sparse` | Use entity-indexed storage instead of per-target archetype components (default: `false`) | | `reverseIndex` | Maintain an inverse index for `world:targets()`, `world:traverse()`, and `world:walkUp()` (works on sparse or dense) | | `cascadeDelete` | Despawning the target despawns all source entities. Requires `exclusive` and `reverseIndex`. | | `container` | Type for relationships with data. If omitted, creates a simple relationship. | | `fields` | Ordered field names for the generated positional base shape. | | `defaults` | Default values for `fields`, in matching order (`nil` = no default). Requires `fields`. | | `init` | Optional hook to validate or refine a relationship instance after allocation. Requires `container`, plus `fields` or `new`. | | `transient` | If `true`, omit this relationship from snapshots. Mutually exclusive with `serialize`. | To run code when a relationship is added or removed, create a query on the relationship (or its wildcard container) and attach [`onEntitiesAdded` / `onEntitiesRemoved`](/tecs/queries/callbacks). ### Relationship API methods All relationships provide these methods: * `Relationship(targetId, ...)` - Creates a relationship instance targeting the specified entity * `Relationship:targeting(targetId)` - Returns the component for target-specific queries (dense relationships only) World methods for relationships with `reverseIndex = true`: * `world:targets(entity, Relationship, callback, context?)` - Invokes `callback(sourceId, context)` for each source entity targeting `entity`. `context` is optional and forwarded to the callback unchanged. * `world:traverse(root, Relationship)` - DFS iterator yielding `(depth, entityId)` for the full subtree * `world:walkUp(entity, Relationship, callback, context?, maxDepth?)` - Walks up the parent chain from `entity`, invoking `callback(ancestorId, depth, context)` for each ancestor. Return `false` from the callback to stop early. `maxDepth` defaults to 100; exceeding it raises an error. ### Data fields and initialization Relationships follow the same positional-plus-table pattern as regular components. See [Component Construction](/tecs/components/construction) for the full shared rules (`fields` / `defaults` / `init` / `new`, validation, and when to reach for each). A few details are relationship-specific: * **Target is always required.** Every relationship carries a target entity id, written onto the instance by Tecs. In the positional form it's the first argument (`Rel(targetId, ...)`); in the table form it's the `target` key (`Rel.new({ target = id, ... })`). * **`fields` lists data fields only.** Don't include `"target"`; it's implicit and set by the framework. Positional args after the target map to `fields` in order. * **`init` signature starts with `instance, targetId`.** `function(instance, targetId, field1, field2, ...)`. When paired with `fields`, it refines the allocated instance while `fields` drives the base positional mapping and `.new`. Reusing the `Follows` relationship from [Creating relationships with data](#creating-relationships-with-data) (`fields = {"delay", "maxDistance"}`, `defaults = {0.5, 100}`), positional arguments fill `fields` left to right and `defaults` cover any omitted: ```teal -- Positional: target first, then fields in order Follows(target) -- {delay = 0.5, maxDistance = 100, target = …} Follows(target, 0.2) -- {delay = 0.2, maxDistance = 100, target = …} Follows(target, 0.2, 50) -- {delay = 0.2, maxDistance = 50, target = …} -- Table form names them instead Follows.new({ target = target, delay = 0.2, maxDistance = 50 }) ``` `fields` and `init` can coexist. Use a custom `init` hook when you need non-trivial initialization (validation, derived values, external resources); pair it with `fields` so `.new` still codegens: ```teal local record CustomRel is tecs.Relationship customData: string metamethod __call: function(self, target: integer, customData: string): CustomRel end tecs.newRelationship({ name = "CustomRel", container = CustomRel, fields = {"customData"}, init = function(instance: CustomRel, _targetId: integer, customData: string) assert(customData, "CustomRel requires customData") instance.customData = customData end, }) ``` Snapshot deserialize for relationships routes through `.new(data)` where `data.target` is the restored target id, identical in shape to the table form above. ## Relationship lifecycle ### Automatic cleanup When you despawn an entity, Tecs automatically removes all relationships pointing to it. For sparse relationships with `cascadeDelete`, source entities are also despawned recursively. ### Dense relationship cleanup For dense relationships, when a target entity is despawned, Tecs removes the relationship components from all source entities. To run cleanup logic, attach `onEntitiesRemoved` to a query whose `include` contains the relationship (or its wildcard container); the callback fires for each archetype that loses members. --- --- url: /tecs/relationships/ffi.md --- # FFI Relationships FFI-backed relationships store relationship data in a LuaJIT FFI struct for compact, high-performance storage. This page covers only the FFI-specific storage side. For general relationship concepts (targets, `exclusive`, `sparse`, traversal) see [Relationships](/tecs/relationships/); for the shared constructor model see [Component Construction](/tecs/components/construction). If you only need a target with no extra payload, a [target-only relationship](/tecs/relationships/#creating-simple-relationships) (`newRelationship` with just a name) is FFI-backed automatically. FFI relationships provide: * **High performance**: data lives in an FFI struct, avoiding per-instance table allocation * **Compact memory**: tightly packed fields with no per-field boxing * **Type safety**: strongly typed fields with compile-time guarantees * **Full feature set**: works with `sparse`, `reverseIndex`, and `cascadeDelete` ## FFI relationships with data FFI-backed relationships can store additional data along with the target while maintaining FFI performance benefits. ```teal local tecs = require("tecs") -- Define the relationship record local record FastFollows is tecs.Relationship delay: number maxDistance: number metamethod __call: function( self, target: integer, delay: number, maxDistance: number ): self end -- Create an FFI-backed relationship local FastFollows = tecs.newFFIRelationship({ name = "FastFollows", container = FastFollows, fields = { {"delay", "float"}, {"maxDistance", "float"} }, }) ``` Both construction forms are generated automatically from the `fields` definition. Positional arguments are mapped to fields in order with `target` always first; the table form takes `target` as a key: ```teal -- Positional __call: target first, then fields in order world:set(follower, FastFollows(targetEntity, 0.3, 50.0)) -- Table form: target is a key alongside the data world:set(follower, FastFollows.new({ target = targetEntity, delay = 0.3, maxDistance = 50.0, })) ``` See [Component Construction](/tecs/components/construction) for the shared `fields` / `defaults` / `init` / `.new` rules, and [Relationships](/tecs/relationships/) for target/exclusive/sparse semantics. Note that the `target` field is automatically included in the FFI struct; you only need to specify additional data fields. ## Configuration reference The `tecs.newFFIRelationship` function accepts a configuration table with these fields: | Property | Description | | --------------- | --------------------------------------------------------------------------------------------- | | `name` | **Required** - The name of the FFI relationship | | `container` | **Required** - Type for the FFI relationship data | | `fields` | **Required** - Array of field tuples `{name, type}` for FFI struct definition | | `exclusive` | Whether only one target can exist per entity (default: `false`) | | `sparse` | Use entity-indexed storage instead of per-target archetype components (default: `false`) | | `reverseIndex` | Maintain an inverse index for `world:targets()`, `world:traverse()`, and `world:walkUp()` | | `cascadeDelete` | Despawning the target despawns all source entities. Requires `exclusive` and `reverseIndex` | | `init` | Validation and initialization hook (positional args only; `.new` unpacks before calling) | | `new` | Override the auto-codegenned `.new(data)` table-form constructor (optional) | | `transient` | If `true`, omit this relationship from snapshots. Mutually exclusive with `serialize` | ::: info Positional shape is auto-generated FFI relationships generate their positional base constructor from the `fields` definition. Use `defaults` for static defaults and `init` for validation or derived state. ::: ### FFI field types The `fields` array supports standard FFI types: | Type | Description | | ------------ | ----------------------- | | `"float"` | 32-bit floating point | | `"double"` | 64-bit floating point | | `"int8_t"` | 8-bit signed integer | | `"uint8_t"` | 8-bit unsigned integer | | `"int16_t"` | 16-bit signed integer | | `"uint16_t"` | 16-bit unsigned integer | | `"int32_t"` | 32-bit signed integer | | `"uint32_t"` | 32-bit unsigned integer | | `"int64_t"` | 64-bit signed integer | | `"bool"` | Boolean value | ### Init Hooks FFI relationships support `init` hooks for validation and derived state: ```teal local record SafeFollows is tecs.Relationship delay: number maxDistance: number end local SafeFollows = tecs.newFFIRelationship({ name = "SafeFollows", container = SafeFollows, fields = { {"delay", "float"}, {"maxDistance", "float"} }, init = function( instance: SafeFollows, target: integer, delay: number, maxDistance: number ) instance.delay = math.max(0.1, delay) instance.maxDistance = math.max(1, maxDistance) end, }) ``` The init hook receives the allocated relationship instance plus the positional arguments. By the time it runs, `target` and any generated/defaulted field population have already occurred. --- --- url: /tecs/archetype.md --- # Archetypes An archetype is a storage group for entities with the same component signature. Each entity belongs to exactly one archetype at a time. When an entity gains or loses a component, it moves to another archetype. Most code reaches archetypes through [queries](/tecs/queries/). A query finds the archetypes whose signatures match its descriptor, then each loop step gives you an archetype, a row count, and the entity IDs for those rows. ## Entities, rows, and columns Archetypes store entities by row. The same row index addresses the entity ID and each of that entity's component values: * **Entity IDs** live in `archetype.entities`. The array is 1-based, and `entities[0]` stores the current length. * **Rows** are the current positions inside an archetype. Use rows while iterating; don't cache them as stable identifiers because despawns and archetype moves can reorder rows. * **Columns** store component values for every row in the archetype. Bind columns with `archetype:get(Component)` for reads or `archetype:getMut(Component)` before writes, then index them by row. This row/column layout is why query loops bind columns once per archetype: ```teal for archetype, len, entities in query:iter() do local transforms = archetype:get(Transform) local sprites = archetype:get(Sprite) for row = 1, len do local entity = entities[row] local transform = transforms[row] local sprite = sprites[row] -- ... end end ``` ## Archetype properties | Name | Type | Description | | --------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `id` | `integer` | Unique identifier of the archetype. | | `entities` | `DoubleArray` | Entity IDs by row. `entities[0]` is the length; valid rows are `1..entities[0]`. | | `componentList` | `{Component}` | Component types in this archetype's fixed signature. Use `#componentList` / `ipairs` to inspect the signature. | | `columns` | `{Component: {Component}}` | Raw data columns. Treat as read-only; prefer `get` / `getMut` so sparse relationship proxies and dirty marking work correctly. | ## Archetype methods ### get / getMut Column access by component type. Both methods return the row-indexed column for a component when one exists. The difference is dirty marking. ```teal function Archetype:get(component: T): {T} function Archetype:getMut(component: T): {T} ``` Use `get` when you only read values. Use `getMut` when you will mutate values through the returned column. `getMut` marks the component dirty on the archetype so incremental-sync consumers such as renderer shadow buffers and snapshots can resync. `get` does not protect the column from writes; it simply does not mark the component dirty. ```teal for archetype, len in query:iter() do local transforms = archetype:getMut(Transform) local velocities = archetype:get(Velocity) -- read-only for row = 1, len do transforms[row].x = transforms[row].x + velocities[row].x * dt end end ``` ### set Replaces a component value at a row and marks the component dirty on the archetype. No archetype transition happens; the row stays where it is. The component **must** already be present on the archetype. Use `world:set` when you need to add a component to an entity or move it to another archetype. ```teal function Archetype:set(row: integer, value: C) ``` **Parameters:** * `row`: The 1-based row position of the entity. * `value`: The new component instance. **Example:** ```teal for archetype, len in query:iter() do for row = 1, len do -- Replace the component and mark dirty in one call. archetype:set(row, Color(1, 0, 0, 1)) end end ``` ### forEachRelationship Iterates all relationship instances of the given relationship container for an entity. Only concrete relationship instances are visited; the container itself is not included. ```teal function Archetype:forEachRelationship( relationshipContainer: T, row: integer, callback: function(T) ) ``` **Parameters:** * `relationshipContainer`: The relationship container to iterate. * `row`: The 1-based row position of the entity in this archetype. * `callback`: Called with each relationship instance of this type. **Example:** ```teal local Likes = tecs.newRelationship({name = "Likes"}) archetype:forEachRelationship(Likes, 5, function(likes: Likes) print("Entity likes", likes.target) end) ``` ### getFirstRelationship Gets the first relationship instance of the given relationship container for an entity, if any. For exclusive relationships (e.g. `ChildOf`) this is the single instance. ```teal function Archetype:getFirstRelationship(relationshipContainer: T, row: integer): T ``` **Parameters:** * `relationshipContainer`: The relationship container to retrieve a relationship from. * `row`: The 1-based row position of the entity in this archetype. **Returns:** * The first relationship of this type for the entity, or `nil` if none exists. **Example:** ```teal local ChildOf = tecs.builtins.ChildOf local childOf: ChildOf = archetype:getFirstRelationship(ChildOf, 5) if childOf then print("parent id:", childOf.target) end ``` ### markComponentDirty / markAllComponentsDirty Explicit dirty markers for cases where mutation happens through a path the framework can't intercept. ```teal function Archetype:markComponentDirty(component: Component) function Archetype:markAllComponentsDirty() ``` `getMut`, `world:set`, `archetype:set`, spawn, and archetype move-in / swap-pop all mark dirty internally. Reach for the explicit markers when none of those apply. ### isComponentDirty / anyComponentDirty / dirtyComponents Read dirty state. ```teal function Archetype:isComponentDirty(component: Component): boolean function Archetype:anyComponentDirty(): boolean function Archetype:dirtyComponents(): function(): Component ``` Bits are cleared automatically at the end of each `world:update`. ### clearDirtyComponents Clear every component-dirty bit on this archetype. The world's end-of-update loop calls this for each archetype that touched the dirty set during the frame; callers rarely invoke it directly. ```teal function Archetype:clearDirtyComponents() ``` ## Observing entity lifecycle To receive callbacks when entities join or leave an archetype's match set, attach [query callbacks](/tecs/queries/callbacks) (`onEntitiesAdded` / `onEntitiesRemoved`) to a `world:query(...)`. Queries handle archetype discovery, filtering, and observer registration for you. To discover new archetypes as they're created, observe the [`ArchetypeCreated`](/tecs/builtins#archetypecreated-event) event on the world (address 0). --- --- url: /tecs/events.md --- # Events Tecs provides a lightweight, type-safe event system with centralized address-based routing. Events allow decoupled systems to communicate. ## Core concepts The events system centers on three concepts: * **Events**: Type-safe objects that carry information. * **Addresses**: Integer routing destinations for events (`0` for world-level, entity IDs for entity events). * **Observers**: Functions that respond to events at specific addresses. ## Address types Events are routed through integer addresses: | Address Type | Description | Example | | -------------- | --------------------------------------------- | ------------------------------------------- | | `0` | World-level events for global communication | `world:observe(0, GamePaused, ...)` | | `>0` | Events for an entity by ID | `world:observe(entityId, OnDespawn, ...)` | ## Example: react when entity despawns Tecs emits a built-in event, `tecs.builtins.OnDespawn`, when you despawn an entity. Listen for this event to clean up references to the entity, spawn an explosion animation, play a sound effect, etc. ```teal local tecs = require("tecs") -- Spawn an entity and get the entity ID. local entity: integer = world:spawn() -- Listen for when the entity is despawned. world:observe(entity, tecs.builtins.OnDespawn, function(e: tecs.builtins.OnDespawn) print("Entity " .. e.entity .. " was despawned") end) ``` ## World-level events Use address `0` for events that aren't tied to a specific entity: ```teal -- Observe world-level events world:observe(0, MyEvent, function(e: MyEvent) print("Got MyEvent") end) -- Emit world-level events world:emit(0, MyEvent, "hi") ``` ## Entity-level events Use the entity ID as the address for entity-specific events: ```teal local entityId: integer = world:spawn() -- Observe events on this specific entity world:observe(entityId, tecs.builtins.OnDespawn, function(e: tecs.builtins.OnDespawn) print("Entity " .. e.entity .. " was despawned") end) -- When the entity is despawned, all observers on its address are automatically cleaned up ``` ## World methods These methods are available on every `World`. | Method | Description | | ------ | ----------- | | [`world:observe`](#world-observe) | Subscribe to an event at a world or entity address. | | [`world:emit`](#world-emit) | Emit an event instance or construct-and-emit an event type. | | [`world:hasObservers`](#world-has-observers) | Check whether any observer exists for an address and event type. | | [`world:stopObserving`](#world-stop-observing) | Remove a callback or named observer. | | [`world:clearObservers`](#world-clear-observers) | Remove all observers at one address. | ### world:observe {#world-observe} Registers an observer for an event at a specific address. ```teal function World:observe( address: integer, eventType: E, observer: function(E), id?: string ) ``` **Parameters:** * `address`: Address to observe (`0` for world-level events, entity ID for entity events). * `eventType`: Event type to observe. * `observer`: Callback called when the event is emitted. * `id`: Optional string ID for later removal. ```teal world:observe(0, MyCustomEvent, function(e: MyCustomEvent) print("Got MyCustomEvent") end) world:observe(entityId, tecs.builtins.OnDespawn, function(e: tecs.builtins.OnDespawn) print("Entity despawned: " .. e.entity) end) ``` ### world:emit {#world-emit} Emits an event to all observers at a specific address. ```teal function World:emit(address: integer, eventOrType: Event, ...: any) ``` **Parameters:** * `address`: Address to emit to (`0` for world-level events, entity ID for entity events). * `eventOrType`: Event instance to emit, or an event type followed by constructor args. * `...`: Constructor args, when `eventOrType` is an event type. ```teal world:emit(0, MyCustomEvent) -- Passing the type plus constructor args lets the world skip construction -- when no observers are registered. world:emit(entityId, DamageReceived, 15) ``` ### world:hasObservers {#world-has-observers} Checks if any observers exist for an event at an address. This is useful when computing the payload is expensive; you usually do not need it just to avoid event construction if you use `world:emit(address, EventType, ...)`. ```teal function World:hasObservers(address: integer, eventType: E): boolean ``` ```teal if world:hasObservers(entityId, DamageReceived) then world:emit(entityId, DamageReceived, expensiveDamagePayload()) end ``` ### world:stopObserving {#world-stop-observing} Stops observing an event at an address. ```teal function World:stopObserving( address: integer, eventType: E, observer: function(E) | string ) ``` **Parameters:** * `address`: Address to stop observing. * `eventType`: Event type. * `observer`: Callback function or string ID provided to `world:observe`. ```teal world:stopObserving(0, MyEvent, myCallback) world:stopObserving(entityId, tecs.builtins.OnDespawn, "cleanup-handler") ``` ### world:clearObservers {#world-clear-observers} Clears all observers for an address. Entity-address observers are cleared automatically when that entity despawns. ```teal function World:clearObservers(address: integer) ``` ## Event functions Event management functions are available directly on the `tecs` module: ```teal local tecs = require("tecs") ``` ### tecs.newEvent Configures an event to have an appropriate `__call`-based initialization path. ```teal function tecs.newEvent(event: E) ``` **Parameters:** * `event`: The event instance to configure * `event.init`: A function that populates an event instance (mutates in place, does not return) **Example:** ```teal -- Define the PlayerDamaged record as an event. local record PlayerDamaged is tecs.Event damage: number source: string --- Create a new PlayerDamaged event. metamethod __call: function(self, damage: number, source: string): self end -- Have Tecs set up the metatable, assign type, etc. -- `.init` receives a pre-allocated instance and mutates it. PlayerDamaged.init = function(e: PlayerDamaged, damage: number, source: string) e.damage = damage e.source = source end tecs.newEvent(PlayerDamaged) -- Direct constructors always allocate a fresh instance: local damageEvent: PlayerDamaged = PlayerDamaged(10, "fire") -- For the optimized emission path, let the world construct lazily: world:emit(0, PlayerDamaged, 10, "fire") ``` ### Constructor vs emit The two construction paths differ in allocation: * **Direct constructor** (`PlayerDamaged(10, "fire")`): always allocates a fresh, independent instance. Use it when you need to hold onto the event past the current emit. * **`world:emit(address, EventType, ...)`**: the fast path. It checks for observers before constructing anything, then reuses world-local backing storage across repeated emissions of the same type, so a hot emission loop allocates nothing. ```teal world:emit(0, PlayerDamaged, 10, "fire") ``` ::: warning Don't retain a reference to the instance an observer receives from `world:emit` past that callback; the world reuses the backing storage on the next emit of the same type. ::: ### tecs.newFFIEvent Configures an FFI event with C struct backing and slice-scoped arena allocation for optimized world emission. ```teal function tecs.newFFIEvent( event: E, fields: {{string, string}}, structName?: string ) ``` **Parameters:** * `event`: The event type to configure * `fields`: Field definitions in format `{ {"name", "type"}, ...}` where type uses C FFI types * `structName`: Optional struct name (auto-generated if not provided) **FFI Field Types:** Common FFI field types you can use: * `int32_t`, `uint32_t` - 32-bit signed/unsigned integers * `int64_t`, `uint64_t` - 64-bit signed/unsigned integers * `float`, `double` - floating point numbers * `bool` - boolean values * `char[N]` - fixed-size character arrays (e.g., `char[64]`) **Example:** ```teal -- Define an FFI event for high-frequency damage events local record DamageEvent is tecs.Event damage: number entityId: integer damageType: integer -- Constructor must match the order of the FFI fields. metamethod __call: function( self, damage: number, entityId: integer, damageType: integer ): self end -- Configure as FFI event with C struct backing. Use `"double"` (not -- `"int32_t"`) for any field that holds an entity id: packed ids can -- exceed int32 range once the generation bits are populated, and -- truncation would produce garbage ids on the receiving side. tecs.newFFIEvent(DamageEvent, { {"damage", "float"}, {"entityId", "double"}, {"damageType", "int32_t"} }, "Game_DamageEvent") -- Usage is identical to regular events local damage: DamageEvent = DamageEvent(15.5, 1234, 2) ``` Direct FFI constructors allocate a fresh cdata instance each call. For the optimized path, emit the event type through the world: ```teal world:emit(0, DamageEvent, 15.5, 1234, 2) ``` That path checks for observers first and allocates from the emitting world's leased FFI slice in the global event manager. ::: tip FFI event compatibility FFI events cannot contain Lua objects, userdata, functions, or anything incompatible with LuaJIT FFI. Use `tecs.newEvent` for events that need Lua values and prefer `world:emit(address, EventType, ...)` for the optimized path. ::: ## MessageBus `world:observe` / `world:emit` delegate to the world's `MessageBus`, the address-keyed router that holds observers and dispatches events. Each world owns one. `tecs.newMessageBus()` creates a standalone bus if you want routing without a world. | Method | Description | |--------|-------------| | `bus:observe(address, EventType, callback, id?)` | Subscribe to an event type at an address. Optional `id` lets you unsubscribe by name. | | `bus:observeOnce(address, EventType, callback)` | Subscribe; the observer is removed after it fires once. | | `bus:stopObserving(address, EventType, callbackOrId)` | Unsubscribe a callback (or `id`) from an event type at an address. | | `bus:emit(address, EventType, ...)` | Construct and dispatch an event to observers at the address. Skips construction when there are none. | | `bus:hasObservers(address, EventType)` | Whether any observer exists for that event type at the address. | | `bus:clearAddress(address)` | Remove every observer at one address. Used when an entity despawns. | | `bus:clearEntityObservers()` | Remove every per-entity observer (all addresses except global `0`), preserving global subscriptions. Used by `world:clearEntities`. | | `bus:reset()` | Remove all observers, global ones included. Full teardown. | --- --- url: /tecs/states.md --- # State Stack The state stack manages game states (play, pause, menus) with automatic entity lifecycle. Push a state to enter it, pop to leave. Entities spawned during a state are automatically tagged with that state's component and cleaned up when the state is popped. ## World methods These methods are available on every `World`. | Method | Description | | ------ | ----------- | | [`world:createState`](#world-create-state) | Create a named state and return its tag component. | | [`world:pushState`](#world-push-state) | Push a state onto the stack. | | [`world:popState`](#world-pop-state) | Pop the current state from the stack. | | [`world:peekState`](#world-peek-state) | Return the current top state name. | ### world:createState {#world-create-state} Creates a named state with an optional lifecycle policy. Returns a tag component that is auto-added to entities spawned while this state is on top of the stack. ```teal function World:createState(name: string, policy?: StatePolicy): Component ``` **Parameters:** * `name`: State name. * `policy`: Optional lifecycle policy for state transitions. **Returns:** * The tag component for this state. ### world:pushState {#world-push-state} Pushes a state onto the state stack. Fires the previous top state's `onBlur` policy and the new state's `onEnter` policy. Entities spawned after this call automatically receive the state's tag component. ```teal function World:pushState(name: string) ``` **Parameters:** * `name`: State name, previously created with `world:createState`. ### world:popState {#world-pop-state} Pops the current state from the state stack. Fires the current state's `onExit` policy and the new top state's `onFocus` policy. ```teal function World:popState() ``` ### world:peekState {#world-peek-state} Returns the name of the current top state, or `nil` if the stack is empty. ```teal function World:peekState(): string ``` ## Creating states Define states with `world:createState()`. Each state gets a tag component that is auto-added to entities spawned while the state is active. The returned component can be used in queries. ```teal local GameState = world:createState("game", { onBlur = "pause", -- pause game entities when another state is pushed on top onFocus = "resume", -- resume game entities when this state becomes top again }) local PausedState = world:createState("paused") -- Default onExit = "despawn": entities tagged with this state are despawned when popped ``` ## Pushing and popping states ```teal -- Start gameplay world:pushState("game") -- All entities spawned from here are auto-tagged with the gameState component -- Pause the game world:pushState("paused") -- game.onBlur fires: adds Paused to all gameState entities (they freeze but keep rendering) -- Entities spawned now are auto-tagged with pausedState -- Unpause world:popState() -- paused.onExit fires: despawns all pausedState entities (pause menu cleaned up) -- game.onFocus fires: removes Paused from gameState entities (they resume) ``` Check the current state with `peekState()`: ```teal local current = world:peekState() -- returns "game", "paused", etc. or nil if stack empty ``` ## Lifecycle policies Each state can define policies for lifecycle events: | Policy | When it fires | | ----------- | ---------------------------------------------------------- | | `onEnter` | When this state is first pushed | | `onExit` | When this state is popped (default: `"despawn"`) | | `onBlur` | When this state is no longer top (another state pushed) | | `onFocus` | When this state becomes top again (state above popped) | ### Policy actions Each policy can be a string action or a custom function: | Action | Effect | | ------------ | ------------------------------------------------------------ | | `"pause"` | Adds `Paused` component to all entities with this state tag | | `"resume"` | Removes `Paused` component from those entities | | `"despawn"` | Despawns all entities with this state tag | | `"disable"` | Adds `Disabled` component to those entities | | function | Calls the function with the world as argument | ```teal world:createState("cutscene", { onEnter = function(world) -- custom enter logic end, onExit = "despawn", }) ``` If no policies are specified, the default is `onExit = "despawn"`. ## Auto-tagging Entities spawned while a state is active automatically receive that state's tag component. This happens transparently in the spawn path. ```teal world:pushState("game") -- This entity automatically gets the gameState component world:spawn( tecs.builtins.Transform(100, 100), gfx.Sprite.fromAseprite("enemy.png", "idle") ) -- Entities spawned before any pushState have no state tag -- and persist across all state transitions ``` ::: tip Permanent entities Spawn persistent entities (stars, HUD, cameras) before the first `pushState` call. They won't have a state tag and will persist through all state transitions without needing any special handling. ::: ## Events The state stack emits events on transitions. Observe them at address 0 (world-level): ```teal world:observe(0, tecs.builtins.StateEnter, function(e) print("Entered state:", e.state) end) world:observe(0, tecs.builtins.StateExit, function(e) print("Exited state:", e.state) end) world:observe(0, tecs.builtins.StateBlur, function(e) print(e.state, "lost focus, pushed:", e.pushed) end) world:observe(0, tecs.builtins.StateFocus, function(e) print(e.state, "regained focus, popped:", e.popped) end) ``` ## Querying by state The state component returned by `createState` can be used in queries: ```teal local GameState = world:createState("game") -- Find all entities in the "game" state local gameEntities = world:query({include = {GameState}}) ``` ## Conditional systems Use `runIf.inState` to conditionally run systems based on the current state: ```teal local runIf = tecs.runif world:addSystem({ name = "GameplayUpdate", phase = tecs.phases.Update, runIf = runIf.inState("game"), run = function(dt) -- only runs when "game" is the top state end }) ``` ## Example: full game flow ```teal -- Create states local GameState = world:createState("game", { onBlur = "pause", onFocus = "resume", }) world:createState("paused") world:createState("dead") -- Spawn permanent entities (HUD, stars) before pushState world:spawn(tecs.builtins.Transform(0, 0), gfx.Text(font, "SCORE")) -- Start game world:pushState("game") -- Spawn game entities (auto-tagged) -- Pause world:pushState("paused") -- Game entities freeze, pause menu entities spawned -- Unpause world:popState() -- Pause menu despawned, game entities resume -- Player dies world:pushState("dead") -- Game entities freeze again, death overlay spawned -- Restart world:popState() -- dead entities despawned, game entities resume world:popState() -- game entities despawned world:pushState("game") -- fresh start ``` --- --- url: /tecs/builtins.md --- # Builtins Tecs provides various builtin plugins, components, and events. These builtins are registered with every [World](/tecs/world) by default. ## Components | Component | Description | | --------------------------------------------------- | --------------------------------------------------- | | [Name](#name-component) | Provides a name for an entity | | [ChildOf](#childof-relationship-component) | Parent/child relationship between entities | | [Transform](#transform-component) | Position, rotation, scale, and layer | | [RelativeTransform](#relativetransform-component) | Transform relative to parent entity | | [TTL](#ttl-component) | Automatically despawn after time expires | | [Disabled](#disabled-component) | Excludes entity from all queries | | [Paused](#paused-component) | Excludes from gameplay queries, keeps rendering | ### Name component Provides a name for an entity. Stored as a [scalar component](/tecs/components/scalar-components) of kind `string`, so the column holds the raw string and `world:get(id, Name)` returns a string directly. **Teal type:** ```teal Name: tecs.ScalarComponent ``` **Example:** ```teal local tecs = require("tecs") local entity = world:spawn( tecs.builtins.Name("Phreddy") ) local name = world:get(entity, tecs.builtins.Name) -- "Phreddy" ``` To update an existing entity's name, prefer the 3-arg form of `world:set`: ```teal world:set(entity, tecs.builtins.Name, "Greg") ``` ### ChildOf relationship component Defines an exclusive parent-child relationship between two entities. Uses sparse storage with cascade delete: despawning a parent automatically despawns all children (and grandchildren, recursively). **Registered with:** `exclusive = true`, `sparse = true`, `reverseIndex = true`, `cascadeDelete = true` **Example:** ```teal local parent = world:spawn() local child = world:spawn(tecs.builtins.ChildOf(parent)) -- Find all children of a parent world:targets(parent, tecs.builtins.ChildOf, function(childId: integer) print("Child:", childId) end) -- Despawning the parent cascades to children world:despawn(parent) ``` See [Relationships](/tecs/relationships/) for details on sparse storage and cascade delete. ### Transform component Provides the position, rotation, scale, and layer of an entity. You can use this component if it works for your game, or ignore it if not. **Teal type:** ```teal record Transform is components.Component --- The x coordinate of the entity. x: number --- The y coordinate of the entity. y: number --- The z coordinate of the entity. z: number --- The layer of the entity (or nil if you don't use layers). layer: integer --- The rotation of the entity in radians (default: 0). rotation: number --- The horizontal scale of the entity (default: 1). scaleX: number --- The vertical scale of the entity (default: 1). scaleY: number --- Creates a new Transform component. --- --- @param xOrValue The x position or full transform data if passing a table. --- @param y The y position. --- @param z The z position. --- @param layer The layer. --- @return the created transform component. metamethod __call: function( self, xOrValue?: Transform | number, y?: number, z?: number, layer?: integer ): Transform end ``` **Examples:** You can create a Transform component using positional arguments. This is ideal for performance: ```teal local entity = world:spawn( tecs.builtins.Transform(10, 11, 1, 2) -- x, y, z, layer ) ``` Alternatively, you can pass a table of named arguments. This generates garbage due to the table input, but it's more readable. ```teal world:spawn( tecs.builtins.Transform.new({ x = 10, y = 11, z = 1, layer = 2 }) ) ``` After the component is created, you can modify anything inside of it as needed, like rotation and scale. ```teal local transform = world:get(entity, tecs.builtins.Transform) transform.rotation = math.pi / 4 -- Rotate 45 degrees transform.scaleX = 2 -- Scale 2x horizontally transform.scaleY = 2 -- Scale 2x vertically ``` ::: tip Auto-sync Modifying anything in the `Transform` component automatically syncs to all builtin GPU systems in Tecs. No dirty tracking needed. ::: ### RelativeTransform component Defines an entity's transform relative to its parent entity. Requires the `ChildOf` component to be present on the same entity. A builtin system automatically composes the parent's transform with the relative transform to compute the final world-space position, rotation, and scale. This component: * Can position an entity relative to another, allowing use cases like GUI elements * Causes child entities to hierarchically inherit the position, scaling, and rotation of a parent entity * Automatically adds a `Transform` component if one is not present when added to an entity **Parameters:** * `x`: The x offset from the parent (default: 0) * `y`: The y offset from the parent (default: 0) * `z`: The z offset from the parent (default: 0) * `rotation`: The rotation offset in radians from the parent (default: 0) * `scaleX`: The x scale multiplier relative to the parent (default: 1) * `scaleY`: The y scale multiplier relative to the parent (default: 1) * `originX`: Origin as a fraction (0-1) of the entity's width; 0 = left, 0.5 = center, 1 = right (default: 0) * `originY`: Origin as a fraction (0-1) of the entity's height; 0 = top, 0.5 = center, 1 = bottom (default: 0) **Teal type:** ```teal record RelativeTransform is components.Component x: number y: number z: number rotation: number scaleX: number scaleY: number --- Origin as a fraction (0-1) of the entity's width: 0 = left, 0.5 = center, 1 = right. originX: number --- Origin as a fraction (0-1) of the entity's height: 0 = top, 0.5 = center, 1 = bottom. originY: number metamethod __call: function( self, x?: number, y?: number, z?: number, rotation?: number, scaleX?: number, scaleY?: number, originX?: number, originY?: number ): RelativeTransform end ``` **Examples:** Create a child entity positioned relative to its parent: ```teal local parent = world:spawn( tecs.builtins.Transform(100, 100, 0) ) local child = world:spawn( tecs.builtins.ChildOf(parent), tecs.builtins.RelativeTransform(50, 30) -- 50px right, 30px down ) ``` The child's world-space position is automatically calculated as (150, 130) and updated each frame as the parent moves. ### TTL component Automatically despawns an entity when the TTL, or "time to live", reaches zero. Tecs automatically adds a system that tracks entities with a TTL and despawns them. **Teal type:** ```teal --- Despawns an entity when the TTL reaches zero. record TTL is components.Component --- The total amount of time the entity had to live. startingTime: number --- The remaining time the entity has to live. remaining: number --- Compute the percentage of completion as a number between 0 and 1. percentComplete: function(self): number --- Create a new TTL component. --- --- @param remaining The amount of time the entity has to live. --- @return the created TTL component. metamethod __call: function(self, remaining: number): TTL end ``` **Example:** ```teal world:spawn( -- Despawn the entity after 10 seconds. tecs.builtins.TTL(10) ) ``` ### Disabled component A tag component that marks an entity as disabled, causing it to be excluded from all queries by default. This is useful for temporarily hiding entities without despawning them. **Usage:** ```teal -- Spawn a disabled entity (won't appear in queries by default) local entity = world:spawn( tecs.builtins.Disabled ) ``` ::: tip See also See *[Disabled entities](/tecs/queries/#disabled-entities)* for more on how disabled entities work with queries. ::: ### Paused component A tag component that marks an entity as paused. Unlike `Disabled`, `Paused` is **not** auto-excluded from queries. Paused entities should still render; only gameplay systems that need to skip them should use `exclude = {Paused}`. This is typically managed automatically by the [state stack](/tecs/states) when a state's `onBlur` policy is set to `"pause"`. You can also add it manually: ```teal -- Pause an entity (excluded from gameplay queries, still renders) world:set(entity, tecs.builtins.Paused) -- Unpause world:remove(entity, tecs.builtins.Paused) ``` When `Paused` is added to an entity with a `Sprite` component, the sprite animation is automatically frozen at the current frame. When `Paused` is removed, the animation resumes from where it left off. ::: tip See also See *[Paused entities](/tecs/queries/#paused-entities)* for more on how paused entities work with queries. ::: ## Events | Event | Description | | --------------------------------------------------- | --------------------------------------------------- | | [ArchetypeCreated](#archetypecreated-event) | Emitted when a new archetype is created | | [OnSpawn](#onspawn-event) | Emitted when an entity is spawned | | [OnDespawn](#ondespawn-event) | Emitted when an entity is despawned | ### ArchetypeCreated event An event emitted when a new archetype is created in the world. Observe this event at address 0 (world-level) to discover new archetypes as entities with new component combinations are spawned. **Teal type:** ```teal --- An event emitted when a new archetype is created. record ArchetypeCreated is events.Event archetype: Archetype --- Create a new ArchetypeCreated event. metamethod __call: function(self, archetype: Archetype): ArchetypeCreated end ``` **Usage:** ```teal world:observe(0, tecs.builtins.ArchetypeCreated, function(event: tecs.builtins.ArchetypeCreated) local archetype = event.archetype if archetype:get(Position) and archetype:get(Velocity) then -- Inspect or introspect newly-created archetypes here. end end) ``` ::: info Advanced feature This event is primarily used internally by queries to track matching archetypes. For normal "react when entities match a component signature" use cases, prefer [query callbacks](/tecs/queries/callbacks) (`onEntitiesAdded` / `onEntitiesRemoved`). ::: ### OnSpawn event An event emitted when an entity is spawned. The event is emitted globally (address 0). Observe this event to react when entities are created. ::: info Event timing The `OnSpawn` event is emitted at spawn time (when `world:spawn` is called), before the entity is committed: * The entity is not yet placed in an archetype or visible to queries * `world:isAlive(entity)` returns `false` until commit * You can stage further mutations on the entity ID between spawn and commit ::: **Teal type:** ```teal --- An event emitted when a specific entity is spawned. record OnSpawn is events.Event entity: integer --- Create a new OnSpawn event. metamethod __call: function(self, entity: integer): OnSpawn end ``` **Usage:** Observe globally to react to all spawns: ```teal world:observe(0, tecs.builtins.OnSpawn, function(event: tecs.builtins.OnSpawn) print("Entity spawned: " .. event.entity) end) ``` ### OnDespawn event An event emitted when an entity is despawned. The event is emitted both globally (address 0) and to the entity's address. Observe this event to clean up resources, spawn effects, or react to entity removal. ::: info Event timing The `OnDespawn` event is emitted during the despawn call, before the entity is physically removed at commit. When this event fires: * `world:isAlive(entity)` still returns `true` (the entity index entry is intact) * Entity components are still accessible via `world:get()` * The entity has not yet been removed from queries * After all observers complete, all observers on the entity's address are automatically cleaned up ::: **Teal type:** ```teal --- An event emitted when a specific entity is despawned. record OnDespawn is events.Event entity: integer --- Create a new OnDespawn event. metamethod __call: function(self, entity: integer): OnDespawn end ``` **Usage:** Observe a specific entity: ```teal world:observe(entityId, tecs.builtins.OnDespawn, function(e: tecs.builtins.OnDespawn) -- Entity is no longer "alive" but components are still accessible local pos = world:get(e.entity, Position) if pos then spawnExplosionAt(pos.x, pos.y) end print("Entity " .. e.entity .. " was despawned") end) ``` Observe globally to react to all despawns: ```teal world:observe(0, tecs.builtins.OnDespawn, function(e: tecs.builtins.OnDespawn) print("Entity " .. e.entity .. " was despawned") end) ``` --- --- url: /tecs/save-games.md --- # Save games Tecs provides a fast snapshot system for save games, checkpoints, and state restoration. Plugins and game code can attach arbitrary metadata alongside ECS data so snapshots stay self-contained (e.g., player profile, physics state, RNG seeds, etc). ```teal -- Save the world to a string.buffer local buf = world:saveSnapshot().buffer -- Load the world from the buffer world:loadSnapshot(buf) ``` ## Save formats Tecs provides two save formats out of the box: | Format | API | Description | | ------ | -------------------------------------------------- | -------- | | Binary | `world:saveSnapshot()`, `world:loadSnapshot(...)` | High-performance [LuaJIT-based](https://luajit.org/ext_buffer.html#serialize) binary format. This should be the default choice for production saves. | | Table | `world:saveSnapshot({format=\"table\"})`, `world:loadSnapshot(...)` | Programmatic inspection, in-memory round-trips, and custom tooling (e.g. JSON via `tecs.json`). | ## World:saveSnapshot Snapshots the ECS world and allows plugins to inject custom data. ```teal function world:saveSnapshot(opts?: tecs.SnapshotOptions): tecs.SnapshotOutput ``` **Parameters:** * `opts`: [Snapshot save options](#saveoptions) **Returns** * A tagged `SnapshotOutput` * For binary saves: `{format = "binary", buffer = string.buffer, snapshot = nil}` * For table saves: `{format = "table", buffer = nil, snapshot = Snapshot}` ## SaveOptions All fields are optional; the default `world:saveSnapshot()` captures every entity into a fresh buffer with no custom data. | Field | Type | Purpose | | ----------------| ----------------------------------------| ------- | | `format` | `"binary" \| "table"` | Output format. Defaults to `"binary"`. | | `buffer` | `string.buffer` | Reuse an existing buffer instead of allocating a fresh one. The buffer is `:reset()` first; see [Reusing a buffer across saves](#buffer). | | `path` | `string` | Optional binary output file path. Writes the bytes to disk and still returns the tagged result. | | `filterQuery` | [`QueryDescriptor`](/tecs/queries/) | Only save entities matching this query (`include` / `includeAny` / `exclude`). Composes freely with `layers`. | | `layers` | `{integer}` | Allow-list of `Transform.layer` values (0..31). Filters Transform-bearing entities by layer; entities without a `Transform` pass through unchanged. | | `customData` | `{string: any}` | Keyed metadata attached to the snapshot's data section. Values must be `string.buffer`-encodable. See [Snapshot events](#snapshot-events) for how to read it back. | ### buffer For high-frequency saves (replay buffers, autosave loops), pass `opts.buffer` to reuse one allocation: ```teal local buffer = require("string.buffer") local sharedBuf = buffer.new() for round = 1, 1000 do world:saveSnapshot({buffer = sharedBuf}) -- The buffer is :reset() automatically before each save. -- Do whatever you need with it (compress, send, write to disk, etc.) end ``` ### filterQuery You can restrict the capture to a subset of entities by providing a `filterQuery`. Any [`QueryDescriptor`](/tecs/queries/) works (`include`, `includeAny`, `exclude`); only matching archetypes are walked. ```teal -- Only entities that carry a Persist component. world:saveSnapshot({ filterQuery = {include = {Persist}}, }) ``` ### layers Some games are logically laid out by layer. You can serialize just specific layers by providing `layers`, an array of `Transform.layer` values (0..31). Entities carrying a `Transform` with a layer outside the allow-list are skipped; entities that don't have a `Transform` pass through unchanged (the filter only applies when there's something to filter on). ```teal -- Only Transform-bearing entities on layer 2 or 3. Non-Transform -- entities (e.g. singletons, config entities) still flow through. world:saveSnapshot({layers = {2, 3}}) -- Combine with a query: Persist entities; if they have a Transform, -- it must be on layer 2 or 3. world:saveSnapshot({ filterQuery = {include = {Persist}}, layers = {2, 3}, }) ``` ### customData You can attach keyed metadata (build version, player profile, checkpoint, etc.) by providing `customData`. Each entry becomes a data pair in the snapshot. Values must be `string.buffer`-encodable (numbers, strings, booleans, plain tables). See [Snapshot events](#snapshot-events) for how to read the data back on load. ```teal world:saveSnapshot({ customData = { build = "v0.1.2-alpha", player = "Alice", checkpoint = {level = "intro", elapsed = 42.5}, }, }) ``` ## World:loadSnapshot Restores a previously saved snapshot into `world`, replacing the current world state. See [Snapshot events](#snapshot-events) for how to hook into the load lifecycle and read back custom data. ```teal function world:loadSnapshot(source: any): tecs.SnapshotPrelude ``` **Parameters:** * `source`: Either a Lua string (e.g. from `love.filesystem.read`) or a `string.buffer` produced by `saveSnapshot`. Strings are copied once into an internal buffer; buffers are read directly without first converting to a Lua string. You may also pass a snapshot table or a tagged `SnapshotOutput`. **Returns** * `SnapshotPrelude` with `version`, `nextEntityId`, `entityCount`, `archetypeCount`, and `componentTable`. ## Table snapshots Capture the world into a plain Lua snapshot table by requesting table format: ```teal local out = world:saveSnapshot({format = "table"}) local snap = out.snapshot ``` This is useful for programmatic inspection, migration, or feeding through another serializer (e.g. `tecs.json`). It is slower than the binary path; prefer binary for production save games. ## Per-component serialization Most components serialize automatically. Table components round-trip every field, and FFI components memcpy through their schema. Components holding non-portable durable state (Love2D handles, GPU slab pointers, derived fields) can opt out of the bulk path with custom `serialize` / `deserialize` hooks. Runtime-only components that should never be saved should use `transient = true`. > See [Component serialization](/tecs/components/serialization) for the full reference, covering when to > override, schema fingerprinting and migration, performance implications, and examples. ## Saving and loading from files `saveSnapshot` returns a LuaJIT [string.buffer](https://luajit.org/ext_buffer.html); you decide how to get its bytes onto disk. ### Plain files You can use plain Lua files (though this allocates unnecessary intermediate strings): ```teal -- Save to a Lua file local buf = world:saveSnapshot().buffer local f = io.open("save.bin", "wb") f:write(tostring(buf)) f:close() -- Load from a Lua file local f = io.open("save.bin", "rb") local data = f:read("*a") f:close() world:loadSnapshot(data) ``` ### `love.filesystem.write` string You can use Love2D's `love.filesystem.write` with a string. This is dead simple, but does unnecessary string allocations. ```teal -- Save to disk local buf = world:saveSnapshot().buffer love.filesystem.write("save.bin", tostring(buf)) -- Load from disk local data = love.filesystem.read("save.bin") world:loadSnapshot(data) ``` ::: info Tecs core does not require Love2D The core of Tecs, where snapshots live, has no Love2D dependency. It does depend on LuaJIT, and Love2D and LuaJIT have really nice interop. ::: ### `love.filesystem.write` ByteData The ideal method for saving and loading goes through Love2D's [ByteData](https://love2d.org/wiki/ByteData) and [FileData](https://love2d.org/wiki/FileData) types instead. For example, to save to disk with no string allocations: ```teal local ffi = require("ffi") local buf = world:saveSnapshot().buffer local ptr, len = buf:ref() local byteData = love.data.newByteData(len) ffi.copy(byteData:getPointer(), ptr, len) love.filesystem.write("save.bin", byteData, len) ``` To load from disk with no string allocations: ```teal local buffer = require("string.buffer") local fileData = love.filesystem.newFileData("save.bin") local loadBuf = buffer.new(fileData:getSize()) loadBuf:putcdata(fileData:getPointer(), fileData:getSize()) world:loadSnapshot(loadBuf) ``` ## Snapshot events Three events fire on entity 0 during save/load so plugins and game systems can participate in the snapshot lifecycle: | Event | When | Purpose | | -------------------- | ------------------------------------------------------------------------------- | ------- | | `OnSnapshotSave` | At the start of `saveSnapshot`, before archetypes are walked. | Attach keyed data via `ev:addData(key, value)` and/or skip derived entities via `ev:exclude(component)`. | | `StartSnapshotLoad` | During `loadSnapshot`, after the world is restored, before data is dispatched. | Register per-key callbacks via `ev:onData(key, callback)`. | | `FinishSnapshotLoad` | During `loadSnapshot`, after every data callback has run. | "Load is complete" hook; `ev.prelude` carries the version/counts. | ### Attaching data during save Plugins (or any system) can attach data by listening for `OnSnapshotSave` and calling `ev:addData(key, value)`. The framework queues these calls during the event and flushes them into the snapshot's data section after the archetype data is written, so the ordering is deterministic (per-listener, in call order). ```teal world:observe(0, tecs.builtins.OnSnapshotSave, function(ev: tecs.builtins.OnSnapshotSave) ev:addData("physics.world", physicsSystem:serialize()) ev:addData("rng.state", rng:getState()) end) ``` Keys are strings; values are `string.buffer`-encodable. Use namespaced keys (`"tecs2d.physics"`, `"mygame.scoreboard"`) to avoid collisions. The framework never inspects keys or values; they're round-tripped verbatim. ### Excluding plugin-derived entities Plugins that own *derived* state (state that's a projection of some smaller, durable input) can mark their derived entities for omission via `ev:exclude(component)`. Entities carrying any excluded component are skipped entirely at save time. On load the plugin re-derives them from the source-of-truth components that *are* saved. ```teal -- Inside the tiled plugin: world:observe(0, tecs.builtins.OnSnapshotSave, function(ev: tecs.builtins.OnSnapshotSave) ev:exclude(gfx.TileChunk) -- GPU instances; re-spawned from Tilemap ev:exclude(gfx.DirtyTileChunk) -- transient dirty marker ev:exclude(tecs2d.tiled.TileSource) -- per-tile metadata; re-derived end) ``` Consider the Tiled plugin as a case study. Only the Tilemap entity + non-tile entities get serialized; the AssetLoader system re-spawns the chunks from `Tilemap.path` on load). The same pattern fits anything with a runtime-bound projection: physics bodies derived from a `RigidBody` marker, audio voices derived from an `AudioSource`, particle systems derived from an emitter component, etc. The contract is symmetric: whatever the plugin omits, the plugin re-creates. The framework only handles the omission; re-derivation is plugin code that runs in normal systems (typically watching for the source-of-truth component to appear, just like during initial spawn). ### Transient components Use `transient = true` when the entity is durable but one component on it is renderer-, physics-, audio-, or plugin-owned runtime state. The entity is still saved; transient component columns are left out of the saved archetype. On load, normal spawn behavior applies, including `requires` defaults for transient components. ```teal local SpriteData = tecs.newFFIComponent({ name = "SpriteData", container = SpriteData, fields = { {"width", "float"}, {"height", "float"}, }, transient = true, }) ``` `transient = true` cannot be combined with a custom `serialize` function. Use one or the other: either declare the whole component runtime-only with `transient`, or provide custom serialization for durable data. `exclude` and `addData` can be combined freely; both happen during the same `OnSnapshotSave` listener. ### Reading data back during load `StartSnapshotLoad` fires once the world is fully restored, before the data section is dispatched. Listeners register per-key callbacks via `ev:onData(key, callback)`; each callback fires exactly once per matching data entry. Keys with no registered callback are silently skipped. Multiple listeners may register the same key; all fire in registration order. ```teal world:observe(0, tecs.builtins.StartSnapshotLoad, function(ev: tecs.builtins.StartSnapshotLoad) ev:onData("physics.world", function(value: any) physicsSystem:deserialize(value) end) ev:onData("rng.state", function(value: any) rng:setState(value) end) end) world:loadSnapshot(buf) ``` ### Reactively finalizing loading logic Once every data callback has run, `FinishSnapshotLoad` fires as a natural "load is complete" hook: ```teal world:observe(0, tecs.builtins.FinishSnapshotLoad, function(ev: tecs.builtins.FinishSnapshotLoad) print("restored to version", ev.prelude.version) end) ``` ## In-memory tables (table format) When you need to inspect or transform the snapshot programmatically, use the table format: ```teal local snap = world:saveSnapshot({format = "table"}).snapshot -- snap is a plain Lua table: mutate, inspect, walk by hand. world:loadSnapshot(snap) ``` It accepts the same `opts` shape as `saveSnapshot` (minus `buffer`). The table is JSON-friendly, so you can feed it through `tecs.json.serialize` for human-readable saves: ```teal local snap = world:saveSnapshot({format = "table"}).snapshot love.filesystem.write("save.json", tecs.json.serialize(snap)) local payload = love.filesystem.read("save.json") world:loadSnapshot(tecs.json.parse(payload)) ``` The table format is substantially slower than binary but useful for debugging or any case where you need to peek at the snapshot before applying it. ## Performance Numbers below were measured against the shape-bench example rendering circles on an Apple-silicon M1 Mac. Each entity carries `Transform` + `Circle` + `Color`, all FFI components, spread across a handful of archetypes: representative of a real game's hot entity loop, not a synthetic fast path. | Entities | Save p50 | Load p50 | Size | | -------- | -------- | -------- | ------- | | 10K | 0.52 ms | 0.98 ms | 587 KB | Scaling is linear in entity count for both save and load; per-entity overhead is roughly 60 bytes (three FFI components, ~20 bytes each), plus negligible fixed overhead per snapshot. To reproduce: ```bash make example-shape-bench SHAPE=circle ENTITIES=10000 ``` then use the MCP integration to call `snapshot_save` against the running world. ::: details Fast-path lower bound The synthetic `make snapshot-bench` hits a stricter fast path (three POD FFI components, no GPU handles, no sparse containers) and clocks **10K save in ~18 μs / load in ~556 μs**. Many real games will not reach that lower bound: shape-bench's numbers above are closer to what you'll actually see in a game with Transforms, rendering components, and light mixed state. If your world uses components with custom `serialize` / `deserialize` (text, sprites holding GPU handles, sparse relationships), expect per-entity costs above the shape-bench numbers too. ::: ::: details What makes the format fast * **LuaJIT**: The biggest reason the binary path is so fast. * **Pre-pass component table**: all unique components used in the save appear once in the prelude; archetype frames reference them by 1-based integer index instead of repeating name+schema strings. * **Column-major data layout**: each FFI column writes / reads as one `structSize × entityCount` memcpy. No per-entity loops, no varargs spreading, no intermediate Lua tables. * **Schema fingerprint per component**: every FFI component carries a canonical `field1:type1,...|sizeBytes` string. Save embeds it; load compares against the current registration. If it matches: bulk memcpy. If it doesn't match (e.g., a component changed in a game update): slower per-entity load. * **Bulk entity IDs**: the entity ID array writes via one `putcdata` per archetype; loads via one `ffi.copy` into a freshly-allocated `double[count]`. Doubles let packed `(slot, generation)` ids up to 2^53 survive round-trip without truncation; the packed id format uses 22 bits for slot and 31 bits for generation. Components with custom `serialize` / `deserialize` (e.g. `gfx.Text`, which holds non-portable glyph slab pointers) opt out of the bulk path automatically and round-trip per entity through their structured codec. ::: ## Snapshot format spec ### Binary (LuaJIT `string.buffer`) The wire format is a sequence of LuaJIT-encoded values + raw byte runs: ``` prelude: encode(version) encode(nextEntityId) encode(entityCount) encode(archetypeCount) encode(componentCount) per i in 1..componentCount: encode(name) -- string encode(fingerprint) -- "" for non-FFI components per archetype: encode(columnCount) encode(entityCount) per c: encode(columnIndex) -- 1-based into componentTable encode(mode) -- 0 = column-major, 1 = row-major (sparse) mode == 0 (column-major): putcdata(idsArray, entityCount * 8) -- raw double[] per column: putcdata(column, structSize * entityCount) -- FFI bulk OR per k: encode(serialized value) -- non-FFI / custom mode == 1 (row-major, sparse-bearing archetype): per entity: encode(id) per component j: serializeRaw OR encode(serialize(value)) OR encode(nil) data section (sentinel-terminated): repeat: encode(true); encode(key); encode(value) encode(false) -- terminator ``` ### Table (Lua / JSON) `world:saveSnapshot({format = "table"}).snapshot` returns: ```teal { version = 1, nextEntityId = 42, componentTable = { {name = "Position"}, {name = "Health"}, }, archetypes = { { columnIndices = {1, 2}, -- references componentTable entities = { {1, {x=10, y=20}, {hp=100}}, -- {id, comp1, comp2, ...} {2, {x=30, y=40}, {hp=50}}, }, }, }, data = { -- ordered (key, value) pairs {key = "build", value = "v0.1.2-alpha"}, {key = "player", value = "Alice"}, }, } ``` Each entity row is a positional array, `{id, comp1_data, comp2_data, ...}`, aligned with the archetype's `columnIndices`. Each `comp_i_data` is whatever the component's `serialize` returned. ## See also * [`examples/save-game/`](https://github.com/tecs-dev/tecs/tree/main/examples/save-game): runnable paint demo using table snapshots * [World reference](/tecs/world): `spawnAt` (restore entity at a specific packed id) * [Components](/tecs/components/serialization): custom `serialize` / `deserialize` * [JSON module](/tecs/utils/json): fast JSON for persistence --- --- url: /tecs/utils/json.md --- # Tecs JSON Tecs JSON is a high-performance JSON parser and serializer optimized for [LuaJIT](https://luajit.org/). * **Fast parsing**: Optimized for LuaJIT with [FFI](https://luajit.org/ext_ffi.html) support * **CData support**: Parse directly from [Love2D ByteData](https://love2d.org/wiki/ByteData) or other FFI pointers * **Pretty printing**: Optional formatted output with customizable indentation * **Null handling**: Sentinel values for distinguishing JSON null from Lua nil ## Installation Tecs JSON is included as part of the tecs package. See the [Tecs Quickstart](/tecs/) for project setup. ## Quick start ```teal local json = require("tecs.utils.json") -- Parse JSON string local data = json.parse('{"name": "Player", "health": 100}') print(data.name) -- "Player" print(data.health) -- 100 -- Serialize to JSON (key order is unspecified without sortKeys) local str = json.serialize({name = "Enemy", health = 50}) print(str) -- e.g. {"name":"Enemy","health":50} ``` ## API reference ### parse Parse a JSON string into a Lua value. ```teal function json.parse(str: string): any ``` **Parameters:** * `str`: JSON string to parse **Returns:** * Parsed Lua value (table, string, number, boolean, or nil) **Example:** ```teal local data = json.parse('{"name": "Player", "health": 100}') print(data.name) -- "Player" ``` ### parseCData Parse JSON directly from an FFI pointer. Useful for parsing Love2D ByteData without string conversion. ```teal function json.parseCData(data: ffi.CData, length: integer): any ``` **Parameters:** * `data`: Pointer to JSON data * `length`: Length of data in bytes **Returns:** * Parsed Lua value **Example:** ```teal local fileData = love.filesystem.read("data", "config.json") local config = json.parseCData(fileData:getFFIPointer(), fileData:getSize()) ``` ### serialize Serialize a Lua value to a compact JSON string. ```teal function json.serialize(value: any, sortKeys?: boolean): string ``` **Parameters:** * `value`: Value to serialize (table, string, number, boolean, nil) * `sortKeys`: Optional. Sort object keys alphabetically (default: false) **Returns:** * JSON string **Example:** ```teal -- Without sortKeys, object key order is unspecified. local str = json.serialize({name = "Enemy", health = 50}) print(str) -- e.g. {"name":"Enemy","health":50} -- Pass sortKeys = true for deterministic, alphabetically-ordered output. local str = json.serialize({b = 2, a = 1}, true) print(str) -- {"a":1,"b":2} ``` ### serializePretty Serialize a Lua value to a formatted JSON string with indentation. ```teal function json.serializePretty(value: any, sortKeys?: boolean, indent?: string): string ``` **Parameters:** * `value`: Value to serialize * `sortKeys`: Optional. Sort object keys (default: true) * `indent`: Optional. Indent string (default: two spaces) **Returns:** * Formatted JSON string **Example:** ```teal local data = {name = "Player", stats = {health = 100}} print(json.serializePretty(data)) -- { -- "name": "Player", -- "stats": { -- "health": 100 -- } -- } ``` ## Sentinel values ### json.NULL Represents JSON `null` in Lua. Use this to distinguish explicit null values from missing keys. ```teal local data = json.parse('[1, null, 3]') print(data[2] == json.NULL) -- true local str = json.serialize({value = json.NULL}) print(str) -- {"value":null} ``` ### json.EMPTY\_ARRAY Explicitly serializes as `[]`. This is the default for empty tables, but provided for symmetry. ```teal local str = json.serialize(json.EMPTY_ARRAY) -- [] ``` ### json.EMPTY\_OBJECT Forces an empty table to serialize as `{}` instead of `[]`. ```teal local str = json.serialize({}) -- [] local str = json.serialize(json.EMPTY_OBJECT) -- {} ``` --- --- url: /tecs/utils/logging.md --- # Logging module Tecs provides a lightweight and fast logging module with a no-op disabled path. Disabled log levels are replaced with an empty function at runtime, avoiding branching, formatting, or allocation. Enabled levels write formatted messages to a configurable sink (defaults to stderr). Timestamps are second-resolution. ## Basic usage First, require the logging module: ```teal local logging = require("tecs.utils.logging") ``` Next, create a logger by providing a name. This is typically the name of the module. ```teal local LOGGER = logging.getLogger("mySystem") ``` Now you can log using `debug`, `info`, `warn`, and `error` methods. The first argument is the message, in `string.format` syntax, followed by zero or more `string.format` arguments. ```teal LOGGER:info("system started") LOGGER:debug("loaded %d assets from %s", count, path) LOGGER:warn("%.1f%% memory used", 85.3) LOGGER:error("failed to load: %s", filename) ``` ::: tip Pass raw values Don't pre-format values yourself. Formatting only runs if the level is enabled, so passing raw values keeps disabled logs free. (Use `%s` for values that need `tostring`; numeric specifiers like `%d` and `%f` expect numbers and may error if given other types.) ::: ## Log levels Levels from most to least verbose: | Level | Description | | ------- | ---------------------------------------- | | `DEBUG` | Verbose diagnostic info | | `INFO` | General operational messages | | `WARN` | Potential issues that aren't fatal | | `ERROR` | Failures that need attention | | `OFF` | Suppresses all output | Setting a level enables that level and all less verbose levels below it. For example, `WARN` enables `WARN` and `ERROR`, but disables `DEBUG` and `INFO`. ::: tip Just log, don't check Don't worry about ever checking if a log level is enabled because: 1. Logging uses Lua varargs, so the logger itself does not allocate transient tables in the common case 2. Disabled levels are an empty function call: no branching, formatting, or argument inspection 3. Formatting on enabled levels only runs after the level check passes ::: ## Changing the log level ```teal logging.setLevel("DEBUG") -- enable all levels logging.setLevel("WARN") -- only warnings and errors logging.setLevel("OFF") -- silence everything ``` `setLevel` updates all existing loggers immediately, in place, so code holding a reference to a logger sees the change. ## Changing the output sink By default, log messages are written to `io.stderr`. You can redirect to any file handle: ```teal local logFile = io.open("game.log", "a") logging.setSink(logFile) ``` Like `setLevel`, this updates all existing loggers. The module does not close or flush sinks for you; the caller owns the file handle lifecycle. ## Output format Each log line is written as: ``` YYYY-MM-DD HH:MM:SS name LEVEL: message ``` For example: ``` 2026-04-12 14:30:00 tecs2d.gfx.mesh ERROR: No render pipeline found in world resources 2026-04-12 14:30:00 mySystem INFO: entity 42 spawned at (100, 200) ``` ## Logger caching `getLogger` returns the same logger instance for a given name. Names are used verbatim and are case-sensitive. Calling it repeatedly is essentially free (a table lookup), though best practice is to create a variable for a logger in each module that needs logging. You can do this: ```teal -- These return the same object: local a = logging.getLogger("physics") local b = logging.getLogger("physics") assert(a == b) ``` But you *should* do this: ```teal local LOGGER = logging.getLogger("physics") ``` ## Module properties | Property | Default | Description | | ------------ | ----------------------- | ------------------------------------------ | | `level` | `"INFO"` | The active log level (read only) | | `sink` | `io.stderr` | The output file handle (read only) | | `dateFormat` | `"%Y-%m-%d %H:%M:%S "` | The strftime format passed to `os.date` | ## Performance An enabled log call measures at **~29 ns/call** on LuaJIT 2.1 with a null sink on an Apple M1 (`logger:info("loaded %d entities", n)`). Real I/O adds its own cost on top; in practice `io.stderr` writes will dominate. Disabled levels have effectively no overhead: the method slot is replaced with a no-op function, avoiding branching, formatting, and timestamp lookup. ::: details What makes it fast 1. **Shared writers, per-logger state.** All loggers of a given level point at the same function object, and all per-logger state (sink, format, prefix) lives on the logger table. Call sites stay monomorphic no matter how many loggers rotate through them, so LuaJIT specializes a single trace. 2. **Per-second date cache.** The default `dateFormat` is seconds-resolution, so a naive `os.date` call per log would allocate a fresh string every time (~500 ns, dominating the cost). The module caches the formatted timestamp once per wall-clock second, keyed on `ffi.C.time` (JIT-traceable; `os.time` is not). Effectively all enabled logs within a given second share one allocation. ::: --- --- url: /tecs/utils/profiling.md --- # Profiling Tecs provides two methods for performance profiling games: 1. A LuaJIT sampling profiler that writes [collapsed-stack][2] output: "What are the slow parts of my code?" 2. LuaJIT trace aborts: "Is the JIT working on my code?" ## Sampling profiler The sampling profiler tags every collected stack with the active [zone path][1], so you can attribute time to phases ("afterFixed/Render"), systems ("gfx.sprite"), and arbitrary sub-regions you push yourself. Output is the standard collapsed-stack format consumed by [speedscope.app][3], [FlameGraph.pl][4], inferno, pyroscope, and most other flamegraph tools. ### MCP client The easiest way to profile is to connect to a running game via the [built-in MCP server][5] and ask your AI agent to do it. ``` You: My game feels sluggish, profile it for 5 seconds. Agent: *starts profiler* ...5 seconds later *saves profile to /tmp/tecs.collapsed* gfx.GPURender/gBuffer dominates at 83.8% of frame time. ``` ### Timed session Start a session, schedule a one-shot system to stop it after a delay. `tecs.runif.after` fires once and removes itself from the pipeline. Pass a path to `:stop()` to write the collapsed-stack text directly. ```teal local profile = require("tecs.utils.profile") local session = profile.sample() world:addSystem({ phase = tecs.phases.First, runIf = tecs.runif.after(5), run = function() session:stop("/tmp/tecs.collapsed") end }) ``` ::: details Saving in Love2D In a LOVE game, get a path inside the save directory with: ```teal local filename = love.filesystem.getSaveDirectory() .. "/tecs.collapsed" ``` ::: ### Custom zones Push a `jit.zone("name")` to tag any region you want samples attributed to: ```teal local zone = require("jit.zone") world:addSystem({ name = "myGame.Render", phase = tecs.phases.Render, run = function(dt, world) zone("uploadBuffers") uploadBuffers() zone() zone("drawScene") drawScene() zone() zone("postProcess") postProcess() zone() end }) ``` ::: tip Where to put zones * Zones are no-ops when no profile session is active, so leaving `zone("foo")` calls sprinkled in shipped builds costs essentially nothing. * Don't push/pop inside hot inner loops, but do fence them around systems and major logic blocks. ::: ## Trace abort tracker LuaJIT compiles hot loops into traces. When recording fails (NYI bytecode, IR overflow, trace too long, blacklisting after repeated failures, etc.), the trace is abandoned and that code falls back to the slower interpreter. Many aborts are harmless, but aborts in hot code are worth investigating. A sampled flamegraph will show the slow code as slow, but won't tell you the JIT gave up on it. `profile.trace` attaches to LuaJIT's trace events and aggregates aborts into a report sorted by severity: | Severity | What it means | What to do | | --------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------| | **`blacklist`** | LuaJIT permanently demoted this trace to the interpreter. | Investigate. | | **`warn`** | NYI bytecode (not yet implemented), FastFunc bailout, or a trace size limit. | If hot, consider rewriting the offending construct | | **`info`** | Benign trace formation events ("leaving loop", "down-recursion"). | Nothing; filtered out unless you opt in. | ### Starting and stopping Start a session, do something with the report when you stop it: ```teal local profile = require("tecs.utils.profile") local session = profile.trace() -- ...later... local report = session:stop() print(report) ``` Or pass a path to write the formatted report to disk: ```teal session:stop("/tmp/aborts.txt") ``` Periodic reporting via a system, restarting after each report: ```teal local session = profile.trace() world:addSystem({ name = "profile.traceReport", phase = tecs.phases.First, runIf = tecs.runif.every(10), -- run every 10 seconds run = function() local report = session:stop() if report.blacklisted > 0 then print(report) end session = profile.trace() end }) ``` ### Sample report `tostring(report)` (and the file written by `:stop(filename)`) is RFC 4180 CSV, suitable for spreadsheets, `sort`, `diff`, and most tools: ```csv severity,count,reason,location,zone blacklist,1,blacklisted,src/enemy/ai.lua:142,update/enemyAi warn,312,"NYI: FastFunc string.format",src/hud/score.lua:38,render/hud warn,87,"NYI: bytecode CAT",src/dialog.lua:64,render/dialog warn,37,trace too long,src/inventory.lua:201,update/inventory ``` Rows are sorted by severity (`blacklist` > `warn` > `info`) then count descending, so the most actionable rows are at the top. The top-level `durationSec`, `totalAborts`, and `blacklisted` summary values live on the returned `TraceReport` object (not in the CSV). Read them directly: ```teal local report = session:stop("/tmp/aborts.csv") print(string.format("%ds, %d aborts, %d blacklisted", report.durationSec, report.totalAborts, report.blacklisted)) ``` ## API ### `profile.sample(opts?)` ```teal function profile.sample(opts?: SampleOptions): SampleSession ``` Starts a sampling session and returns a handle. Errors if a sample session is already active. `SampleOptions`: | Field | Default | Description | | ------------- | ------- | ------------------------------------------------------------------------------------------------ | | `intervalMs` | `1` | Sampler interval in ms. 1 is the practical minimum; use 5 or 10 for long sessions. | | `zone` | nil | Restrict output to samples whose zone path starts with this prefix (e.g. `"afterFixed/Render"`). | Leaf frames always carry a `_[N]`/`_[I]`/`_[C]`/`_[G]`/`_[J]` marker reflecting the dominant VM state (Native, Interpreter, C, GC, JIT compiler). You can start a sample with no options: ```teal local session = profile.sample() ``` Or pass options to make sampling coarser or restrict to a single zone subtree: ```teal local session = profile.sample({ intervalMs = 5, zone = "afterFixed/Render", }) ``` ### `SampleSession:stop(filename?)` ```teal function SampleSession:stop(filename?: string): string ``` Stops the session and returns the [collapsed-stack][2] text. If `filename` is given, the text is also written to that path as a side effect. Errors if called twice or if the file cannot be written. Stop and inspect the text in memory: ```teal local session = profile.sample() -- ...later... local text = session:stop() ``` Stop and write the text to disk in one call (still returned, so you can also inspect it): ```teal local text = session:stop("/tmp/tecs.collapsed") ``` ### `SampleSession:pause()` / `SampleSession:resume()` ```teal function SampleSession:pause() function SampleSession:resume() ``` Use `:pause()` and `:resume()` to skip recording samples. For example, this might be useful for excluding setup / teardown from a benchmark harness. Both methods are idempotent and error if the session has been stopped. ```teal local s = profile.sample() for _, case in ipairs(cases) do setupCase(case) s:resume() runCase(case) s:pause() teardownCase(case) end local text = s:stop() ``` ### `profile.trace(opts?)` ```teal function profile.trace(opts?: TraceOptions): TraceSession ``` Starts a trace abort tracker and returns a handle. Errors if a trace session is already active. `TraceOptions`: | Field | Default | Description | | ----------------- | ------- | ------------------------------------------------------------------------------------------------------------------------- | | `includeBenign` | `false` | Include "leaving loop", "down-recursion", "up-recursion", and "inner loop" events. Useful when debugging trace formation. | You can start a trace with no options: ```teal local session = profile.trace() ``` Or include the benign trace-formation events that are filtered out by default, useful when investigating why a trace failed to form: ```teal local session = profile.trace({includeBenign = true}) ``` ### `TraceSession:stop(filename?)` ```teal function TraceSession:stop(filename?: string): TraceReport ``` Stops the session and returns the `TraceReport`. If `filename` is given, the formatted report is also written to that path as a side effect. Errors if called twice or if the file cannot be written. The returned `TraceReport` has a `__tostring` metamethod that renders RFC 4180 CSV, so `print(report)` works directly. The on-disk format is the same CSV you'd get from `tostring(report)`. ### `TraceSession:pause()` / `TraceSession:resume()` ```teal function TraceSession:pause() function TraceSession:resume() ``` Use `:pause()` and `:resume()` to skip recording traces. Event registration stays active (cost: one boolean check per abort); counts captured on either side of the pause window are preserved. Calls are idempotent and error if the session has been stopped. You can stop and inspect the report: ```teal local session = profile.trace() -- ...later... local report = session:stop() if report.blacklisted > 0 then print(report) end ``` You can stop and write the formatted report to disk in one call (the report is still returned): ```teal local report = session:stop("/tmp/aborts.txt") ``` `TraceReport` fields: | Field | Description | | --------------- | --------------------------------------------------------------------------------------------------- | | `durationSec` | Wallclock seconds the session was active. | | `totalAborts` | Total abort events recorded. Excludes benign trace-formation events unless `includeBenign` was set. | | `blacklisted` | Count of trace blacklist events. Always actionable. | | `sites` | List of `AbortSite` records, sorted by severity then count desc. | `AbortSite` fields: | Field | Description | | ------------ | ---------------------------------------------------------------------------------------------------------- | | `severity` | `"blacklist"`, `"warn"`, or `"info"`. | | `count` | Times this exact `(severity, reason, location, zone)` combination fired. | | `reason` | Human-readable reason from `jit.vmdef.traceerr`. NYI bytecode aborts are rendered with the opcode name. | | `location` | `":"` of the function being recorded at abort time. | | `zone` | Active zone path at abort time (empty when no zone was on the stack). | [1]: https://luajit.org/ext_profiler.html#jit_zone [2]: https://www.brendangregg.com/flamegraphs.html [3]: https://speedscope.app [4]: https://github.com/brendangregg/FlameGraph [5]: /tecs2d/mcp/tools#profiler_start --- --- url: /tecs2d.md --- # Getting Started Tecs2D is a 2D game engine built on top of [Tecs](/tecs/) and [Love2D 12](https://love2d.org). It wires the ECS into the Love2D event loop and adds rendering, audio, input, physics, tiled maps, tweens, and dev tooling. The fastest way to start is the [starter template](https://github.com/tecs-dev/tecs-starter). ## Prerequisites You will need to install these tools to use Tecs2D: * **Lua**: Runs the starter build script * **LuaRocks**: Lua package manager - [Installation](https://github.com/luarocks/luarocks/blob/main/docs/download.md) * **Teal**: Typed Lua compiler - [Download](https://teal-language.org/#download) * **[LÖVE 12](https://love2d.org)**: Game runtime. LÖVE 12 is not yet a stable release, so Tecs2D targets [nightly builds](https://nightly.link/love2d/love/workflows/main/main). The starter downloads this automatically. Next, install Tecs2D (and Tecs) into your project using a single command: ```bash luarocks install --dev --tree=src/vendor --lua-version=5.1 tecs2d ``` *While Tecs2D is in preview, `--dev` is required. There are no tagged releases yet.* ## Starter template ::: code-group ```bash [Git Clone] git clone https://github.com/tecs-dev/tecs-starter.git my-game cd my-game ./tecs run ``` ```bash [GitHub CLI] gh repo create my-game --template tecs-dev/tecs-starter --clone cd my-game ./tecs run ``` ::: You should see the scrolling shooter demo. ### What's included The starter template comes pre-configured with: * **`tecs` CLI** - Cross-platform setup, checking, builds, asset copying, dependency management, and launching * **tlconfig.lua** - Teal compiler configuration * **Type definitions** - Downloaded automatically for Love2D, LuaJIT FFI, etc. * **Demo game** - A small fixed-camera scrolling shooter built from focused plugins and state systems ### Project structure ``` my-game/ ├── tecs # Cross-platform build orchestration ├── tlconfig.lua # Teal configuration ├── src/ │ ├── main.tl # Game entry point │ ├── conf.tl # Love2D configuration │ └── plugins/ │ ├── game.tl # Game setup and shared systems │ ├── shared.tl # Components, constants, and asset preload │ └── states/ # Focused state/gameplay plugins ├── assets/ # Images, sounds, fonts ├── types/ # Type definitions (generated) ├── build/ # Compiled output (generated) └── src/vendor/ # Dependencies (generated) ``` ### Wiring up Tecs2D `tecs2d.run` configures the world, render pipeline, and game plugin, then takes over Love2D's main loop: ```teal local tecs = require("tecs") local tecs2d = require("tecs2d") local function game(world: tecs.World) -- register components, spawn entities, add systems end love.run = tecs2d.run({ fps = 60, game = game, render = { virtualWidth = 800, virtualHeight = 600, }, }) ``` The pure-ECS pieces (`World`, components, queries, systems) come from [Tecs](/tecs/). Tecs2D adds the engine layer: rendering, audio, input, etc. Install `tecs2d` when you want the full engine layer; it depends on `tecs` automatically. ### Build targets | Command | Description | | --------------------- | ------------------------------------------------------ | | `./tecs run` | Build and run the game (runs setup automatically) | | `./tecs build` | Compile without running | | `./tecs check` | Typecheck without compiling | | `./tecs clean` | Remove build artifacts | | `./tecs love12` | Download or refresh the local Love2D 12 runtime | On Windows, use `lua tecs ` instead of `./tecs `. ### Managing dependencies ```bash # Add a package luarocks install --tree=src/vendor --lua-version=5.1 penlight # Add a specific version luarocks install --tree=src/vendor --lua-version=5.1 penlight 1.14.0 ``` ## Next steps * [Tecs Quickstart](/tecs/) - Learn ECS concepts and build your first system * [Love2D Integration](/tecs2d/love2d) - Game loop, events, and Love2D phase mapping * [Rendering](/tecs2d/rendering/) - Camera, sprites, shapes, lighting * [Input & Controls](/tecs2d/input/) - Keyboard, mouse, gamepad --- --- url: /tecs2d/love2d.md --- # Love2D Integration The `tecs2d` module integrates Tecs with Love2D, providing the game loop and event handling. ## Getting started ```teal -- main.tl local tecs2d = require("tecs2d") love.run = tecs2d.run({ fps = 60, game = gamePlugin, render = { virtualHeight = 180 }, }) ``` ### run Creates a `love.run` function integrated with Tecs's world and phase system. This function replaces the default Love2D run loop with one that manages a Tecs world, handles fixed timestep updates, and integrates all Love2D events with Tecs. ```teal love.run = tecs2d.run({ fps = 60, game = gamePlugin, render = { virtualHeight = 180, lightingMode = "deferred", layers = { [1] = { name = "background" }, [2] = { name = "sprites" }, [10] = { name = "hud", space = "virtual" }, } } }) ``` #### Parameters `tecs2d.run` takes a single `RunConfig` table: | Field | Type | Default | Description | | -------- | ------------------------------------------ | ------------------------------ | --------------------------------------------------------------- | | `fps` | `number` | `60` | Target frames per second | | `game` | `function(tecs.World)` | *(required)* | A plugin function that receives the world and sets up your game | | `render` | [`RenderConfig`](/tecs2d/rendering/#renderconfig) | [defaults](/tecs2d/rendering/#fields) | Render pipeline configuration | | `hotReload` | `HotReloadConfig` | disabled | Optional development hot reload using one build stamp file and snapshots | See [`RenderConfig`](/tecs2d/rendering/#renderconfig) for all available fields and their defaults. The pipeline is accessible in your game plugin via `world.resources[gfx.PIPELINE]`. #### Returns A function suitable for assigning to `love.run`. ### quit Triggers a quit event to exit the application cleanly. This is the recommended way to exit a Tecs application. ```teal local input = require("tecs2d.input") -- Exit on escape key if input.isKeyReleased("escape") then tecs2d.quit() end -- Exit with custom error code if criticalError then tecs2d.quit(1) -- Exit code 1 for error end ``` ::: danger `tecs2d.quit()` does not quit immediately Calling `tecs2d.quit()` causes the game to exit at the end of the frame or start of the next frame. It does not cause the function that called it to immediately exit. ::: #### Parameters | Name | Type | Description | | ------------ | --------------------- | ----------------------------------------------------- | | `exitCode` | `number` (optional) | The exit code to return (defaults to 0 for success) | #### Usage notes * The default exit code is 0 (success) * Non-zero exit codes typically indicate an error condition ## Integration features ### Automatic phase mapping The game loop automatically integrates Love2D's rendering pipeline with Tecs phases: | Tecs Phase | Love2D Integration | | ------------------------- | -------------------------------------------------- | | `tecs.phases.PostStartup` | Steps timer after initialization | | `tecs.phases.Update` | Calls `love.update()` if defined | | `tecs.phases.FixedFirst` | Marks entry to fixed timestep phases | | `tecs.phases.FixedLast` | Clears latched input, marks exit from fixed phases | | `tecs.phases.RenderFirst` | Clears screen, resets graphics state | | `tecs.phases.Render` | Calls `love.draw()` if defined | | `tecs.phases.RenderLast` | Presents rendered frame | ### Input resource Input is available through the [input module](/tecs2d/input/): ```teal local input = require("tecs2d.input") -- Check keyboard state if input.isKeyDown("w") then -- Move forward end -- Check mouse position local mouseX, mouseY = input.mouseX, input.mouseY -- Check gamepad (iterate through connected joysticks) for joystick, joystickInput in pairs(input.joysticks) do if joystickInput:isGamepadButtonDown("a") then -- Jump end end ``` ::: tip Why use this? You can still use `love.keyboard.isDown()` and the like, but the input module efficiently buffers keyboard events and tracks when keys are released or pressed in a way that *just works* across fixed and non-fixed update phases. See [input documentation](/tecs2d/input/#input-philosophy-in-tecs) for more information. ::: ### Event observation Love2D events are translated into Tecs events and can be observed like any other Tecs event. See [Love2D Events](/tecs2d/events) for all available event types. ```teal local tecs2d = require("tecs2d") world:observe(0, tecs2d.MousePressed, function(e: tecs2d.MousePressed) print("Mouse clicked at", e.x, e.y, "button", e.button) end) world:observe(0, tecs2d.Resize, function(e: tecs2d.Resize) print("Window resized to", e.width, "x", e.height) end) ``` Standard Love2D callbacks continue to work alongside Tecs: ```teal -- Both approaches work simultaneously function love.keypressed(key) if key == "escape" then love.event.quit() end end world:addSystem({ phase = tecs.phases.Update, run = function() if input.isKeyPressed("escape") then tecs2d.quit() end end }) ``` ::: tip Why use Tecs events? You can still use `love.*` events as usual, but using Tecs events allows for building decoupled and composable plugins that don't have to hijack `love.*` callback methods. Any number of Tecs plugins can listen to these Love2D events and react to them. ::: ### Hot Reload `hotReload` is a development workflow for preserving ECS state across a Love2D restart. Tecs2D polls a single stamp file; your build tool updates that stamp only after a successful rebuild. ```teal love.run = tecs2d.run({ fps = 60, game = gamePlugin, hotReload = { enabled = true, snapshotPath = ".tecs-hot-reload.snapshot", stampPath = ".tecs-reload-stamp", }, }) ``` When the stamp changes, Tecs2D saves a binary world snapshot, shuts down the world, and returns Love2D's `"restart"` signal. On the next startup it restores the snapshot after `Startup` and before `PostStartup`. Use startup phases with that order in mind: * `PreStartup` / `Startup`: register systems, observers, plugins, and runtime resources. * Snapshot restore: replaces entity data while preserving systems, resources, queries, and global observers. * `PostStartup`: rebuild transient or derived entities that are intentionally not snapshotted. For a project launched from its `build/` directory, the host stamp file is often `build/.tecs-reload-stamp`, while the in-game `stampPath` is `.tecs-reload-stamp`. The following example watches game assets for changes and then triggers a hot reload: ```bash watchexec -w src -w assets './tecs build && touch build/.tecs-reload-stamp' ``` ## Basic game setup In your `main.tl` Love 2D script: ```teal local tecs2d = require("tecs2d") local game = require("game") love.run = tecs2d.run({ fps = 60, game = game, }) ``` In `game.tl`, implement the game setup and systems: ```teal local tecs = require("tecs") local tecs2d = require("tecs2d") local input = require("tecs2d.input") return function(world: tecs.World) -- Register components local record PositionType is tecs.Component x: number y: number end local Position = tecs.newComponent({ name = "Position", container = PositionType, fields = {"x", "y"}, defaults = {0, 0}, init = function(instance: PositionType) instance.x = instance.x or 0 instance.y = instance.y or 0 end }) -- Add a system to quit world:addSystem({ phase = tecs.phases.Update, run = function() if input.isKeyPressed("escape") then tecs2d.quit() end end }) -- Create queries outside systems local positionQuery = world:query({include = {Position}}) world:addSystem({ phase = tecs.phases.Render, run = function() for archetype, len in positionQuery:iter() do local positions = archetype:get(Position) for row = 1, len do local pos = positions[row] love.graphics.circle("fill", pos.x, pos.y, 10) end end end }) -- Spawn initial entities on startup world:addSystem({ phase = tecs.phases.Startup, run = function() world:spawn(Position(400, 300)) end }) end ``` --- --- url: /tecs2d/events.md --- # Love2D events Tecs provides type-safe event wrappers for Love2D callbacks, allowing you to observe and react to window, input, and system events through the [event system](../tecs/events.md). Love2D events are automatically captured and emitted by Tecs when you use the framework. You can observe these events on the [world](../tecs/world.md) or use traditional Love2D callbacks. ::: tip World-level events use address `0` Love2D events are world-level events, so use address `0` when observing them. See [Events](../tecs/events.md#address-types) for more on address types. ::: ## Event flow When an event is emitted from Love2D, Tecs emits the event to the Tecs event system and then emits the event to any registered `love.*` function. This means you can use both approaches: ```teal local tecs2d = require("tecs2d") -- Tecs events work world:observe(0, tecs2d.MousePressed, function(e: tecs2d.MousePressed) handleClick(e.x, e.y, e.button) end) -- Love2D callbacks work too function love.mousepressed(x, y, button, istouch, presses) handleClick(x, y, button) end ``` ## Available events ### Window events #### Focus Triggered when the window gains or loses focus. *See [love.focus](https://love2d.org/wiki/love.focus)* | Property | Type | Description | | ----------- | ----------- | ------------------------------------------ | | `visible` | `boolean` | `true` if the window has focus | ```teal world:observe(0, tecs2d.Focus, function(e: tecs2d.Focus) if e.visible then resumeGame() else pauseGame() end end) ``` #### MouseFocus Triggered when the window gains or loses mouse focus. *See [love.mousefocus](https://love2d.org/wiki/love.mousefocus)* | Property | Type | Description | | ---------- | ----------- | ------------------------------------------ | | `focus` | `boolean` | `true` if the window has mouse focus | ```teal world:observe(0, tecs2d.MouseFocus, function(e: tecs2d.MouseFocus) if not e.focus then stopDragging() end end) ``` #### Resize Triggered when the window is resized. *See [love.resize](https://love2d.org/wiki/love.resize)* | Property | Type | Description | | ---------- | ---------- | -------------------------- | | `width` | `number` | New width of the window | | `height` | `number` | New height of the window | ```teal world:observe(0, tecs2d.Resize, function(e: tecs2d.Resize) camera:updateViewport(e.width, e.height) end) ``` #### Visible Triggered when the window is shown or hidden. *See [love.visible](https://love2d.org/wiki/love.visible)* | Property | Type | Description | | ----------- | ----------- | ------------------------------------------ | | `visible` | `boolean` | `true` if the window is visible | ```teal world:observe(0, tecs2d.Visible, function(e: tecs2d.Visible) if not e.visible then audio:pauseMusic() else audio:resumeMusic() end end) ``` #### Exposed Triggered when the window is exposed and needs to be redrawn. *See [love.exposed](https://love2d.org/wiki/love.exposed)* No properties. #### Occluded Triggered when the window is fully occluded by another window. *See [love.occluded](https://love2d.org/wiki/love.occluded)* No properties. ### Keyboard events #### KeyPressed Triggered when a key is pressed. *See [love.keypressed](https://love2d.org/wiki/love.keypressed)* | Property | Type | Description | | ------------ | ----------------------------- | -------------------------------------------------------------------------- | | `key` | `love.keyboard.KeyConstant` | The key that was pressed (e.g., `"space"`, `"a"`) | | `scancode` | `love.keyboard.Scancode` | Hardware scancode for the key | | `isrepeat` | `boolean` | Whether this is a repeat event. Delay depends on user's system settings. | ```teal world:observe(0, tecs2d.KeyPressed, function(e: tecs2d.KeyPressed) if e.key == "escape" then pauseGame() end end) ``` #### KeyReleased Triggered when a key is released. *See [love.keyreleased](https://love2d.org/wiki/love.keyreleased)* | Property | Type | Description | | ------------ | ---------------------------- | ------------------------------------------------ | | `key` | `love.keyboard.KeyConstant` | The key that was released | | `scancode` | `love.keyboard.Scancode` | Hardware scancode for the key | ```teal world:observe(0, tecs2d.KeyReleased, function(e: tecs2d.KeyReleased) if e.key == "space" then endChargeAttack() end end) ``` ### Mouse events #### MousePressed Triggered when a mouse button is pressed. *See [love.mousepressed](https://love2d.org/wiki/love.mousepressed)* | Property | Type | Description | | ----------- | ----------- | ------------------------------------------------------- | | `x` | `number` | Mouse x position in pixels | | `y` | `number` | Mouse y position in pixels | | `button` | `number` | Button index (1 = left, 2 = right, 3 = middle) | | `istouch` | `boolean` | `true` if from a touchscreen | | `presses` | `number` | Number of clicks for double/triple-click detection | ```teal world:observe(0, tecs2d.MousePressed, function(e: tecs2d.MousePressed) if e.button == 1 and e.presses == 2 then handleDoubleClick(e.x, e.y) end end) ``` #### MouseReleased Triggered when a mouse button is released. *See [love.mousereleased](https://love2d.org/wiki/love.mousereleased)* | Property | Type | Description | | ----------- | ----------- | ------------------------------------------------------- | | `x` | `number` | Mouse x position in pixels | | `y` | `number` | Mouse y position in pixels | | `button` | `number` | Button index (1 = left, 2 = right, 3 = middle) | | `istouch` | `boolean` | `true` if from a touchscreen | | `presses` | `number` | Number of clicks | ```teal world:observe(0, tecs2d.MouseReleased, function(e: tecs2d.MouseReleased) if e.button == 1 then endDragOperation(e.x, e.y) end end) ``` ### Joystick events #### JoystickAdded Triggered when a joystick/gamepad is connected. *See [love.joystickadded](https://love2d.org/wiki/love.joystickadded)* | Property | Type | Description | | ------------ | ---------------------------- | ------------------------------ | | `joystick` | `love.joystick.Joystick` | The newly connected joystick | ```teal world:observe(0, tecs2d.JoystickAdded, function(e: tecs2d.JoystickAdded) if e.joystick:isGamepad() then setupGamepadMappings(e.joystick) end end) ``` #### JoystickRemoved Triggered when a joystick/gamepad is disconnected. *See [love.joystickremoved](https://love2d.org/wiki/love.joystickremoved)* | Property | Type | Description | | ------------ | ---------------------------- | --------------------------------- | | `joystick` | `love.joystick.Joystick` | The now-disconnected joystick | ```teal world:observe(0, tecs2d.JoystickRemoved, function(e: tecs2d.JoystickRemoved) if love.joystick.getJoystickCount() == 0 then switchToKeyboardControls() end end) ``` ### File events #### DirectoryDropped Triggered when a directory is dragged and dropped onto the window. *See [love.directorydropped](https://love2d.org/wiki/love.directorydropped)* | Property | Type | Description | | ---------- | ---------- | ------------------------------------------ | | `path` | `string` | Full platform-dependent directory path | | `x` | `number` | X position where the directory was dropped | | `y` | `number` | Y position where the directory was dropped | ```teal world:observe(0, tecs2d.DirectoryDropped, function(e: tecs2d.DirectoryDropped) love.filesystem.mount(e.path, "dropped") loadImagesFromDirectory("dropped") end) ``` #### FileDropped Triggered when a file is dragged and dropped onto the window. *See [love.filedropped](https://love2d.org/wiki/love.filedropped)* | Property | Type | Description | | ---------- | ------------------------------- | -------------------------------------- | | `file` | `love.filesystem.DroppedFile` | The unopened file that was dropped | | `x` | `number` | X position where the file was dropped | | `y` | `number` | Y position where the file was dropped | ```teal world:observe(0, tecs2d.FileDropped, function(e: tecs2d.FileDropped) local filename = e.file:getFilename() if filename:match("%.png$") or filename:match("%.jpg$") then loadDroppedImage(e.file) end end) ``` ### Application events #### Quit Triggered when the application is about to close. *See [love.quit](https://love2d.org/wiki/love.quit)* | Property | Type | Description | | -------------- | ----------- | ------------------ | | `exitstatus` | `integer` | The exit code | ```teal world:observe(0, tecs2d.Quit, function(e: tecs2d.Quit) saveGameState() cleanupResources() end) ``` #### LocaleChanged Triggered when the system locale changes. *See [love.localechanged](https://love2d.org/wiki/love.localechanged)* No properties. #### ThemeChanged Triggered when the system theme changes. *See [love.themechanged](https://love2d.org/wiki/love.themechanged)* | Property | Type | Description | | ---------- | ---------- | ------------------------------------- | | `theme` | `string` | The new theme (`"light"` or `"dark"`) | ```teal world:observe(0, tecs2d.ThemeChanged, function(e: tecs2d.ThemeChanged) updateUITheme(e.theme) end) ``` #### AudioDisconnected Triggered when the audio device is disconnected. *See [love.audiodisconnected](https://love2d.org/wiki/love.audiodisconnected)* No properties. ### Touch events #### TouchPressed Triggered when a touch press is detected. *See [love.touchpressed](https://love2d.org/wiki/love.touchpressed)* | Property | Type | Description | | -------------- | ------------------------------- | ------------------------------------- | | `id` | `any` | Identifier for the touch press | | `x` | `number` | X position of the touch | | `y` | `number` | Y position of the touch | | `pressure` | `number` | Pressure being applied (0-1) | | `deviceType` | `love.touch.TouchDeviceType` | Type of touchscreen or touchpad | | `isMouse` | `boolean` | `true` if from mouse emulation | ```teal world:observe(0, tecs2d.TouchPressed, function(e: tecs2d.TouchPressed) handleTouch(e.id, e.x, e.y) end) ``` #### TouchMoved Triggered when a touch point moves. *See [love.touchmoved](https://love2d.org/wiki/love.touchmoved)* | Property | Type | Description | | -------------- | ------------------------------- | ------------------------------------- | | `id` | `any` | Identifier for the touch press | | `x` | `number` | X position of the touch | | `y` | `number` | Y position of the touch | | `dx` | `number` | X component of movement delta | | `dy` | `number` | Y component of movement delta | | `pressure` | `number` | Pressure being applied (0-1) | | `deviceType` | `love.touch.TouchDeviceType` | Type of touchscreen or touchpad | | `isMouse` | `boolean` | `true` if from mouse emulation | ```teal world:observe(0, tecs2d.TouchMoved, function(e: tecs2d.TouchMoved) handleTouchDrag(e.id, e.dx, e.dy) end) ``` #### TouchReleased Triggered when a touch point is released. *See [love.touchreleased](https://love2d.org/wiki/love.touchreleased)* | Property | Type | Description | | -------------- | ------------------------------- | ------------------------------------- | | `id` | `any` | Identifier for the touch press | | `x` | `number` | X position of the touch | | `y` | `number` | Y position of the touch | | `pressure` | `number` | Pressure being applied (0-1) | | `deviceType` | `love.touch.TouchDeviceType` | Type of touchscreen or touchpad | | `isMouse` | `boolean` | `true` if from mouse emulation | ```teal world:observe(0, tecs2d.TouchReleased, function(e: tecs2d.TouchReleased) handleTouchRelease(e.id, e.x, e.y) end) ``` ### Sensor events #### SensorUpdated Triggered when a device sensor is updated. *See [love.sensorupdated](https://love2d.org/wiki/love.sensorupdated)* | Property | Type | Description | | -------------- | ---------------------------- | --------------------------------------------------- | | `sensorType` | `love.sensor.SensorType` | Type of sensor (`"accelerometer"` or `"gyroscope"`) | | `x` | `number` | X component of sensor data | | `y` | `number` | Y component of sensor data | | `z` | `number` | Z component of sensor data | ```teal world:observe(0, tecs2d.SensorUpdated, function(e: tecs2d.SensorUpdated) handleSensor(e.sensorType, e.x, e.y, e.z) end) ``` #### JoystickSensorUpdated Triggered when a joystick sensor is updated. *See [love.joysticksensorupdated](https://love2d.org/wiki/love.joysticksensorupdated)* | Property | Type | Description | | -------------- | ------------------------------- | --------------------------------------------------- | | `joystick` | `love.joystick.Joystick` | The joystick with the sensor | | `sensorType` | `love.joystick.SensorType` | Type of sensor (`"accelerometer"` or `"gyroscope"`) | | `x` | `number` | X component of sensor data | | `y` | `number` | Y component of sensor data | | `z` | `number` | Z component of sensor data | ```teal world:observe(0, tecs2d.JoystickSensorUpdated, function(e: tecs2d.JoystickSensorUpdated) handleJoystickSensor(e.joystick, e.sensorType, e.x, e.y, e.z) end) ``` ### Drag-and-drop lifecycle events #### DropBegan Triggered when a drag operation begins over the window. | Property | Type | Description | | ---------- | ---------- | -------------------------------- | | `x` | `number` | X position where drag started | | `y` | `number` | Y position where drag started | ```teal world:observe(0, tecs2d.DropBegan, function(e: tecs2d.DropBegan) showDropZone() end) ``` #### DropMoved Triggered when a drag operation moves over the window. | Property | Type | Description | | ---------- | ---------- | -------------------------- | | `x` | `number` | Current drag x position | | `y` | `number` | Current drag y position | ```teal world:observe(0, tecs2d.DropMoved, function(e: tecs2d.DropMoved) updateDropHighlight(e.x, e.y) end) ``` #### DropCompleted Triggered when a drag operation is completed over the window. | Property | Type | Description | | ---------- | ---------- | -------------------------------------- | | `x` | `number` | X position where drop completed | | `y` | `number` | Y position where drop completed | ```teal world:observe(0, tecs2d.DropCompleted, function(e: tecs2d.DropCompleted) hideDropZone() end) ``` --- --- url: /tecs2d/input.md --- # Input Handling The input module provides low-level access to keyboard, mouse, and gamepad state. Use this for direct device queries like "is spacebar down?" or "where is the mouse?". For rebindable game controls (jump, attack, move), see [Controller](/tecs2d/input/controller/). ## Getting Started The input module is globally available and automatically managed by Tecs: ```teal local tecs = require("tecs") local tecs2d = require("tecs2d") local input = require("tecs2d.input") love.run = tecs2d.run({ fps = 60, game = function(world) -- Use input directly in your systems if input.isKeyPressed("space") then -- Handle input end end }) ``` ## Keyboard input Check keyboard state using the input module: ```teal local tecs2d = require("tecs2d") local input = require("tecs2d.input") -- Check if a key is currently down (pressed or held) if input.isKeyDown("space") then player:jump() end -- Check if a key was just pressed this frame if input.isKeyPressed("escape") then pauseGame() end -- Check if a key was just released this frame if input.isKeyReleased("e") then interact() end ``` *See [love.keypressed](https://love2d.org/wiki/love.keypressed) and [love.keyreleased](https://love2d.org/wiki/love.keyreleased) for keyboard events* ### Text input For text entry (chat boxes, name fields, etc.), use the `textInput` field which captures actual typed characters with proper keyboard layout handling: ```teal local tecs2d = require("tecs2d") local input = require("tecs2d.input") -- Get text typed this frame local text = input.textInput if text ~= "" then chatBox:appendText(text) end -- Example text input field system world:addSystem({ phase = tecs.phases.Update, run = function() if activeTextField then -- Append typed text activeTextField.text = activeTextField.text .. input.textInput -- Handle backspace if input.isKeyPressed("backspace") then activeTextField.text = activeTextField.text:sub(1, -2) end end end }) ``` *See [love.textinput](https://love2d.org/wiki/love.textinput) for more about text input handling* ## Mouse input You can get the current mouse X and Y position using `input`: ```teal local tecs2d = require("tecs2d") local input = require("tecs2d.input") local x, y = input.mouseX, input.mouseY ``` You can check if the mouse wheel was moved using `getMouseWheelMovement()`: ```teal local dx, dy = input.getMouseWheelMovement() if dy ~= 0 then zoom = zoom + dy * 0.1 end ``` Or access the wheel movement array directly: ```teal local wheelX = input.mouseWheelMoved[1] local wheelY = input.mouseWheelMoved[2] ``` You can check mouse button states: ```teal if input.isMouseDown(1) then -- Button held end if input.isMousePressed(1) then -- Just pressed end if input.isMouseReleased(1) then -- Just released end ``` ### Mouse button reference * **1**: Primary button (usually left) * **2**: Secondary button (usually right) * **3**: Middle button (wheel click) * **4+**: Additional buttons (mouse-dependent) *See [love.mousepressed](https://love2d.org/wiki/love.mousepressed) for more about mouse buttons* ## Gamepad and joystick input Handle gamepad and joystick input for each connected device: ```teal local tecs2d = require("tecs2d") local input = require("tecs2d.input") -- Iterate through connected joysticks for joystick, joystickInput in pairs(input.joysticks) do -- Check gamepad buttons (using standard names) if joystickInput:isGamepadButtonPressed("a") then player:jump() end if joystickInput:isGamepadButtonDown("x") then player:attack() end -- Read analog stick values local leftStickX = joystickInput.gamepadAxis["leftx"] or 0 local leftStickY = joystickInput.gamepadAxis["lefty"] or 0 player:move(leftStickX, leftStickY) -- Check joystick buttons by number if joystickInput:isJoystickButtonPressed(1) then handleButtonPress(1) end -- Read joystick hat positions local hatDirection = joystickInput.joystickHat[1] if hatDirection == "u" then navigateMenu("up") end end ``` *See [love.gamepadpressed](https://love2d.org/wiki/love.gamepadpressed) for gamepad buttons and [love.joystickpressed](https://love2d.org/wiki/love.joystickpressed) for joystick buttons* ### Reacting to joystick events You can also react to joystick connection/disconnection events: ```teal local tecs2d = require("tecs2d") world:addSystem({ phase = tecs.phases.Startup, run = function() world:observe(0, tecs2d.JoystickAdded, function(e: tecs2d.JoystickAdded) local name = e.joystick:getName() print("Controller connected: " .. name) end) world:observe(0, tecs2d.JoystickRemoved, function(e: tecs2d.JoystickRemoved) print("Controller disconnected") end) end }) ``` ## Latch-based input Tecs takes a different approach to input than most Love2D libraries. The goal is to make input reliable and simple in both variable-rate Update and fixed-rate FixedUpdate phases. ### How Love2D usually does it * Love2D polls input once per render frame * Libraries like Baton give you helpers like `pressed`, `released`, `down`, but these values are *frame-scoped* * That works fine if your gameplay only runs once per frame in `love.update` ::: warning Problem: Fixed phases If you run multiple fixed steps per frame, or sometimes zero (at very high FPS), quick taps can get lost or duplicated. ::: ### Tecs's model Tecs separates how input is captured from how it's consumed: * Events are polled once per frame in the main loop before world:update() * In **FixedUpdate**, input "edges" (like `isKeyPressed` and `isKeyReleased`) are *latched*: * They report their value since the last FixedUpdate tick * If a key was pressed and released between FixedUpdate ticks, the key is both pressed and released * Queries like `isKeyDown` always reflect the most up to date state from Love2D. * Latches are cleared after each FixedUpdate tick. So only the first FixedUpdate tick sees the latched state. So no matter what phase you handle input in, whether it's Update or FixedUpdate, "down", "released", and "pressed" states will return fresh values you'd expect. ## Input events While `isKeyPressed` and `isKeyReleased` use a latching model to ensure inputs are never dropped, they do not preserve the exact sequence or timing of multiple inputs within a single render frame. If your game requires frame-perfect combos or input sequences (e.g., a fighting game), you should implement a custom input buffer by consuming the raw event stream provided by the [Tecs event system](/tecs2d/events). ```teal local tecs2d = require("tecs2d") world:observe(0, tecs2d.KeyPressed, function(e: tecs2d.KeyPressed) addToComboBuffer(e.key, love.timer.getTime()) end) ``` --- --- url: /tecs2d/input/controller.md --- # Tecs Controller Tecs Controller provides rebindable controls for Tecs games. It builds on top of Tecs's event-based [input handling system](/tecs2d/input/) to add a layer of configurable controller mappings. ::: tip Controller == logical input Tecs Input handles the *physical* inputs efficiently, while Controller manages the *logical* mapping of those inputs to game actions like "jump" and "attack". ::: ## Quick Start ```teal local tecs = require("tecs") local tecs2d = require("tecs2d") local controller = require("tecs2d.controller") -- In your game plugin (passed to tecs2d.run) local function gamePlugin(world: tecs.World) -- Get the control manager from world resources (auto-added by tecs2d) local controlManager = world.resources[controller] -- Define control bindings local bindings = { controls = { jump = {"key:space", "button:a"}, attack = {"key:z", "mouse:1", "button:x"}, menu = {"key:escape", "button:start"}, -- Direction controls for the movement pair left = {"key:a", "key:left", "axis:leftx-"}, right = {"key:d", "key:right", "axis:leftx+"}, up = {"key:w", "key:up", "axis:lefty-"}, down = {"key:s", "key:down", "axis:lefty+"} }, pairs = { move = {"left", "right", "up", "down"} } } -- Add a controller with auto-assignment enabled local player1 = controlManager:addController(bindings, {auto = true}) -- Use controller state in a system world:addSystem({ name = "PlayerInput", phase = tecs.phases.FixedUpdate, run = function() if player1:isPressed("jump") then -- Player just pressed jump end if player1:isDown("attack") then -- Player is holding attack end local moveX, moveY = player1:getPair("move") -- moveX is -1 (left), 0 (none), or 1 (right) -- moveY is -1 (up), 0 (none), or 1 (down) end }) end ``` ## Multiple Players ```teal -- Define different bindings for each player local player1Bindings = { controls = { jump = {"key:w", "button:a"}, attack = {"key:space", "button:x"} } } local player2Bindings = { controls = { jump = {"key:up", "button:a"}, attack = {"key:rctrl", "button:x"} } } -- Add controllers with auto mode for multiplayer local player1 = controlManager:addController(player1Bindings, { auto = true, deadzone = 0.25 }) local player2 = controlManager:addController(player2Bindings, { auto = true, deadzone = 0.25 }) ``` ## Custom Dead Zones Dead zones prevent analog stick drift from registering as input: ```teal -- Create controller with custom dead zone (0.0 to 1.0) local player = controlManager:addController(bindings, { auto = true, deadzone = 0.3 -- 30% dead zone }) ``` ## Custom Control Manager If you need a custom control manager, you can override the auto-created one by setting the resource key directly: ```teal -- Create custom control manager local customManager = controller.newManager() -- Configure the manager as needed customManager:addController(player1Bindings, {auto = true}) customManager:addController(player2Bindings, {auto = true}) -- Override the auto-created manager world.resources[controller] = customManager ``` --- --- url: /tecs2d/input/controller/bindings.md --- # Bindings Bindings are defined as strings in the format `source:value`. Each control can have multiple bindings, allowing the same action to be triggered by different inputs. ::: info Based on baton The binding system is based on [baton.lua](https://github.com/tesselode/baton) by Andrew Minnich, with the following differences: * Scancode inputs are not supported: scancodes exist for layout-independent physical key positions, but since controls are rebindable, users can simply rebind to the desired key * Raw axis support (axes without + or - suffix return full -1 to 1 range) ::: ## Keyboard Bindings Use Love2D [KeyConstants](https://love2d.org/wiki/KeyConstant). For example: | Binding | Description | | ---------------- | ------------------ | | `"key:space"` | Spacebar | | `"key:w"` | W key | | `"key:escape"` | Escape key | | `"key:lshift"` | Left shift key | | `"key:return"` | Enter/Return key | ## Mouse Bindings Use mouse button numbers. For example: | Binding | Description | | ------------- | ---------------------- | | `"mouse:1"` | Left mouse button | | `"mouse:2"` | Right mouse button | | `"mouse:3"` | Middle mouse button | | `"mouse:4"` | Extra mouse button 1 | | `"mouse:5"` | Extra mouse button 2 | ## Gamepad Button Bindings Use Love2D [GamepadButton](https://love2d.org/wiki/GamepadButton) constants. For example: | Binding | Description | | --------------------------- | -------------------------------------- | | `"button:a"` | A button (Cross on PlayStation) | | `"button:b"` | B button (Circle on PlayStation) | | `"button:x"` | X button (Square on PlayStation) | | `"button:y"` | Y button (Triangle on PlayStation) | | `"button:start"` | Start button | | `"button:back"` | Back/Select button | | `"button:guide"` | Guide/Home button | | `"button:leftshoulder"` | Left shoulder button (L1/LB) | | `"button:rightshoulder"` | Right shoulder button (R1/RB) | | `"button:leftstick"` | Left stick click (L3) | | `"button:rightstick"` | Right stick click (R3) | | `"button:dpup"` | D-pad up | | `"button:dpdown"` | D-pad down | | `"button:dpleft"` | D-pad left | | `"button:dpright"` | D-pad right | ## Gamepad Axis Bindings Use Love2D [GamepadAxis](https://love2d.org/wiki/GamepadAxis) constants. Axes can be used in two ways: ### Directional Axes (with + or - suffix) For digital-style input from analog axes (returns 0 to 1): | Binding | Description | | ------------------------- | ------------------------ | | `"axis:leftx+"` | Left stick right | | `"axis:leftx-"` | Left stick left | | `"axis:lefty+"` | Left stick down | | `"axis:lefty-"` | Left stick up | | `"axis:rightx+"` | Right stick right | | `"axis:rightx-"` | Right stick left | | `"axis:righty+"` | Right stick down | | `"axis:righty-"` | Right stick up | | `"axis:triggerleft+"` | Left trigger (L2/LT) | | `"axis:triggerright+"` | Right trigger (R2/RT) | ### Raw Axes (no suffix) For full analog range (returns -1 to 1): | Binding | Description | | ------------------------ | -------------------------------------------------- | | `"axis:leftx"` | Left stick horizontal (-1 = left, 1 = right) | | `"axis:lefty"` | Left stick vertical (-1 = up, 1 = down) | | `"axis:rightx"` | Right stick horizontal (-1 = left, 1 = right) | | `"axis:righty"` | Right stick vertical (-1 = up, 1 = down) | | `"axis:triggerleft"` | Left trigger (0 to 1 typically) | | `"axis:triggerright"` | Right trigger (0 to 1 typically) | ## Joystick Hat Bindings For joystick hats, use the hat index followed by direction: | Binding | Description | | ------------- | ---------------------------- | | `"hat:1u"` | Hat 1 up | | `"hat:1d"` | Hat 1 down | | `"hat:1l"` | Hat 1 left | | `"hat:1r"` | Hat 1 right | | `"hat:1c"` | Hat 1 center (released) | ## Button Pairs for Movement Button pairs convert digital inputs (like keyboard keys or d-pad buttons) into analog-style movement values. They make it easier to implement directional movement. A movement pair combines four directional controls into X/Y coordinates: ```teal local bindings = { controls = { left = {"key:a", "key:left"}, right = {"key:d", "key:right"}, up = {"key:w", "key:up"}, down = {"key:s", "key:down"} }, pairs = { move = {"left", "right", "up", "down"} } } -- Get normalized movement direction local moveX, moveY = controller:getPair("move") -- moveX: -1 (left), 0 (none), or 1 (right) -- moveY: -1 (up), 0 (none), or 1 (down) ``` ### Pair Format Button pairs must be defined with exactly 4 controls in a specific order: ```teal pairs = { pairName = { "left", -- Negative X direction "right", -- Positive X direction "up", -- Negative Y direction "down" -- Positive Y direction } } ``` When you call `getPair()`, it returns: * **X value**: -1 if left is pressed, +1 if right is pressed, 0 if neither/both * **Y value**: -1 if up is pressed, +1 if down is pressed, 0 if neither/both ### Using Pairs for Player Movement ```teal local playerQuery = world:query({include = {Player, Velocity}}) world:addSystem({ phase = tecs.phases.FixedUpdate, run = function(dt: number) for arch, len in playerQuery:iter() do local players = arch:get(Player) local velocities = arch:getMut(Velocity) for row = 1, len do local player = players[row] local velocity = velocities[row] -- Get movement from button pair, normalized for diagonal movement. local moveX, moveY = player.controller:getPairNormalized("move") velocity.x = moveX * player.speed velocity.y = moveY * player.speed end end end }) ``` ::: info Diagonal movement `getPair` returns raw axis values, which means diagonal input (e.g. W+D) will produce a magnitude of ~1.41 instead of 1.0, causing faster diagonal movement. Use `getPairNormalized` when applying input to velocity or any speed-sensitive calculation. ::: ### Multiple Pairs for Different Actions You can define multiple pairs for different types of movement: ```teal local bindings = { controls = { -- Movement controls left = {"key:a"}, right = {"key:d"}, up = {"key:w"}, down = {"key:s"}, -- Camera controls camLeft = {"key:left"}, camRight = {"key:right"}, camUp = {"key:up"}, camDown = {"key:down"}, }, pairs = { move = {"left", "right", "up", "down"}, camera = {"camLeft", "camRight", "camUp", "camDown"}, } } -- In game local moveX, moveY = controller:getPair("move") local camX, camY = controller:getPair("camera") ``` ## Raw Values Get the raw analog value of a control (useful for triggers and sticks): ```teal -- For buttons and keys: returns 0 or 1 local jumpValue = controller:getRaw("jump") -- For directional axes (with + or -): returns 0 to 1 local throttle = controller:getRaw("accelerate") -- axis:triggerright+ -- For raw axes (no suffix): returns -1 to 1 local aimX = controller:getRaw("aimHorizontal") -- axis:rightx ``` ## Analog Stick Movement For smooth analog movement without button pairs, you can directly bind analog axes: ```teal local bindings = { controls = { -- Raw analog stick bindings (full -1 to 1 range) moveX = {"axis:leftx"}, -- Full horizontal axis moveY = {"axis:lefty"}, -- Full vertical axis aimX = {"axis:rightx"}, -- Right stick horizontal aimY = {"axis:righty"}, -- Right stick vertical -- Or use directional bindings (0 to 1 range) moveLeft = {"axis:leftx-"}, moveRight = {"axis:leftx+"}, moveUp = {"axis:lefty-"}, moveDown = {"axis:lefty+"} } } -- Get smooth analog movement with raw axes local moveX = controller:getRaw("moveX") -- -1 to 1 local moveY = controller:getRaw("moveY") -- -1 to 1 -- Get aim direction for twin-stick shooter local aimX = controller:getRaw("aimX") -- -1 to 1 local aimY = controller:getRaw("aimY") -- -1 to 1 if math.abs(aimX) > 0.1 or math.abs(aimY) > 0.1 then player.aimAngle = math.atan2(aimY, aimX) end ``` You can also combine analog sticks with keyboard fallback: ```teal local bindings = { controls = { -- Keyboard and analog stick support left = {"key:a", "axis:leftx-"}, right = {"key:d", "axis:leftx+"}, up = {"key:w", "axis:lefty-"}, down = {"key:s", "axis:lefty+"} }, pairs = { move = {"left", "right", "up", "down"} } } -- Works with both keyboard (digital) and gamepad (analog) local moveX, moveY = controller:getPair("move") ``` --- --- url: /tecs2d/input/controller/gamepad.md --- # Gamepad Support Controller provides comprehensive gamepad support with automatic input mapping and hot-plugging capabilities. ## Creating Controllers with Gamepads Controllers can be created with different auto-assignment modes: ```teal local bindings = { controls = { jump = {"key:space", "button:a"}, attack = {"key:x", "button:x"}, -- Raw axes for full analog control moveX = {"axis:leftx"}, -- Returns -1 to 1 moveY = {"axis:lefty"}, -- Returns -1 to 1 aimX = {"axis:rightx"}, -- Returns -1 to 1 aimY = {"axis:righty"} -- Returns -1 to 1 } } -- Add a controller with no gamepad local controller1 = controlManager:addController(bindings) -- Add a controller that auto-assigns a gamepad local controller2 = controlManager:addController(bindings, { auto = true, deadzone = 0.25 }) ``` Manually assigning a specific gamepad: ```teal local controller4 = controlManager:addController(bindings, { -- Assign the first connected gamepad joystick = love.joystick.getJoysticks()[1], deadzone = 0.25 }) ``` ## Auto-Assignment Controller provides automatic gamepad assignment to simplify setup: ### When auto=false (default) * No automatic gamepad assignment * Must manually assign gamepads ### When auto=true * Automatically assigns first available gamepad * Prioritizes controllers with activity * Reassigns to any available gamepad when disconnected ```teal -- Player 1 gets first available gamepad (or active one if detected) local player1 = controlManager:addController(bindings, {auto = true}) -- Player 2 gets next available gamepad local player2 = controlManager:addController(bindings, {auto = true}) ``` ## Switching Controllers The `resetJoystick()` method allows changing controller assignments at runtime: ```teal -- Enable auto-assignment controller:resetJoystick({auto = true}) -- Disable auto-assignment controller:resetJoystick({auto = false}) -- Switch to a specific gamepad controller:resetJoystick({ joystick = joysticks[2], deadzone = 0.3 }) -- Disconnect gamepad (keyboard-only mode) controller:resetJoystick(nil) ``` ## Programmatic Joystick Assignment You can directly set a controller's joystick using the `setJoystick` method: ```teal -- Assign a specific joystick local joystick = love.joystick.getJoysticks()[1] controller:setJoystick(joystick) -- Clear the joystick controller:setJoystick(nil) ``` The `setJoystick` method automatically triggers rumble feedback when a joystick is connected to help players identify their controller. ## Controller Joystick Changes You can monitor when a controller's joystick changes by setting an `onJoystickChanged` callback: ```teal -- Set a callback to be notified of joystick changes controller.onJoystickChanged = function( ctrl: controller.Controller, newJoystick: love.joystick.Joystick, oldJoystick: love.joystick.Joystick ) if newJoystick then print(string.format("Controller connected: %s", newJoystick:getName())) else print("Controller disconnected") end -- Update UI to show controller status updateControllerIcon(ctrl, newJoystick) end ``` This callback is triggered whenever: * A joystick is assigned (manually or automatically) * A joystick is disconnected * The controller switches to a different joystick ## Vibration/Rumble ```teal -- Add rumble feedback on hit if controller:isPressed("attack") and enemy:wasHit() then local joystick = controller.joystick if joystick then -- Vibrate for 0.2 seconds at 50% strength joystick:setVibration(0.5, 0.5, 0.2) end end ``` ## Analog Triggers ```teal local bindings = { controls = { accelerate = {"key:up", "axis:triggerright+"}, brake = {"key:down", "axis:triggerleft+"} } } -- Get analog trigger values (0 to 1) local acceleration = controller:getRaw("accelerate") local braking = controller:getRaw("brake") -- Apply to vehicle vehicle.throttle = acceleration vehicle.brakeForce = braking * vehicle.maxBrakeForce ``` --- --- url: /tecs2d/input/controller/api.md --- # API Reference ## JoystickConfig The `JoystickConfig` type defines joystick assignment options: ```teal type JoystickConfig = { joystick?: love.joystick.Joystick, -- Specific gamepad to use auto?: boolean, -- Enable auto-assignment (default: false) deadzone?: number -- Axis deadzone threshold 0-1 (default: 0.5) } ``` **Fields:** * `joystick`: A specific Love2D joystick/gamepad to assign to the controller * `auto`: Enable automatic gamepad assignment (default: false) * When `true`: Auto-assigns available gamepads, prioritizes controllers with activity * When `false`: Manual assignment only * `deadzone`: Minimum axis value to register as input (prevents stick drift) ## Bindings The `Bindings` type defines the structure for control mappings: ```teal type Bindings = { controls: {string: {string}}, -- Map of control names to binding arrays pairs?: {string: {string}} -- Optional map of pair names to 4 control names } ``` **Fields:** * `controls`: A table mapping control names (strings) to arrays of binding strings * Keys are control names like "jump", "attack", "moveLeft" * Values are arrays of binding strings like `{"key:space", "button:a"}` * `pairs` (optional): A table mapping pair names to exactly 4 control names * Keys are pair names like "move", "aim" * Values must be arrays with exactly 4 control names: `{left, right, up, down}` **Example:** ```teal local bindings: Bindings = { controls = { jump = {"key:space", "button:a"}, attack = {"key:z", "mouse:1"}, left = {"key:a"}, right = {"key:d"}, up = {"key:w"}, down = {"key:s"} }, pairs = { move = {"left", "right", "up", "down"} } } ``` ## ControlManager The `ControlManager` manages multiple controllers for different players in your game. ### newManager Creates a new control manager. ```teal function controller.newManager(): ControlManager ``` **Returns:** * A new `ControlManager` instance. ::: tip A ControlManager is created automatically You typically don't need to call this. A ControlManager is created and registered automatically when Tecs registers with Love2D. ::: ### addController Adds a new controller with the specified bindings. ```teal function ControlManager:addController( bindings: Bindings, config?: JoystickConfig ): Controller ``` * `bindings`: Table containing control mappings and button pairs. * `config`: Optional joystick configuration: * `joystick`: Specific gamepad to use * `auto`: Enable auto-assignment (true/false, default: false) * `deadzone`: Dead zone threshold from 0 to 1 (default: 0.5) **Returns:** * The newly created `Controller` instance. **Example:** ```teal local bindings = { controls = { jump = {"key:space", "button:a"}, attack = {"key:z", "button:x"}, left = {"key:a", "axis:leftx-"}, right = {"key:d", "axis:leftx+"}, up = {"key:w", "axis:lefty-"}, down = {"key:s", "axis:lefty+"} }, pairs = { move = {"left", "right", "up", "down"} } } -- Manual mode (default) local controller1 = controlManager:addController(bindings) -- Auto-assignment enabled local controller2 = controlManager:addController(bindings, { auto = true, deadzone = 0.25 }) -- Specific joystick assignment local joystick = love.joystick.getJoysticks()[1] local controller3 = controlManager:addController(bindings, { joystick = joystick, deadzone = 0.25 }) ``` ### removeController Removes a controller from the manager. ```teal function ControlManager:removeController(controller: Controller) ``` * `controller`: The controller instance to remove. **Example:** ```teal controlManager:removeController(player2Controller) ``` ### get Gets a controller by its index. ```teal function ControlManager:get(index: integer): Controller ``` * `index`: The 1-based index of the controller. **Returns:** * The controller at the specified index, or nil if not found. **Example:** ```teal local player1 = controlManager:get(1) local player2 = controlManager:get(2) ``` ## Controller The `Controller` represents a single player's input device with their control bindings. ### isPressed Checks if a button was just pressed this frame. ```teal function Controller:isPressed(button: string): boolean ``` * `button`: The name of the button to check. **Returns:** * `true` if the button was just pressed, `false` otherwise. **Notes:** * Only returns true on the frame the button is first pressed. * Will not return true while the button is held down. **Example:** ```teal if controller:isPressed("jump") then player:startJump() end ``` ### isDown Checks if a button is currently being held down. ```teal function Controller:isDown(button: string): boolean ``` * `button`: The name of the button to check. **Returns:** * `true` if the button is currently down, `false` otherwise. **Notes:** * Returns true for every frame the button is held. * Includes the initial press frame. **Example:** ```teal if controller:isDown("sprint") then player.speed = player.runSpeed end ``` ### isReleased Checks if a button was just released this frame. ```teal function Controller:isReleased(button: string): boolean ``` * `button`: The name of the button to check. **Returns:** * `true` if the button was just released, `false` otherwise. **Notes:** * Only returns true on the frame the button is released. **Example:** ```teal if controller:isReleased("charge") then player:releaseChargedAttack() end ``` ### getPair Gets the directional input from a button pair. ```teal function Controller:getPair(name: string): number, number ``` * `name`: The name of the button pair. **Returns:** * `x`: Horizontal direction (-1 for left, 0 for neutral, 1 for right). * `y`: Vertical direction (-1 for up, 0 for neutral, 1 for down). **Notes:** * Button pairs must be defined in the bindings with exactly 4 buttons: left, right, up, down. * Opposite directions cancel out (e.g., pressing both left and right returns 0). **Example:** ```teal local moveX, moveY = controller:getPair("move") velocity.x = moveX * player.speed velocity.y = moveY * player.speed ``` ### getPairNormalized Gets the directional input from a button pair, normalized to work correctly with diagonal movement. ```teal function Controller:getPairNormalized(name: string): number, number ``` * `name`: The name of the button pair. **Returns:** * `x`: Horizontal direction (-1 for left, 0 for neutral, 1 for right). * `y`: Vertical direction (-1 for up, 0 for neutral, 1 for down). ### getRaw Gets the raw numeric value of a control. ```teal function Controller:getRaw(button: string): number ``` * `button`: The name of the control to check. **Returns:** * For buttons, keys, and hats: 0 when not pressed, 1 when pressed * For directional axes (with + or -): 0 to 1 based on axis position * For raw axes (without suffix): -1 to 1 for the full axis range **Notes:** * Useful for analog controls like triggers or thumbsticks. * Values within the dead zone return 0. **Example:** ```teal local throttle = controller:getRaw("accelerate") car.acceleration = throttle * car.maxAcceleration ``` ### rebind Changes the controller's bindings at runtime. ```teal function Controller:rebind(bindings: Bindings) ``` * `bindings`: The new binding configuration to apply. **Notes:** * Useful for implementing control remapping in settings menus. * Preserves the controller's joystick and deadzone settings. * Clears all previous bindings before applying new ones. **Example:** ```teal -- In a settings menu: create new bindings with the changed control local function remapJumpKey(newKey: string) local newBindings = { controls = { jump = {"key:" .. newKey, "button:a"}, attack = player1Controller.bindings.controls.attack }, pairs = player1Controller.bindings.pairs } player1Controller:rebind(newBindings) end -- Complete rebinding local newBindings = { controls = { jump = {"key:w", "button:a"}, attack = {"key:q", "button:x"} } } player1Controller:rebind(newBindings) ``` ### notifyWithRumble Triggers rumble feedback on the controller's joystick. ```teal function Controller:notifyWithRumble(strength?: number, duration?: number) ``` * `strength`: Vibration strength from 0 to 1 (default: 0.5) * `duration`: Duration in seconds (default: 0.2) **Notes:** * No-op if no joystick is assigned or if vibration is not supported. **Example:** ```teal if controller:isPressed("attack") and enemy:wasHit() then controller:notifyWithRumble(0.7, 0.3) end ``` ### setJoystick Directly sets the joystick for this controller. ```teal function Controller:setJoystick(joystick?: love.joystick.Joystick) ``` * `joystick`: The joystick to assign, or `nil` to clear. **Notes:** * Automatically triggers rumble feedback when a joystick is assigned * Calls the `onJoystickChanged` callback if set * Does not change the auto-assignment setting **Example:** ```teal -- Assign a specific joystick local joystick = love.joystick.getJoysticks()[1] controller:setJoystick(joystick) -- Clear the joystick controller:setJoystick(nil) ``` ### resetJoystick Resets or changes the joystick assignment for the controller. ```teal function Controller:resetJoystick(config?: JoystickConfig) ``` * `config`: Optional joystick configuration: * `joystick`: Specific gamepad to use * `auto`: Enable auto-assignment (true/false) * `deadzone`: Dead zone threshold from 0 to 1 * `nil`: Disconnect gamepad and disable auto mode **Notes:** * Useful for switching controllers or changing modes at runtime * Can update deadzone without changing joystick **Example:** ```teal -- Enable auto-assignment controller:resetJoystick({auto = true}) -- Disable auto-assignment controller:resetJoystick({auto = false}) -- Switch to specific gamepad local joysticks = love.joystick.getJoysticks() controller:resetJoystick({ joystick = joysticks[2], deadzone = 0.3 }) -- Disconnect gamepad (keyboard only) controller:resetJoystick(nil) ``` ## Controller Properties ### bindings The current binding configuration. Can be read to inspect bindings or passed to `rebind()`. ```teal controller.bindings: Bindings ``` ### joystick The currently assigned joystick, or `nil` if no joystick is connected. ```teal controller.joystick: love.joystick.Joystick ``` ### deadzone The axis deadzone threshold (0 to 1). Values below this threshold are treated as 0. ```teal controller.deadzone: number -- default: 0.5 ``` ### auto Whether automatic gamepad assignment is enabled. ```teal controller.auto: boolean -- default: false ``` ### onJoystickChanged Optional callback function that's called when the controller's joystick changes. ```teal controller.onJoystickChanged: function( controller: Controller, newJoystick: love.joystick.Joystick, oldJoystick: love.joystick.Joystick ) ``` **Parameters:** * `controller`: The controller whose joystick changed * `newJoystick`: The new joystick (nil if disconnected) * `oldJoystick`: The previous joystick (nil if was disconnected) **Example:** ```teal controller.onJoystickChanged = function( ctrl: controller.Controller, newJoy: love.joystick.Joystick, oldJoy: love.joystick.Joystick ) if newJoy then print("Controller connected: " .. newJoy:getName()) updateControllerUI(ctrl, newJoy) else print("Controller disconnected") showKeyboardControlsUI(ctrl) end end ``` --- --- url: /tecs2d/rendering.md --- # Rendering Tecs provides a high-performance GPU-accelerated rendering pipeline designed for 2D games with advanced lighting and shadow effects. The rendering system is built on a deferred G-Buffer architecture that decouples geometry rendering from lighting calculations. ## Quick Start ```teal local tecs = require("tecs") local tecs2d = require("tecs2d") local gfx = require("tecs2d.gfx") love.run = tecs2d.run({ fps = 60, game = function(world) -- Spawn a simple shape world:spawn( tecs.builtins.Transform(100, 100), gfx.Circle(20), gfx.Color(1, 0.5, 0, 1) ) -- Spawn a sprite from Aseprite world:spawn( tecs.builtins.Transform(200, 100), gfx.Sprite.fromAseprite("assets/player.png", "idle") ) end, render = { virtualWidth = 320, virtualHeight = 180, pixelMode = true, } }) ``` ## Architecture Overview The rendering pipeline consists of several passes: 1. **G-Buffer Pass**: Renders all geometry (sprites, shapes, text) to multiple render targets storing albedo, normals, specular, and emission information. 2. **Shadow Mask Pass**: Renders occluder silhouettes to a shadow mask texture using compute shader culling. 3. **Lighting Pass**: Applies dynamic lighting using raymarched shadows and normal-based shading. 4. **Composite Pass**: Combines all passes into the final output. ## Key Features * **GPU Instancing**: Batches thousands of entities into single draw calls * **Compute Shader Culling**: Visibility testing runs entirely on the GPU * **Deferred Lighting**: Decouples scene complexity from lighting cost * **2.5D Lighting**: Height-based shading for pseudo-3D effects * **Dynamic Shadows**: Raymarched soft shadows with height-based occlusion * **Pixel-Perfect Rendering**: Opt-in retro mode for integer-scaled pixel art * **Multiple Cameras**: Minimaps, split-screen, and render-to-texture via independent cameras ## Components Overview ### Drawable Components | Component | Description | | ------------------------------- | -------------------------------------------- | | [Sprite](./sprites/) | Animated sprites from Aseprite sprite sheets | | [Circle](./shapes#circle) | Filled or outlined circles | | [Ellipse](./shapes#ellipse) | Filled or outlined ellipses | | [Arc](./shapes#arc) | Partial circles/ellipses (pie slices) | | [Rectangle](./shapes#rectangle) | Filled or outlined rectangles | | [Line](./shapes#line) | Line segments | | [Mesh](./shapes#mesh) | Custom geometry | | [Text](./text) | Text using BMFont atlases (bitmap or MSDF) | ### Styling Components | Component | Description | | ------------------------------------ | ---------------------------------------- | | [Color](./styling#color) | RGBA tinting | | [Blend Modes](./styling#blend-modes) | Blend mode control (add, multiply, etc.) | | [Unlit](./styling#unlit) | Skip dynamic lighting | | [Pivot](./styling#pivot) | Custom pivot points (0-1 range) | ### Lighting Components | Component | Description | | -------------------------------------------- | --------------------------- | | [Light](./lighting#light-component) | Point lights and spotlights | | [Occluder](./lighting#shadows-and-occluders) | Shadow-casting entities | ### Special Components | Component | Description | | ----------------------------------------------- | ------------------------------------- | | [CameraTarget](./camera#cameratarget-component) | Makes camera follow an entity | | [Material](./materials) | GPU-batched fragment shader injection | ### Layer Features | Feature | Description | | --------------------------------------- | -------------------------------------- | | [Parallax](./layers#parallax-scrolling) | Layer-based parallax scrolling effects | ## Documentation | Topic | Description | | ---------------------------------------- | -------------------------------------------------------------------------------- | | [Camera](./camera) | Camera controls, multiple cameras, minimaps, split-screen, coordinate conversion | | [Sprites](./sprites/) | Sprite sheets, animation, slices, collisions | | [Shapes](./shapes) | Circles, rectangles, lines, and other primitives | | [Text](./text) | Bitmap and MSDF text rendering | | [Styling](./styling) | Color, blend modes, render flags | | [Layers](./layers) | Layer configuration and coordinate spaces | | [Lighting](./lighting) | Point lights, spotlights, shadows, and occluders | | [Custom Drawing](./custom-drawing) | CPU drawing with depth sorting | | [Materials](./materials) | GPU-batched fragment shader injection | ## Performance Characteristics The rendering system is designed to be **GPU-bound** rather than CPU-bound: * **Zero-copy FFI buffers**: Entity data is written directly to GPU-mapped memory * **Archetype batching**: Entities with the same components render together * **Dirty range tracking**: Only modified buffer regions are uploaded * **Indirect drawing**: Draw calls are issued from GPU-populated buffers This architecture allows rendering of 100K+ entities at 60fps on modern hardware, with performance scaling based on GPU fill rate rather than Lua interpreter speed. ## RenderConfig The render pipeline is configured via the `render` table in [`tecs2d.run`](/tecs2d/love2d#run). All fields are optional with sensible defaults. ```teal love.run = tecs2d.run({ fps = 60, game = gamePlugin, render = { virtualHeight = 180, pixelMode = true, lightingMode = "deferred", layers = { [10] = { name = "hud", space = "virtual", unlit = true }, }, }, }) ``` ### Fields | Field | Type | Default | Description | | -------------------- | ---------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------- | | `virtualHeight` | `integer` | window height | Virtual resolution height. Controls camera FOV and, in retro mode, the integer scale factor | | `virtualWidth` | `integer` | auto | Fixed virtual width. Auto-computed from aspect ratio (non-retro) or to fill screen at integer scale (retro) | | `pixelMode` | `boolean` | `false` | `true` = retro (low-res + nearest-neighbor upscale). See [Camera](/tecs2d/rendering/camera#pixel-modes) | | `lightingMode` | `string` | `"deferred"` | `"deferred"` or `"none"` (full brightness). See [Lighting](/tecs2d/rendering/lighting) | | `shadowsEnabled` | `boolean` | `true` | Whether occluders cast shadows | | `ambientLight` | `{r, g, b}` | `{1, 1, 1}` | Base illumination for unlit areas. See [Lighting](/tecs2d/rendering/lighting#ambient-light) | | `zoom` | `number` | `1.0` | Initial camera zoom level. See [Camera](/tecs2d/rendering/camera#zoom) | | `cameraPosition` | `{x, y}` | `{0, 0}` | Initial camera position | | `lerpingEnabled` | `boolean` | `true` | Enable smooth camera movement. See [Camera](/tecs2d/rendering/camera#smooth-movement) | | `lerpSpeed` | `number` | `8.0` | Lerp speed factor (higher = snappier) | | `clampToBounds` | `boolean` | `false` | Clamp camera to world bounds | | `worldBounds` | `{minX, minY, maxX, maxY}` | none | World bounds for clamping | | `layers` | `{integer: LayerConfig}` | none | Layer configurations keyed by layer number (1-16). See [Layers](/tecs2d/rendering/layers) | | `sizeHints` | `{string: integer}` | none | Initial GPU buffer capacities keyed by name (sprites, circles, lights, etc.). Grows automatically | | `dropShadowScale` | `number` | `0.5` | Drop shadow AO canvas resolution scale (0.5 = half res, 1.0 = full res) | | `bloom` | `BloomConfig` | none | Bloom post-processing config: `enabled`, `intensity`, `radius`, `threshold` | --- --- url: /tecs2d/rendering/camera.md --- # Camera The camera controls how the game world is presented on screen. It handles virtual resolution, viewport management, smooth movement, and coordinate conversion. Tecs supports multiple cameras for effects like minimaps, split-screen, and render-to-texture. ## Configuration Camera settings are provided via the `render` table in `tecs2d.run`. These configure the **primary camera**, which renders fullscreen by default. ```teal local tecs2d = require("tecs2d") -- Retro pixel art game (integer-scaled, no pixel crawl) love.run = tecs2d.run({ fps = 60, game = gamePlugin, render = { virtualHeight = 270, pixelMode = true, }, }) -- HD game with virtual resolution for consistent world view love.run = tecs2d.run({ fps = 60, game = gamePlugin, render = { virtualHeight = 270, -- always show 270 world units of height -- virtualWidth computed from aspect ratio automatically }, }) ``` All camera settings are optional. Additional options include: ```teal render = { cameraPosition = {0, 0}, -- Initial position (default: {0, 0}) zoom = 1.0, -- Initial zoom level (default: 1.0) lerpingEnabled = true, -- Enable smooth camera movement (default: true) lerpSpeed = 8.0, -- Lerp speed factor (default: 8.0) clampToBounds = false, -- Clamp camera to world bounds (default: false) worldBounds = {0, 0, 1000, 1000}, -- {minX, minY, maxX, maxY} } ``` ## Accessing the Camera Access the primary camera through the pipeline resource: ```teal local gfx = require("tecs2d.gfx") local pipeline = world.resources[gfx.PIPELINE] local cam = pipeline:getCamera() ``` All camera methods are called on the camera object directly, not on the pipeline. ## Pixel Modes The `pixelMode` boolean controls how rendering is scaled to the screen: | Value | Description | | ------- | ----------------------------------------------------------------------------------------------------- | | `false` | Smooth full-resolution rendering (default) | | `true` | Integer-scaled low-res canvas with nearest-neighbor filtering. Eliminates pixel crawl via blit-shift. | ```teal -- Change pixel mode at runtime pipeline:setPixelMode(true) -- enable retro pipeline:setPixelMode(false) -- disable retro -- Get current pixel mode local isRetro = pipeline:getPixelMode() ``` ### How retro mode works (blit-shift) Retro mode uses integer scaling with a blit-shift technique that completely eliminates pixel crawl: 1. Camera snaps to the virtual pixel grid: `snappedPos = floor(camPos / worldPixelSize) * worldPixelSize` 2. The render pass uses **zero sub-pixel offset**, so every pixel is perfect at virtual resolution 3. The sub-pixel remainder is applied as an integer screen-pixel offset when blitting the final canvas 4. Every virtual pixel maps to exactly `intScale x intScale` screen pixels 5. A scissor clips to a fixed viewport so letterbox bars remain stable Because the sub-pixel shift happens at the screen-pixel level (after integer scaling), virtual pixels never straddle screen pixel boundaries and there is no crawl or shimmer. When `pixelMode` is `false`, rendering is smooth at full resolution with no snap — the camera scrolls at fractional positions and bilinear filtering handles any sub-pixel motion. #### Retro resolution The integer scale factor and virtual dimensions are computed as follows: * `intScale = floor(screenHeight / virtualHeight)` (e.g., `floor(720 / 270) = 2`) * `virtualWidth = floor(screenWidth / intScale)` fills the screen width (e.g., `floor(1280 / 2) = 640`) * Only small vertical letterbox bars appear from the integer constraint * The camera FOV adapts to the screen aspect ratio: wider screens see more of the world ### Coordinate conversion `cam:toWorld()` and `cam:toScreen()` work correctly regardless of `pixelMode`. After rendering, the pipeline updates the camera's view-projection matrix to account for the render-to-screen mapping, so mouse coordinates always convert to the correct world positions. ## Camera Controls ### Position The camera uses **center-based positioning**: the position represents the center of the camera's view. ```teal local cam = pipeline:getCamera() -- Set position immediately (ignores lerping) cam:setPosition(100, 200) -- Move to position with smooth lerping cam:move(100, 200) -- Nudge by delta amount (useful for WASD input) cam:nudge(dx, dy) -- Get current position local x, y = cam:getPosition() ``` ### Zoom Zoom controls how much of the world is visible. Values greater than 1 zoom in (less visible area); values less than 1 zoom out (more visible area). ```teal -- Set zoom level (1 = normal, 2 = 2x zoom in, 0.5 = 2x zoom out) cam:setZoom(0.5) -- Get current zoom local zoom = cam:getZoom() ``` ### Smooth Movement When lerping is enabled, calls to `move()` and `nudge()` smoothly interpolate to the target position. ```teal -- Enable/disable smooth movement cam:setLerpingEnabled(true) cam:setLerpingEnabled(false) -- Adjust lerp speed (higher = snappier, lower = smoother) cam:setLerpSpeed(12.0) -- Snappy response cam:setLerpSpeed(4.0) -- Very smooth but slower ``` **Input-driven camera movement:** ```teal local input = require("tecs2d.input") local cam = pipeline:getCamera() local speed = 200 world:addSystem({ name = "CameraControls", phase = tecs.phases.FixedUpdate, run = function(dt) if input.isKeyDown("w") then cam:nudge(0, -speed * dt) end if input.isKeyDown("a") then cam:nudge(-speed * dt, 0) end if input.isKeyDown("s") then cam:nudge(0, speed * dt) end if input.isKeyDown("d") then cam:nudge(speed * dt, 0) end end }) ``` ### World Bounds Clamp the camera so it cannot scroll past the edges of the world: ```teal cam:setWorldBounds(0, 0, 3200, 2400) cam:setClamp(true) ``` ### Rotation ```teal -- Set rotation in radians (uses lerp if enabled) cam:setRotation(math.pi / 4) -- Get current rotation local angle = cam:getRotation() ``` ### Screenshake The camera includes a trauma-based screenshake system. Trauma is a 0-1 value that decays over time; shake intensity is proportional to trauma squared (`trauma^2`). Perlin noise produces smooth, continuous rumble for both translational and rotational shake. The shake offsets are applied to the rendered position without affecting the camera's logical position. ```teal local cam = pipeline:getCamera() -- Trigger screenshake (e.g., on hit or explosion) cam:shake(0.5) -- Add 0.5 trauma (stacks with existing) cam:shake(1.0) -- Full-intensity shake -- Configure shake parameters cam:setShakeIntensity(12) -- Max pixel offset at full trauma (default: 8) cam:setShakeRotation(0.05) -- Max rotation in radians at full trauma (default: 0.03) cam:setTraumaDecay(2.0) -- Decay rate in trauma/second (default: 1.5) -- Query current state local trauma = cam:getTrauma() ``` Screenshake works independently of lerping: it applies to both lerped and non-lerped cameras. The visible bounds are padded by the max shake intensity (a constant), not the current trauma, to prevent entities at screen edges from popping in and out as the shake decays. ## Coordinate Conversion Convert between world and screen coordinates: ```teal -- Convert screen coordinates to world coordinates (e.g., for mouse input) local worldX, worldY = cam:toWorld(love.mouse.getPosition()) -- Convert world coordinates to screen coordinates local screenX, screenY = cam:toScreen(entity.x, entity.y) ``` ## Visibility Query the camera's visible area: ```teal -- Get visible world bounds local left, top, right, bottom = cam:getVisibleCorners() -- Get visible dimensions local width, height = cam:getVisibleDimensions() -- Check if a rectangle is visible (useful for culling) if cam:isVisible(x, y, width, height) then -- Entity is on screen end ``` ## CameraTarget Component The `CameraTarget` component makes the primary camera automatically follow an entity. Add it to any entity with a Transform. ```teal local gfx = require("tecs2d.gfx") -- Make the player the camera target world:spawn( tecs.builtins.Transform(100, 100), gfx.Sprite.fromAseprite("player.png", "idle"), gfx.CameraTarget() ) ``` The camera uses its lerping settings to smoothly follow the target. ### Configuration | Property | Type | Default | Description | | -------------- | --------- | --------- | -------------------------------------------------------------- | | `offsetX` | number | 0 | Horizontal offset from entity center | | `offsetY` | number | 0 | Vertical offset from entity center | | `active` | boolean | true | Whether the camera should follow this target | | `deadZoneW` | number | 0 | Half-width of dead zone in world units (0 = disabled) | | `deadZoneH` | number | 0 | Half-height of dead zone in world units (0 = disabled) | | `lookAheadX` | number | 0 | Horizontal look-ahead factor (0 = disabled) | | `lookAheadY` | number | 0 | Vertical look-ahead factor (0 = disabled) | ```teal -- Camera focuses above and to the right of the entity gfx.CameraTarget({ offsetX = 20, offsetY = -30 }) -- Camera target that starts inactive gfx.CameraTarget({ active = false }) ``` ### Dead Zone and Look-Ahead The **dead zone** defines a rectangular area around the screen center where the target can move without the camera following. The camera only moves when the target exits the dead zone, keeping it at the edge. This reduces camera motion for small movements, making gameplay feel smoother. **Look-ahead** shifts the camera in the direction the target is moving, giving the player more visibility ahead. The look-ahead offset is smoothed with framerate-independent exponential decay. ```teal -- Dead zone: camera stays still for small movements gfx.CameraTarget({ deadZoneW = 40, -- 40 world units half-width deadZoneH = 30, -- 30 world units half-height }) -- Look-ahead: camera leads the target's movement gfx.CameraTarget({ lookAheadX = 3.0, -- 3x velocity look-ahead horizontally lookAheadY = 2.0, -- 2x velocity look-ahead vertically }) -- Both together gfx.CameraTarget({ deadZoneW = 32, deadZoneH = 24, lookAheadX = 2.5, lookAheadY = 1.5, }) ``` **Teleport detection:** If the target moves more than 500 world units from the camera in a single frame (e.g., map transition or respawn), the camera snaps immediately instead of lerping. ### Pausing and Resuming Toggle `active` to pause or resume camera following: ```teal -- Pause following (e.g., during cutscene) local target = world:get(playerId, gfx.CameraTarget) target.active = false -- Pan camera manually during cutscene cam:move(cutsceneX, cutsceneY) -- Resume following target.active = true ``` ### Multiple Targets If multiple entities have active `CameraTarget` components, the camera follows whichever one is processed last. For predictable behavior, ensure only one entity has an active `CameraTarget` at a time. ## Multiple Cameras Create additional cameras for minimaps, split-screen, or render-to-texture effects using `pipeline:newCamera()` (e.g., a security camera rendered in-game). Each camera renders the scene independently with its own position, zoom, and viewport. ### Camera Config When creating a camera with `pipeline:newCamera()`, you can pass an optional config table: | **Field** | **Type** | **Default** | **Description** | | -------------------- | ----------------- | -------------- | ----------------------------------------------------- | | `position` | `{x, y}` | `{0, 0}` | Initial camera position | | `zoom` | `number` | `1.0` | Initial zoom level | | `lerpingEnabled` | `boolean` | `true` | Enable smooth movement | | `lerpSpeed` | `number` | `8.0` | Lerp speed factor | | `viewport` | `{x, y, w, h}` | `fullscreen` | Screen-space viewport rectangle for split-screen | | `canvas` | `Canvas` | `none` | Render to this canvas instead of blitting to screen | | `layerMask` | `integer` | `0xFFFF` | 16-bit bitmask of visible layers | | `layers` | `{integer}` | `all` | List of visible layer numbers (builds mask) | | `layerRange` | `{min, max}` | `all` | Contiguous range of visible layers (builds mask) | | `disableDrawPhase` | `boolean` | `false` | Disable CPU Draw-phase systems for this camera | ### Minimap Camera A minimap camera renders the entire world to a small off-screen texture. Pass a `canvas` to render off-screen without blitting to the display, then draw that canvas wherever you want (screen overlay, in-game TV, etc.). ```teal local MINIMAP_W, MINIMAP_H = 200, 150 local WORLD_W, WORLD_H = 3200, 2400 local VIRTUAL_HEIGHT = 360 local minimapPixelScale = MINIMAP_H / VIRTUAL_HEIGHT local zoomX = MINIMAP_W / WORLD_W local zoomY = MINIMAP_H / WORLD_H local minimapCam = pipeline:newCamera("minimap", { canvas = love.graphics.newCanvas(MINIMAP_W, MINIMAP_H), lerpingEnabled = false, position = {WORLD_W / 2, WORLD_H / 2}, zoom = math.min(zoomX, zoomY) / minimapPixelScale, }) ``` Draw the minimap as a screen overlay in PostRender: ```teal world:addSystem({ name = "MinimapOverlay", phase = tecs.phases.PostRender, run = function() local canvas = minimapCam:getCanvas() if not canvas then return end -- Draw minimap in top-right corner local mx = love.graphics.getWidth() - MINIMAP_W - 10 local my = 10 love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(canvas, mx, my) -- Draw viewport indicator local cx, cy = cam:getPosition() local vw, vh = cam:getVisibleDimensions() local x1 = mx + (cx - vw / 2) / WORLD_W * MINIMAP_W local y1 = my + (cy - vh / 2) / WORLD_H * MINIMAP_H local w = vw / WORLD_W * MINIMAP_W local h = vh / WORLD_H * MINIMAP_H love.graphics.setColor(1, 1, 1, 0.9) love.graphics.setLineWidth(1) love.graphics.rectangle("line", x1, y1, w, h) end }) ``` Draw the minimap as an in-game TV in the Draw phase (world coordinates): ```teal world:addSystem({ name = "TVDraw", phase = tecs.phases.Draw, run = function() local canvas = minimapCam:getCanvas() if canvas then love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(canvas, 400, 400, 0, 300 / MINIMAP_W, 225 / MINIMAP_H) end end }) ``` ### Split-Screen A split-screen setup uses `viewport` to assign each camera a portion of the screen: ```teal local screenW = love.graphics.getWidth() local screenH = love.graphics.getHeight() local halfW = math.floor(screenW / 2) - 1 -- Deactivate primary camera local primaryCam = pipeline:getCamera() primaryCam:setActive(false) -- Left viewport local leftCam = pipeline:newCamera("left", { viewport = {0, 0, halfW, screenH}, lerpingEnabled = true, lerpSpeed = 8, position = {WORLD_W * 0.3, WORLD_H / 2}, }) leftCam:setWorldBounds(0, 0, WORLD_W, WORLD_H) leftCam:setClamp(true) -- Right viewport local rightCam = pipeline:newCamera("right", { viewport = {halfW + 2, 0, halfW, screenH}, lerpingEnabled = true, lerpSpeed = 8, position = {WORLD_W * 0.7, WORLD_H / 2}, }) rightCam:setWorldBounds(0, 0, WORLD_W, WORLD_H) rightCam:setClamp(true) ``` Each camera can be controlled independently: ```teal -- Left camera: WASD if input.isKeyDown("w") then leftCam:nudge(0, -speed * dt) end if input.isKeyDown("a") then leftCam:nudge(-speed * dt, 0) end -- Right camera: arrow keys if input.isKeyDown("up") then rightCam:nudge(0, -speed * dt) end if input.isKeyDown("left") then rightCam:nudge(-speed * dt, 0) end ``` Draw a divider between viewports in PostRender: ```teal world:addSystem({ name = "Divider", phase = tecs.phases.PostRender, run = function() love.graphics.setColor(0.2, 0.2, 0.2, 1) love.graphics.rectangle("fill", halfW, 0, 2, screenH) end }) ``` :::info See the example See the `multi-cam` example for a complete implementation that combines minimap, in-game TV, and split-screen cameras. ::: ## Pipeline Camera API ### pipeline:getCamera Get a camera by name. Called with no arguments, returns the primary camera. ```teal function Pipeline:getCamera(name?: string): Camera ``` **Parameters:** * `name` (optional): The camera name. If omitted, returns the primary camera. **Returns:** * The `Camera` instance, or `nil` if no camera with that name exists. **Example:** ```teal local cam = pipeline:getCamera() -- primary camera local mini = pipeline:getCamera("minimap") -- named camera ``` ### pipeline:newCamera Create and register a new camera. ```teal function Pipeline:newCamera(name: string, config?: CameraConfig): Camera ``` **Parameters:** * `name`: Unique name for the camera. * `config` (optional): A [CameraConfig](#camera-config) table. Screen dimensions and virtual height default to the pipeline's current values. **Returns:** * The newly created `Camera`. **Notes:** * Throws an error if a camera with the same name already exists. * The camera starts active. Call `setActive(false)` to defer rendering. **Example:** ```teal local minimapCam = pipeline:newCamera("minimap", { canvas = love.graphics.newCanvas(200, 150), position = {1600, 1200}, }) ``` ### pipeline:removeCamera Remove a registered camera by name. ```teal function Pipeline:removeCamera(name: string) ``` **Parameters:** * `name`: The camera name to remove. **Notes:** * The primary camera cannot be removed. * No-op if the name is not found. ## Camera API ### setPosition Set the camera position immediately, bypassing lerp interpolation. Both the current and target positions are updated. ```teal function Camera:setPosition(x: number, y: number) ``` **Parameters:** * `x`: The x position (world coordinates). * `y`: The y position (world coordinates). ### move Move the camera to a target position. If lerping is enabled, the camera smoothly interpolates to the target. Otherwise, the position is set immediately. ```teal function Camera:move(x: number, y: number) ``` **Parameters:** * `x`: The target x position (world coordinates). * `y`: The target y position (world coordinates). ### nudge Move the camera by a delta amount relative to its current target position. Useful for input-driven movement. ```teal function Camera:nudge(dx: number, dy: number) ``` **Parameters:** * `dx`: Horizontal delta. * `dy`: Vertical delta. **Example:** ```teal -- Move camera right at 200 units/sec cam:nudge(200 * dt, 0) ``` ### getPosition Get the current interpolated camera position. ```teal function Camera:getPosition(): number, number ``` **Returns:** * `x`: Current x position. * `y`: Current y position. ### setZoom Set the camera zoom level. Values greater than 1 zoom in; values less than 1 zoom out. Clamped to a minimum of 0.001. ```teal function Camera:setZoom(zoom: number) ``` **Parameters:** * `zoom`: The zoom level. **Notes:** * If bounds clamping is enabled, the position is re-clamped after the zoom change to account for the new visible area. ### getZoom Get the current zoom level. ```teal function Camera:getZoom(): number ``` **Returns:** * The current zoom level. ### setRotation Set the camera rotation. If lerping is enabled, the rotation smoothly interpolates via the shortest angular path. ```teal function Camera:setRotation(rotation: number) ``` **Parameters:** * `rotation`: Rotation angle in radians. ### getRotation Get the current camera rotation. ```teal function Camera:getRotation(): number ``` **Returns:** * The current rotation in radians. ### setLerpingEnabled Enable or disable smooth camera interpolation. When disabled, the camera snaps to its target position immediately. ```teal function Camera:setLerpingEnabled(enabled: boolean) ``` **Parameters:** * `enabled`: `true` to enable lerping, `false` to disable. **Notes:** * Disabling lerp immediately snaps the camera to its target position. ### setLerpSpeed Set the lerp speed factor. Higher values produce snappier movement; lower values produce smoother, slower tracking. ```teal function Camera:setLerpSpeed(speed: number) ``` **Parameters:** * `speed`: Lerp speed factor, clamped to the range 1-20. ### setClamp Enable or disable clamping the camera position to world bounds. ```teal function Camera:setClamp(enabled: boolean) ``` **Parameters:** * `enabled`: `true` to clamp, `false` to allow free scrolling. ### setWorldBounds Set the world bounds used for clamping. The camera will not scroll past these edges when clamping is enabled. ```teal function Camera:setWorldBounds(minX: number, minY: number, maxX: number, maxY: number) ``` **Parameters:** * `minX`: Left edge of the world. * `minY`: Top edge of the world. * `maxX`: Right edge of the world. * `maxY`: Bottom edge of the world. ### toWorld Convert screen-space coordinates to world-space coordinates, accounting for camera position, zoom, and rotation. ```teal function Camera:toWorld(screenX: number, screenY: number): number, number ``` **Parameters:** * `screenX`: X position on the screen. * `screenY`: Y position on the screen. **Returns:** * `worldX`: X position in the world. * `worldY`: Y position in the world. **Example:** ```teal local wx, wy = cam:toWorld(love.mouse.getPosition()) ``` ### toScreen Convert world-space coordinates to screen-space coordinates. ```teal function Camera:toScreen(worldX: number, worldY: number): number, number ``` **Parameters:** * `worldX`: X position in the world. * `worldY`: Y position in the world. **Returns:** * `screenX`: X position on the screen. * `screenY`: Y position on the screen. ### getVisibleCorners Get the world-space bounding box of the camera's visible area. ```teal function Camera:getVisibleCorners(): number, number, number, number ``` **Returns:** * `x1`: Left edge. * `y1`: Top edge. * `x2`: Right edge. * `y2`: Bottom edge. **Notes:** * When the camera is rotated, this returns the axis-aligned bounding box of the rotated viewport, which is slightly larger than the actual visible area. ### getVisibleDimensions Get the width and height of the camera's visible area in world units. ```teal function Camera:getVisibleDimensions(): number, number ``` **Returns:** * `width`: Visible width in world units. * `height`: Visible height in world units. ### isVisible Check if a world-space rectangle is within the camera's visible area. Useful for culling expensive draw calls. ```teal function Camera:isVisible(x: number, y: number, w: number, h: number): boolean ``` **Parameters:** * `x`: Left edge of the rectangle. * `y`: Top edge of the rectangle. * `w`: Width of the rectangle. * `h`: Height of the rectangle. **Returns:** * `true` if any part of the rectangle overlaps the visible area. ### setActive Enable or disable this camera. Inactive cameras are skipped during rendering. ```teal function Camera:setActive(active: boolean) ``` **Parameters:** * `active`: `true` to enable rendering, `false` to skip. ### isActive Check if this camera is currently active. ```teal function Camera:isActive(): boolean ``` **Returns:** * `true` if the camera will render. ### setViewport Set the screen-space viewport rectangle. This also updates the camera's internal screen dimensions to match the viewport aspect ratio. ```teal function Camera:setViewport(x: number, y: number, w: number, h: number) ``` **Parameters:** * `x`: Left edge in screen pixels. * `y`: Top edge in screen pixels. * `w`: Width in screen pixels. * `h`: Height in screen pixels. **Notes:** * Use `0, 0, 0, 0` to reset to fullscreen rendering. ### getViewport Get the current viewport rectangle. ```teal function Camera:getViewport(): number, number, number, number ``` **Returns:** * `x`, `y`, `w`, `h`: The viewport rect. `0, 0, 0, 0` means fullscreen. ### setLayerMask Set the 16-bit layer visibility bitmask. Bit N-1 corresponds to layer N. For example, `0x0003` makes layers 1 and 2 visible. ```teal function Camera:setLayerMask(mask: integer) ``` **Parameters:** * `mask`: 16-bit bitmask. `0xFFFF` makes all layers visible. ### getLayerMask Get the current layer visibility bitmask. ```teal function Camera:getLayerMask(): integer ``` **Returns:** * The 16-bit layer mask. ### setLayers Set visible layers from a list of layer numbers. Replaces the current mask. ```teal function Camera:setLayers(layers: {integer}) ``` **Parameters:** * `layers`: List of layer numbers (1-16). **Example:** ```teal cam:setLayers({1, 2, 5}) -- Only layers 1, 2, and 5 visible ``` ### setLayerRange Set visible layers from a contiguous range. Replaces the current mask. ```teal function Camera:setLayerRange(minLayer: integer, maxLayer: integer) ``` **Parameters:** * `minLayer`: First visible layer (inclusive). * `maxLayer`: Last visible layer (inclusive). ### setLayer Toggle visibility of a single layer without affecting other layers. ```teal function Camera:setLayer(layer: integer, enabled: boolean) ``` **Parameters:** * `layer`: Layer number (1-16). * `enabled`: `true` to make visible, `false` to hide. ### isLayerVisible Check if a specific layer is visible to this camera. ```teal function Camera:isLayerVisible(layer: integer): boolean ``` **Parameters:** * `layer`: Layer number (1-16). **Returns:** * `true` if the layer is visible. ### setRunDrawPhase Enable or disable the CPU Draw phase for this camera. When enabled, systems registered in `tecs.phases.Draw` will run during this camera's render pass with the camera's transform applied. ```teal function Camera:setRunDrawPhase(enabled: boolean) ``` **Parameters:** * `enabled`: `true` to run Draw-phase systems. **Notes:** * The primary camera always runs the Draw phase. Secondary cameras only run it when explicitly enabled. * Required for texture cameras that need custom CPU drawing (e.g., minimap with in-game TV overlay). ### getRunDrawPhase Check if the CPU Draw phase is enabled for this camera. ```teal function Camera:getRunDrawPhase(): boolean ``` **Returns:** * `true` if Draw-phase systems run for this camera. ### getName Get the camera's name as set during creation. ```teal function Camera:getName(): string ``` **Returns:** * The name string. The primary camera's name is `"__primary"`. ### getCanvas Get the rendered canvas for render-to-texture cameras (created with `canvas`). ```teal function Camera:getCanvas(): love.graphics.Canvas ``` **Returns:** * The canvas containing the camera's rendered output, or `nil` for viewport cameras. **Notes:** * Returns the canvas passed to the constructor, so `getCanvas()` returns a valid canvas even before the first render frame. * Draw this canvas in a PostRender system (screen overlay) or a Draw system (in-game display). ### shake Add trauma to trigger screenshake. Stacks with existing trauma, clamped to 0-1. ```teal function Camera:shake(amount: number) ``` **Parameters:** * `amount`: Trauma amount to add (0-1). Values above 1 are clamped. **Example:** ```teal cam:shake(0.5) -- Medium shake cam:shake(1.0) -- Maximum shake ``` ### setShakeIntensity Set the maximum pixel offset at full trauma (trauma = 1). ```teal function Camera:setShakeIntensity(intensity: number) ``` **Parameters:** * `intensity`: Max pixel offset (default: 8). Clamped to >= 0. ### setShakeRotation Set the maximum rotational shake in radians at full trauma. ```teal function Camera:setShakeRotation(maxAngle: number) ``` **Parameters:** * `maxAngle`: Max rotation in radians (default: 0.03, approximately 1.7 degrees). Clamped to >= 0. ### setTraumaDecay Set the trauma decay rate. ```teal function Camera:setTraumaDecay(decay: number) ``` **Parameters:** * `decay`: Decay rate in trauma per second (default: 1.5). Higher values make the shake end faster. ### getTrauma Get the current trauma level. ```teal function Camera:getTrauma(): number ``` **Returns:** * The current trauma level (0-1). ### setScreenSize Update the camera's screen dimensions. Called automatically by the pipeline on window resize. ```teal function Camera:setScreenSize(sw: integer, sh: integer, vh: integer) ``` **Parameters:** * `sw`: Screen width in pixels. * `sh`: Screen height in pixels. * `vh`: Virtual height for resolution scaling. :::tip You typically do not need to call this directly; the pipeline handles it on resize. ::: --- --- url: /tecs2d/rendering/shapes.md --- # Shapes Tecs provides GPU-accelerated shape components for rendering circles, ellipses, arcs, rectangles, lines, and custom meshes. All shapes are rendered using GPU instancing with compute shader culling. ## Common Patterns All shape components require a `Transform` component for positioning: ```teal local tecs = require("tecs") local gfx = require("tecs2d.gfx") world:spawn( tecs.builtins.Transform(100, 100), -- Position gfx.Circle(20), -- Shape gfx.Color(1, 0.5, 0, 1) -- Optional: color ) ``` Shapes can be combined with styling components: * `Color` - RGBA tinting * `BlendMode` - Blend mode (add, multiply, etc.) * `Unlit` - Skip dynamic lighting * `Pivot` - Custom pivot point * *See [Styling](./styling) for details on these components.* For advanced visual effects, shapes can also use [Materials](./materials). ## Circle Renders filled or outlined circles. The transform position is the center of the circle. ```teal -- Filled circle (default) gfx.Circle(radius) -- Outlined circle with line width gfx.Circle(radius, lineWidth) ``` **Parameters:** | Parameter | Type | Default | Description | | ------------- | -------- | ---------- | ------------------------------------------------------------------------------------------ | | `radius` | number | required | Circle radius in pixels | | `lineWidth` | number | nil | Line width for outline. If nil/0, draws filled. ([dirty tracking](#changing-line-width)) | **Examples:** ```teal -- Filled red circle world:spawn( tecs.builtins.Transform(100, 100), gfx.Circle(30), gfx.Color(1, 0, 0, 1) ) -- Outlined circle with thick anti-aliased line world:spawn( tecs.builtins.Transform(200, 100), gfx.Circle(25, 3), gfx.Color(0, 1, 0, 1), ) ``` ## Ellipse Renders filled or outlined ellipses. The transform position is the center of the ellipse. ```teal -- Filled ellipse (default) gfx.Ellipse(radiusX, radiusY) -- Outlined ellipse with line width gfx.Ellipse(radiusX, radiusY, lineWidth) ``` **Parameters:** | Parameter | Type | Default | Description | | ------------- | -------- | ---------- | ------------------------------------------------------------------------------------------ | | `radiusX` | number | required | Horizontal radius in pixels | | `radiusY` | number | required | Vertical radius in pixels | | `lineWidth` | number | nil | Line width for outline. If nil/0, draws filled. ([dirty tracking](#changing-line-width)) | **Examples:** ```teal -- Shadow ellipse under a character world:spawn( tecs.builtins.Transform(x, y + 10), gfx.Ellipse(40, 15), gfx.Color(0, 0, 0, 0.3) ) ``` ## Arc Renders filled or outlined arc segments (pie slices or arc outlines). Arcs are defined by start and end angles in radians. ```teal -- Filled arc (pie slice) gfx.Arc(radiusX, radiusY, startAngle, endAngle) -- Outlined arc with line width gfx.Arc(radiusX, radiusY, startAngle, endAngle, lineWidth) ``` **Parameters:** | Parameter | Type | Default | Description | | -------------- | -------- | ---------- | ------------------------------------------------------------------------------------------ | | `radiusX` | number | required | Horizontal radius in pixels | | `radiusY` | number | required | Vertical radius in pixels | | `startAngle` | number | 0 | Starting angle in radians | | `endAngle` | number | 2\*pi | Ending angle in radians | | `lineWidth` | number | nil | Line width for outline. If nil/0, draws filled. ([dirty tracking](#changing-line-width)) | **Angle Reference:** * `0` = right * `math.pi / 2` = down * `math.pi` = left * `math.pi * 1.5` = up **Examples:** ```teal -- Pac-man shape world:spawn( tecs.builtins.Transform(100, 100), gfx.Arc(30, 30, math.pi / 6, 2 * math.pi - math.pi / 6), gfx.Color(1, 1, 0, 1) ) -- Progress indicator (half circle arc outline) world:spawn( tecs.builtins.Transform(200, 100), gfx.Arc(40, 40, 0, math.pi, 4), gfx.Color(0, 1, 0, 1), ) ``` ## Rectangle Renders filled or outlined rectangles. The transform position is the center of the rectangle. ```teal -- Filled rectangle (default) gfx.Rectangle(width, height) -- Outlined rectangle with line width gfx.Rectangle(width, height, lineWidth) ``` **Parameters:** | Parameter | Type | Default | Description | | ------------- | -------- | ---------- | ------------------------------------------------------------------------------------------ | | `width` | number | required | Width in pixels | | `height` | number | required | Height in pixels | | `lineWidth` | number | nil | Line width for outline. If nil/0, draws filled. ([dirty tracking](#changing-line-width)) | **Examples:** ```teal -- Platform world:spawn( tecs.builtins.Transform(200, 300), gfx.Rectangle(100, 20), gfx.Color(0.5, 0.3, 0.2, 1) ) -- Debug bounding box world:spawn( tecs.builtins.Transform(x, y), gfx.Rectangle(50, 50, 1), gfx.Color(1, 0, 0, 0.5) ) ``` ### Rounded Corners Add rounded corners to rectangles with the `RoundedCorners` component: ```teal -- Uniform corner radius world:spawn( tecs.builtins.Transform(100, 100), gfx.Rectangle(80, 40), gfx.RoundedCorners(10), -- 10px corner radius gfx.Color(0.2, 0.4, 0.8, 1) ) -- Different horizontal and vertical radii (elliptical corners) world:spawn( tecs.builtins.Transform(200, 100), gfx.Rectangle(80, 40), gfx.RoundedCorners(15, 8), -- rx=15, ry=8 gfx.Color(0.8, 0.4, 0.2, 1) ) ``` **Note:** `RoundedCorners` requires a `Rectangle` component on the same entity. ## Line Renders line segments. Endpoints are relative to the transform position. ```teal gfx.Line(x1, y1, x2, y2, width?) ``` **Parameters:** | Parameter | Type | Default | Description | | ----------- | -------- | --------- | --------------------------------------------------------------- | | `x1` | number | 0 | Start X relative to transform | | `y1` | number | 0 | Start Y relative to transform | | `x2` | number | 0 | End X relative to transform | | `y2` | number | 0 | End Y relative to transform | | `width` | number | 1 | Line width in pixels ([dirty tracking](#changing-line-width)) | **Examples:** ```teal -- Simple line (1px default width) world:spawn( tecs.builtins.Transform(100, 100), gfx.Line(0, 0, 50, 50), gfx.Color(1, 0, 0, 1) ) -- Thick horizontal line centered at transform world:spawn( tecs.builtins.Transform(200, 100), gfx.Line(-30, 0, 30, 0, 5), gfx.Color(0, 1, 0, 1), ) -- Rotating line (e.g., clock hand) world:spawn( tecs.builtins.Transform(x, y, 0, 1, { rotation = math.pi / 4 }), gfx.Line(0, 0, 0, -40, 3), gfx.Color(1, 1, 1, 1) ) ``` **Notes:** * Line endpoints scale with `Transform.scaleX` and `Transform.scaleY` * Rotation is applied around the transform position * Edges are anti-aliased by default; flip the entire scene to hard pixel cutoffs with `pipeline:setRoughGeometry(true)` ## Mesh Renders custom geometry using GPU instancing. This is useful for complex shapes that can't be expressed with the built-in primitives. ### Defining a Mesh First, register a mesh definition with a [Love2D Mesh](https://love2d.org/wiki/Mesh): ```teal local gfx = require("tecs2d.gfx") -- Create a Love2D mesh (triangle example) local vertices = { {0, -20, 0, 0, 1, 1, 1, 1}, -- top {-17, 15, 0, 0, 1, 1, 1, 1}, -- bottom-left {17, 15, 0, 0, 1, 1, 1, 1}, -- bottom-right } local loveMesh = love.graphics.newMesh(vertices, "triangles", "static") -- Register the mesh definition gfx.MeshDefinition.new("triangle", loveMesh, 1000) -- name, mesh, capacity ``` ### Using a Mesh Spawn entities with the `Mesh` component referencing the definition name: ```teal world:spawn( tecs.builtins.Transform(100, 100), gfx.Mesh("triangle"), gfx.Color(1, 0, 0, 1) ) ``` **Parameters:** | Parameter | Type | Description | | -------------- | -------- | --------------------------------------- | | `definition` | string | Name of the registered MeshDefinition | ### Styling Meshes support all the same styling components as built-in shapes: ```teal world:spawn( tecs.builtins.Transform(100, 100), gfx.Mesh("triangle"), gfx.Color(1, 0, 0, 1), -- Tinting (multiplied with vertex colors) gfx.blend.AdditiveBlend(), -- Blend mode gfx.Unlit, -- Skip dynamic lighting gfx.Pivot(0.5, 0.5) -- Custom pivot point ) ``` For advanced effects, meshes can also use [Materials](./materials). **Notes:** * Mesh geometry is defined once and shared by all instances * Each instance can have its own transform, color, and styling * The mesh is GPU-instanced for efficient rendering of many copies * Vertex colors in the mesh are multiplied by the `Color` component ## Changing Line Width Tecs uses a two-tier syncing architecture for GPU rendering. Transform properties (position, rotation, scale) sync automatically every frame, but metadata properties like `lineWidth` only sync when the column carrying the change is marked dirty on the archetype. This avoids uploading unchanged data to the GPU every frame. If you modify `lineWidth` after spawning, fetch the column with `archetype:getMut(...)` so the renderer shadow buffer re-uploads the changes: ```teal for archetype, len in query:iter() do local circles = archetype:getMut(gfx.Circle) for i = 1, len do circles[i].lineWidth = math.sin(time) * 2 + 3 end end ``` *See [Dirty Tracking](/tecs/components/dirty-tracking) for more details.* ## Rect Interface All shape components implement the `Rect` interface, which returns bounding box information: ```teal local circle = world:get(entity, gfx.Circle) local offsetX, offsetY, width, height = circle:getRect() ``` The returned values are: * `offsetX`, `offsetY`: Offset from the entity position to the top-left corner * `width`, `height`: Dimensions of the bounding box This is used internally by the rendering pipeline for culling calculations. You can also use it for gameplay logic like collision detection, mouse hit-testing, or spatial queries: ```teal -- Check if a point is within an entity's bounding box local function pointInEntity(world, entity, px, py) local transform = world:get(entity, tecs.builtins.Transform) local shape = world:get(entity, gfx.Rectangle) -- or Circle, Ellipse, etc. local ox, oy, w, h = shape:getRect() local left = transform.x + ox local top = transform.y + oy return px >= left and px <= left + w and py >= top and py <= top + h end ``` --- --- url: /tecs2d/rendering/text.md --- # Text Tecs provides GPU-accelerated text rendering using [BMFont](https://www.angelcode.com/products/bmfont/doc/file_format.html) format atlases. Both bitmap fonts and MSDF (multi-channel signed distance field) fonts are supported. Text is rendered with GPU instancing for efficient batching. ## Quick Start ```teal local tecs = require("tecs") local gfx = require("tecs2d.gfx") -- Load a bitmap font and create text world:spawn( tecs.builtins.Transform(100, 50), gfx.Text("assets/fonts/pixel.fnt", "Hello World!") ) ``` ## Text Component The `Text` component renders text using a BMFont atlas. It requires a `Transform` component for positioning. ```teal gfx.Text(fontPath, text, scaleX?, scaleY?) ``` **Parameters:** | Parameter | Type | Default | Description | | ------------ | -------- | ----------- | ------------------------------------------------------------------ | | `fontPath` | string | required | Path to the `.fnt` or `.json` font file | | `text` | string | required | Text to display | | `scaleX` | number | 1 | Horizontal scale factor | | `scaleY` | number | `scaleX` | Vertical scale factor (defaults to `scaleX` for uniform scaling) | **Examples:** ```teal -- Basic text world:spawn( tecs.builtins.Transform(100, 100), gfx.Text("fonts/pixel.fnt", "Score: 1000") ) -- Scaled text (uniform scaling with single value) world:spawn( tecs.builtins.Transform(200, 100), gfx.Text("fonts/pixel.fnt", "BIG TEXT", 2) ) -- Colored text world:spawn( tecs.builtins.Transform(100, 150), gfx.Text("fonts/pixel.fnt", "Warning!"), gfx.Color(1, 0.5, 0, 1) ) ``` ### Updating Text Use `setText()` to change the displayed text: ```teal local textComp = world:get(entityId, gfx.Text) textComp:setText("New text content") ``` Use `setScale()` to change the scale: ```teal textComp:setScale(1.5) -- Uniform scale textComp:setScale(2.0, 1.0) -- Different X and Y scale ``` Both methods automatically [mark the `Text` column dirty](/tecs/components/dirty-tracking) on the entity's archetype so the GPU buffer is updated. ### Getting Text Properties ```teal local textComp = world:get(entityId, gfx.Text) -- Get current text local currentText = textComp:getText() -- Get dimensions (unscaled) local offsetX, offsetY, width, height = textComp:getRect() ``` ## Creating BMFont Files Many tools can generate BMFont files. These free online tools are confirmed to work with Tecs: * [SnowB](https://snowb.org) - Bitmap font generator * [MSDF BMFont](https://msdf-bmfont.donmccurdy.com) - MSDF font generator ### File Requirements Each BMFont requires two files: * **Font definition**: `.fnt` (text format) or `.json` * **Atlas image**: `.png` (or other image format) The atlas image path is specified in the font definition file. Paths are relative to the font file location. **Example directory structure:** ``` assets/ fonts/ pixel.fnt pixel.png fancy.json fancy.png ``` ### Supported Font Types | Type | Description | Best For | | ---------- | -------------------------------- | ----------------------------- | | `bitmap` | Pre-rendered at fixed size | Pixel art, retro games | | `msdf` | Multi-channel distance field | Sharp at any scale/rotation | MSDF fonts are auto-detected from the font metadata (the `distanceField` field in JSON BMFont files). When an MSDF font is loaded, text entities using it are automatically rendered with the MSDF shader pipeline, producing sharp edges at any scale and rotation. No separate component or flag is needed; detection is automatic. ### Generating MSDF Atlases For a quick browser-based option, use [MSDF BMFont](https://msdf-bmfont.donmccurdy.com). For more control over atlas parameters, use the CLI tool [msdf-atlas-gen](https://github.com/Chlumsky/msdf-atlas-gen): ```bash msdf-atlas-gen -font MyFont.ttf -type msdf -format png -size 42 \ -pxrange 4 -json MyFont-msdf.json -imageout MyFont-msdf.png ``` The output JSON + PNG pair can be loaded directly with `Text`: ```teal gfx.Text("fonts/MyFont-msdf.json", "Sharp at any size!", 1.0) ``` #### Atlas Parameters | Parameter | Flag | Default | Description | | ---------------- | ----------- | --------- | --------------------------------------------------------------- | | Font size | `-size` | - | Base font size in pixels. Larger = more detail, bigger atlas | | Distance range | `-pxrange` | 2 | Atlas pixels of signed distance data around each glyph edge | | Font type | `-type` | - | Use `msdf` for multi-channel SDF (sharp corners) | ::: tip Distance Range and Effects The `-pxrange` value controls how many atlas pixels of distance data are stored around each glyph edge. This directly limits the maximum width of effects like outline and glow, since these effects can only render where SDF distance data exists. * **`-pxrange 4`**: Good for basic rendering and thin outlines * **`-pxrange 8`**: Recommended when using glow or wider outlines * **`-pxrange 16`**: Maximum effect width; produces larger atlases A larger pxrange requires more padding between glyphs in the atlas, increasing atlas texture size. For most games, `-pxrange 4` is sufficient for sharp text with outlines. ::: ## Preloading Fonts To preload fonts during startup and avoid loading hitches, use the asset manager's [`loadBMFont`](/tecs2d/assets/api#loadbmfont) method: ```teal local tecs = require("tecs") local assets = require("tecs2d.assets") world:addSystem({ phase = tecs.phases.Startup, run = function() local manager = world.resources[assets] manager:loadBMFont("fonts/pixel.fnt"):pin() manager:loadBMFont("fonts/title.fnt"):pin() end, }) ``` The fonts will be cached and available immediately when `Text` components reference them by path. ## Text Effects (MSDF only) The `TextEffects` component adds outline, glow, and drop shadow effects to MSDF text. Effects are rendered entirely on the GPU with no extra draw calls. All effects are composited back-to-front: shadow, then glow, then outline, then the text fill. ```teal gfx.TextEffects(config?) ``` **Config fields:** | Field | Type | Description | | ----------------- | ------------- | --------------------------------------------------------------------------- | | `outline.width` | number | Outline thickness (0.05-0.3 typical). Extends outward from the glyph edge | | `outline.color` | `{r,g,b,a}` | Outline color | | `glow.radius` | number | Glow intensity/width (0.5-1.0 typical). Limited by atlas `-pxrange` | | `glow.color` | `{r,g,b,a}` | Glow color | | `shadow.offset` | `{x,y}` | Shadow offset in atlas pixels | | `shadow.blur` | number | Shadow blur amount (0.01-0.1 typical) | | `shadow.color` | `{r,g,b,a}` | Shadow color | **Examples:** ```teal -- Outline world:spawn( tecs.builtins.Transform(100, 100), gfx.Text("fonts/msdf.json", "Outlined", 2), gfx.Color(1, 1, 1, 1), gfx.TextEffects.new({ outline = { width = 0.15, color = {0, 0, 0, 1} }, }) ) -- Glow world:spawn( tecs.builtins.Transform(100, 150), gfx.Text("fonts/msdf.json", "Glowing", 2), gfx.Color(1, 1, 1, 1), gfx.TextEffects.new({ glow = { radius = 1.0, color = {0, 1, 0.2, 1} }, }) ) -- Drop shadow world:spawn( tecs.builtins.Transform(100, 200), gfx.Text("fonts/msdf.json", "Shadowed", 2), gfx.Color(1, 1, 1, 1), gfx.TextEffects.new({ shadow = { offset = {3, 3}, blur = 0.05, color = {0, 0, 0, 0.8} }, }) ) -- Combined effects world:spawn( tecs.builtins.Transform(100, 300), gfx.Text("fonts/msdf.json", "All Effects", 2), gfx.Color(0.2, 0.6, 1, 1), gfx.TextEffects.new({ outline = { width = 0.1, color = {0, 0, 0.3, 1} }, glow = { radius = 0.5, color = {0.3, 0.5, 1, 0.8} }, shadow = { offset = {4, 4}, blur = 0.08, color = {0, 0, 0, 0.6} }, }) ) ``` ::: tip TextEffects only works with MSDF/SDF fonts. Adding it to bitmap text entities has no visual effect. ::: ::: warning Effect Width Limits Outline, glow, and shadow effects can only extend as far as the SDF distance data in the atlas padding. This is determined by the `-pxrange` value used when generating the atlas. If effects appear clipped or too narrow, regenerate the atlas with a larger `-pxrange` (e.g., 8 or 16). ::: ## Styling Text works with these [styling components](/tecs2d/rendering/styling): * [Color](/tecs2d/rendering/styling#color) - Tint the text * [Pivot](/tecs2d/rendering/styling#pivot) - Change the origin point * [Unlit](/tecs2d/rendering/styling#unlit) - Skip dynamic lighting for UI text * [BlendMode](/tecs2d/rendering/styling#blendmode) - Control blending * [Material](/tecs2d/rendering/materials) - Per-entity GPU shader effects * [TextEffects](#text-effects-msdf-only) - Outline, glow, drop shadow (MSDF only) ```teal -- Colored text world:spawn( tecs.builtins.Transform(x, y), gfx.Text("fonts/pixel.fnt", "Colorful!"), gfx.Color(0.2, 0.8, 0.4, 1) ) -- Unlit text (ignores lighting) world:spawn( tecs.builtins.Transform(x, y), gfx.Text("fonts/pixel.fnt", "UI Text"), gfx.Unlit ) ``` ### Materials with MSDF Text [Materials](/tecs2d/rendering/materials) work with both bitmap and MSDF text. For MSDF text, the material input `mi.sdfDist` contains the signed distance value, which can be used for custom effects like dissolve edges or color gradients based on glyph shape. For bitmap text, `mi.sdfDist` is always 0. --- --- url: /tecs2d/rendering/styling.md --- # Styling Styling components modify how entities are rendered without changing their shape or content. These components work with sprites, shapes, and text. ## Color The `Color` component applies RGBA tinting to sprites, shapes, and text. ```teal gfx.Color(r?, g?, b?, a?) ``` **Parameters:** | Parameter | Type | Default | Description | | ----------- | -------- | --------- | -------------------------- | | `r` | number | 1.0 | Red component (0.0-1.0) | | `g` | number | 1.0 | Green component (0.0-1.0) | | `b` | number | 1.0 | Blue component (0.0-1.0) | | `a` | number | 1.0 | Alpha component (0.0-1.0) | **Examples:** ```teal local gfx = require("tecs2d.gfx") -- Red tint world:spawn( tecs.builtins.Transform(100, 100), gfx.Circle(20), gfx.Color(1, 0, 0, 1) ) -- Semi-transparent white world:spawn( tecs.builtins.Transform(200, 100), gfx.Rectangle(50, 50), gfx.Color(1, 1, 1, 0.5) ) -- No color component = full white (1, 1, 1, 1) world:spawn( tecs.builtins.Transform(300, 100), gfx.Circle(20) ) ``` ### Modifying Color Tecs syncs color changes only when the `Color` component is detected as dirty. See [Dirty Tracking](/tecs/components/dirty-tracking) for details. There are two approaches: **Option 1: Replace the component in the archetype** This is preferred if you have the archetype and row. ```teal archetype:set(row, Color(r, g, b, a)) ``` **Option 2: Replace the component in the world** This is useful if you just have the entity ID. ```teal world:set(entityId, Color(r, g, b, a)) ``` **Option 3: Modify in place via getMut** If you have an archetype and row: ```teal local colors = archetype:getMut(Color) local color = colors[row] color.r = 0.5 color.g = 1.0 color.b = 0.5 color.a = 1.0 ``` `getMut` flags `Color` dirty on the archetype, so the renderer re-uploads it next frame. If you have just the entity ID: ```teal world:markComponentDirty(entityId, Color) ``` ## Blend Modes Blend mode components control how an entity's pixels combine with the background. Each blend mode is a separate tag component. Entities without a blend component use normal alpha blending. Blend mode components are stored in `tecs2d.gfx.blend`: ```teal local gfx = require("tecs2d.gfx") local blend = gfx.blend ``` | Component | Description | | ------------------------ | --------------------------------------------------------------------------------- | | `AdditiveBlend` | Adds source and destination colors. Use for glow, fire, light beams, particles. | | `AdditiveBlendPremul` | Additive blend for pre-multiplied alpha textures. | | `MultiplyBlend` | Multiplies source and destination colors. Use for shadows, tinting, darkening. | | `MultiplyBlendPremul` | Multiply blend for pre-multiplied alpha textures. | | `ScreenBlend` | Inverse of multiply; lightens the image. Use for soft glow and highlights. | | `ScreenBlendPremul` | Screen blend for pre-multiplied alpha textures. | | `SubtractBlend` | Subtracts source from destination. Use for color removal and special darkening. | | `SubtractBlendPremul` | Subtract blend for pre-multiplied alpha textures. | | `LightenBlend` | Takes the maximum of each color channel. Use for dodge and highlight effects. | | `LightenBlendPremul` | Lighten blend for pre-multiplied alpha textures. | | `DarkenBlend` | Takes the minimum of each color channel. Use for burn and shadow effects. | | `DarkenBlendPremul` | Darken blend for pre-multiplied alpha textures. | | `ReplaceBlend` | Overwrites the destination completely. Use when you need exact color output. | | `ReplaceBlendPremul` | Replace blend for pre-multiplied alpha textures. | ::: details Why 14 blend mode components? Blend modes are tag components rather than a single component with a "mode" field. This allows the ECS to group entities by blend mode into separate archetypes. Since all entities in an archetype share the same blend mode, the renderer can batch them into a single draw call without state changes. ::: ### Using Blend Modes Blend modes are **mutually exclusive**: adding a blend component automatically removes any existing blend component from the entity. **Adding a blend mode** when spawning: ```teal world:spawn( tecs.builtins.Transform(100, 100), gfx.Circle(30), gfx.Color(1, 0.8, 0.2, 0.9), blend.AdditiveBlend() ) ``` **Changing a blend mode** on an existing entity (the old blend component is removed automatically): ```teal world:set(id, blend.MultiplyBlend()) ``` **Removing a blend mode** to return to normal alpha blending: ```teal world:remove(id, blend.MultiplyBlend) ``` ::: warning Materials Blend mode components are **not compatible with [Materials](./materials)**. Materials render through the deferred G-buffer pipeline, while blend modes use a forward pass after lighting. Entities with both a Material and a blend mode component will not render. ::: ## Unlit The `Unlit` tag component opts an entity out of dynamic lighting. Entities with `Unlit` render at full brightness regardless of nearby light sources or ambient light. Use it for UI elements, light emitters that already encode their own brightness in their color, and any entity where shading would be wrong. ```teal world:spawn( tecs.builtins.Transform(10, 10), gfx.Rectangle(200, 50), gfx.Color(0.2, 0.2, 0.2, 0.9), gfx.Unlit ) ``` `Unlit` carries no data, so adding it is a single argument. To toggle at runtime: ```teal world:add(entityId, gfx.Unlit) -- make unlit world:remove(entityId, gfx.Unlit) -- restore normal lighting ``` `Unlit` composes with the per-layer unlit mask (`pipeline:setLayerUnlit(layer, true)`): an entity is unlit if **either** the tag is on the entity **or** the layer is unlit. ## Geometry style SDF shapes (`Circle`, `Rectangle`, `Ellipse`, `Arc`, `Line`) render with anti-aliased edges by default. The pipeline-level `roughGeometry` flag flips the entire scene to hard pixel cutoffs, suitable for pixel-art aesthetics where AA gradients would soften the look. ```teal local pipeline = gfx.newPipeline({ world = world, roughGeometry = true, -- hard pixel edges }) ``` Toggle at runtime: ```teal pipeline:setRoughGeometry(true) local rough = pipeline:getRoughGeometry() ``` `roughGeometry` is independent of `pixelMode` (the integer-scale upscale). Combine them for retro pixel-art, or use either alone. ## Pivot The `Pivot` component controls the origin point for rendering and rotation. Values are normalized (0-1 range). ```teal gfx.Pivot(x?, y?) ``` **Parameters:** | Parameter | Type | Default | Description | | ----------- | -------- | --------- | ------------------------------------------------ | | `x` | number | 0.5 | Horizontal pivot (0=left, 0.5=center, 1=right) | | `y` | number | 0.5 | Vertical pivot (0=top, 0.5=center, 1=bottom) | **Examples:** ```teal -- Rectangle anchored at top-left world:spawn( tecs.builtins.Transform(0, 0), gfx.Rectangle(100, 50), gfx.Pivot(0, 0) ) -- Sprite rotating around its feet world:spawn( tecs.builtins.Transform(200, 300, 0, 1, { rotation = math.pi / 4 }), gfx.Sprite.fromAseprite("character.png", "idle"), gfx.Pivot(0.5, 1) -- Bottom-center ) ``` ::: warning Dirty Tracking Changing `Pivot` at runtime requires [dirty tracking](/tecs/components/dirty-tracking) to take effect. ::: ::: info Aseprite Slices Sprites loaded with `pivotSlice` option use per-frame pivot points from Aseprite slices, which override the Pivot component. ::: ## Combining Styling Components Styling components can be combined freely: ```teal -- Glowing additive circle world:spawn( tecs.builtins.Transform(100, 100), gfx.Circle(30), gfx.Color(1, 0.8, 0.2, 0.9), gfx.blend.AdditiveBlend() ) -- UI button (unlit, rounded, with custom pivot) world:spawn( tecs.builtins.Transform(400, 20), gfx.Rectangle(120, 40), gfx.RoundedCorners(8), gfx.Color(0.3, 0.5, 0.9, 1), gfx.Unlit, gfx.Pivot(1, 0) -- Anchor top-right ) ``` ## See Also * [Materials](./materials) - GPU-batched fragment shader injection for per-entity visual effects (dissolve, glow, tinting) --- --- url: /tecs2d/rendering/layers.md --- # Layers The render pipeline organizes entities into 16 layers for controlling draw order, visibility, and coordinate spaces. Layers are rendered from 1 (back) to 16 (front). ## Using Layers Entities are assigned to layers via the `Transform` component's `layer` field: ```teal local tecs = require("tecs") local gfx = require("tecs2d.gfx") -- Background on layer 1 world:spawn( tecs.builtins.Transform(0, 0, 0, 1), -- x, y, z, layer gfx.Sprite.fromAseprite("background.png") ) -- Player on layer 5 world:spawn( tecs.builtins.Transform(100, 100, 0, 5), gfx.Sprite.fromAseprite("player.png", "idle") ) -- UI on layer 15 world:spawn( tecs.builtins.Transform(10, 10, 0, 15), gfx.Text("fonts/ui.fnt", "Score: 0"), gfx.Unlit ) ``` ## Layer Configuration Configure layers with `pipeline:configureLayer()`: ```teal local gfx = require("tecs2d.gfx") -- Configure layers in tecs2d.run love.run = tecs2d.run({ fps = 60, game = gamePlugin, render = { layers = { [1] = { name = "background" }, [5] = { name = "entities" }, [15] = { name = "ui", space = "virtual", unlit = true }, }, }, }) -- Or configure at runtime via the pipeline local pipeline = world.resources[gfx.PIPELINE] pipeline:configureLayer(15, { name = "ui", space = "virtual", -- Fixed to virtual resolution unlit = true -- No lighting effects }) ``` ### Configuration Options | Option | Type | Default | Description | | ------------- | --------- | ----------- | -------------------------------------------------------- | | `name` | string | nil | Optional layer name for debugging | | `visible` | boolean | true | Whether the layer is rendered | | `space` | string | "world" | Coordinate space: "world", "virtual", or "screen" | | `unlit` | boolean | false | If true, entities skip lighting | | `parallaxX` | number | 1.0 | Horizontal parallax factor (0.0 = fixed, 1.0 = normal) | | `parallaxY` | number | 1.0 | Vertical parallax factor (0.0 = fixed, 1.0 = normal) | | `sortMode` | string | "topdown" | Depth sorting mode: "topdown", "z", or "isometric" | ## Coordinate Spaces Layers can use different coordinate spaces: ### World Space (Default) Entities move with the camera. Use for game objects. ```teal pipeline:configureLayer(5, { space = "world" }) ``` * Coordinates are in world units * Position is camera-relative * Affected by zoom * Supports parallax scrolling ### Virtual Space Entities are fixed to the virtual resolution. Use for HUD elements. ```teal pipeline:configureLayer(15, { space = "virtual" }) ``` * Coordinates are in virtual pixels (e.g., 0-320 × 0-180) * Position is screen-fixed * Not affected by camera or zoom * Scales with window resize * Parallax has no effect ### Screen Space Entities are fixed to screen pixels. Use for overlays that shouldn't scale. ```teal pipeline:configureLayer(16, { space = "screen" }) ``` * Coordinates are in screen pixels * Position is screen-fixed * Not affected by camera or zoom * Does not scale with window resize * Parallax has no effect * Screen-space layers must have higher layer indices than all world/virtual layers * Screen-space layers are always unlit (forced by the pipeline) * Screen layers render at native window resolution regardless of pixel mode * Useful for HUD/UI elements that should remain crisp at all settings ## Layer Visibility Toggle layer visibility at runtime: ```teal -- Hide a layer pipeline:setLayerVisibility(1, false) pipeline:setLayerVisibility("background", false) -- By name -- Show a layer pipeline:setLayerVisibility(1, true) -- Check visibility if pipeline:isLayerVisible(1) then -- Layer is visible end ``` **Use cases:** * Debug visualization layers * Optional UI elements * Level transitions ## Layer Lighting Control whether a layer is unlit (skips lighting effects): ```teal -- Disable lighting on UI layer (entities render at full brightness) pipeline:setLayerUnlit(15, true) -- Re-enable lighting pipeline:setLayerUnlit(15, false) -- Check if layer is unlit if pipeline:isLayerUnlit(5) then -- Layer renders at full brightness end ``` You can also set this via configuration: ```teal pipeline:configureLayer(15, { unlit = true }) ``` **Note:** Individual entities can be marked unlit with the [`Unlit`](/tecs2d/rendering/styling#unlit) tag component, which composes with the per-layer setting (either makes the entity unlit). ## Depth Sorting Each layer can use a different sorting mode via the `sortMode` option. The sort mode determines how entities are ordered within the layer. ### Sort Modes | Mode | Description | | ------------- | ------------------------------------------------------------------------- | | `"topdown"` | Z-index primary, Y-position secondary. Lower on screen appears in front. | | `"z"` | Pure Z-index sorting. Only `Transform.z` determines order. | | `"isometric"` | X + Y + Z sorting. Good for isometric/diamond tile layouts. | ### TopDown Mode (Default) The default mode uses Z-index as the primary sort and Y-position as a secondary tiebreaker, giving natural depth ordering in 3/4 view (oblique top-down) games. ```teal pipeline:configureLayer(5, { sortMode = "topdown" }) ``` ### Z-Only Mode Use `"z"` mode when you want explicit control over draw order without Y-sorting: ```teal pipeline:configureLayer(15, { name = "ui", space = "virtual", sortMode = "z" -- Only z-index matters }) ``` ### Isometric Mode Use `"isometric"` mode for diamond-grid isometric games where depth depends on both X and Y: ```teal pipeline:configureLayer(5, { name = "isometric", sortMode = "isometric" -- X + Y + Z sorting }) ``` ### Runtime Sort Mode Changes You can change sort mode at runtime: ```teal pipeline:setLayerSortMode(5, "isometric") pipeline:setLayerSortMode("entities", "topdown") -- By name local mode = pipeline:getLayerSortMode(5) -- Returns "topdown", "z", or "isometric" ``` ## Parallax Scrolling Configure parallax to make layers scroll at different rates relative to the camera: ```teal -- Background scrolls at 10% of camera speed pipeline:configureLayer(1, { name = "background", parallaxX = 0.1, parallaxY = 0.1 }) -- Midground scrolls at half speed pipeline:configureLayer(2, { name = "midground", parallaxX = 0.5, parallaxY = 0.5 }) -- Foreground (default) scrolls with camera pipeline:configureLayer(3, { name = "foreground" }) ``` You can also update parallax at runtime: ```teal pipeline:setLayerParallax(1, 0.2, 0.2) local px, py = pipeline:getLayerParallax(1) ``` ### Parallax Factors | Factor | Behavior | | --------- | -------------------------------------------- | | `1.0` | Moves with camera (default) | | `0.5` | Moves at half camera speed | | `0.0` | Stays fixed (distant background) | | `> 1.0` | Moves faster than camera (near foreground) | ::: info World Space Only Parallax only applies to world space layers. Virtual and screen space layers are already detached from the camera, so parallax values have no effect on them. ::: ::: tip Tiled Integration When loading Tiled maps, layer parallax is automatically configured from `parallaxx` and `parallaxy` layer properties. ::: ## Layer Effects Layer effects are post-processing shaders applied to rendered layers. After a layer (or group of layers) is rendered and lit, the result is passed through one or more shader passes. Use them for per-layer blur, color grading, desaturation, and other full-screen effects. ::: tip Materials vs Layer Effects Layer effects operate on entire rendered layers (screen-space post-processing). For per-entity visual effects like dissolve, glow, or tinting, use [Materials](./materials) instead, which are GPU-batched and much faster for per-entity use. ::: ### Setting an Effect Use `pipeline:setLayerEffect()` to apply a shader effect to a layer: ```teal -- Single-pass effect (e.g., color inversion) pipeline:setLayerEffect(layerNum, invertShader, nil, { singleLayer = true }) -- Single-pass effect with uniforms pipeline:setLayerEffect(layerNum, desatShader, { strength = 0.8 }, { singleLayer = true }) -- Multi-pass effect (e.g., separable Gaussian blur) pipeline:setLayerEffect(layerNum, {blurH, blurV}, { radius = 8.0 }, { singleLayer = true }) -- Layers can be referenced by name pipeline:setLayerEffect("entities", invertShader) ``` Clear an effect with `pipeline:clearLayerEffect()`: ```teal pipeline:clearLayerEffect(layerNum) pipeline:clearLayerEffect("entities") -- By name ``` ### API Reference ```teal pipeline:setLayerEffect( layer, -- integer (1-16) or string (layer name) shader, -- A single Shader or a list of Shaders for multi-pass uniforms, -- Optional {name = value} table sent to all shaders options -- Optional {singleLayer = boolean} ) pipeline:clearLayerEffect(layer) -- integer (1-16) or string (layer name) ``` ### Effect Modes The `singleLayer` option controls what the effect applies to: #### Single Layer Mode (`singleLayer = true`) The effect applies only to the specified layer. Other layers are unaffected. ```teal pipeline:setLayerEffect(3, blurShader, { radius = 4.0 }, { singleLayer = true }) ``` #### Layers Below Mode (`singleLayer = false`, default) The effect applies to the specified layer **and all layers below it**. This is useful for effects like heat haze that should distort everything underneath. ```teal -- Blur layer 3 and everything below it (layers 1, 2, 3) pipeline:setLayerEffect(3, blurShader, { radius = 4.0 }) ``` ### Layer Groups When effects are active, the pipeline splits the 16-layer stack into **layer groups**: contiguous ranges of layers that are rendered and composited together. The grouping is determined automatically based on which layers have effects and what mode they use. Without effects, all 16 layers are a single group rendered in one pass. Each effect boundary creates a new group. #### Single Layer Mode Grouping With `singleLayer = true`, the effect layer is isolated into its own group. Layers before and after it form separate groups: ``` ┌──────────────────┐ Effect on layer 3 │ Group 3 │ (singleLayer = true) │ Layers 4..16 │ │ No effect │ ├──────────────────┤ │ Group 2 │ │ Layer 3 only │◄── Effect applied │ blur + invert │ to this layer ├──────────────────┤ │ Group 1 │ │ Layers 1..2 │ │ No effect │ └──────────────────┘ ``` The effect shader processes layer 3's rendered output before it is composited onto the accumulator. Layers 1-2 and 4-16 pass through unchanged. #### Layers Below Mode Grouping With `singleLayer = false` (default), the effect layer and everything below it form one group, and the effect is applied to the composited result of all those layers together: ``` ┌──────────────────┐ Effect on layer 3 │ Group 2 │ (singleLayer = false) │ Layers 4..16 │ │ No effect │ ├──────────────────┤ │ Group 1 │ │ Layers 1..3 │◄── Effect applied to │ blur │ ALL layers in group └──────────────────┘ ``` Here the blur applies to the combined output of layers 1, 2, and 3. #### Multiple Effects Multiple effects on different layers create more groups. Each effect boundary splits the stack: ``` ┌──────────────────┐ Effects on layers 2 and 5 │ Group 4 │ (both singleLayer = true) │ Layers 6..16 │ │ No effect │ ├──────────────────┤ │ Group 3 │ │ Layer 5 only │◄── desaturate ├──────────────────┤ │ Group 2 │ │ Layers 3..4 │ │ No effect │ ├──────────────────┤ │ Group 1 │ │ Layers 1..2 │◄── blur └──────────────────┘ ``` ### Multi-Pass Effects Some effects require multiple shader passes. For example, a Gaussian blur is faster as two separable passes (horizontal then vertical) than a single 2D pass. Pass a list of shaders to `setLayerEffect`: ```teal local BLUR_SHADER = [[ extern vec2 direction; extern number radius; vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { vec4 sum = vec4(0.0); float totalWeight = 0.0; vec2 texSize = vec2(textureSize(tex, 0)); vec2 pixelDir = direction / texSize; for (float i = -radius; i <= radius; i += 1.0) { float weight = 1.0 - abs(i) / (radius + 1.0); weight = weight * weight; sum += Texel(tex, tc + pixelDir * i) * weight; totalWeight += weight; } return sum / totalWeight * color; } ]] -- Create two instances with different directions local blurH = love.graphics.newShader(BLUR_SHADER) local blurV = love.graphics.newShader(BLUR_SHADER) blurH:send("direction", {1.0, 0.0}) -- Horizontal blurV:send("direction", {0.0, 1.0}) -- Vertical -- Apply as a 2-pass effect pipeline:setLayerEffect(2, {blurH, blurV}, { radius = 8.0 }, { singleLayer = true }) ``` Internally, multi-pass effects use **ping-pong rendering**: the output of each pass becomes the input of the next, alternating between two canvases. ``` Pass 1 (blurH) Pass 2 (blurV) ┌───────────┐ ┌────────────┐ ┌───────────┐ │ litLayer │──shader───►│ effectTemp │──shader───►│ litLayer │ └───────────┘ └────────────┘ └───────────┘ src dst swap src ``` ### Stacking Effects on One Layer You can combine multiple effects on a single layer by passing all their shaders as one list. Effects are applied in order: ```teal -- Blur, then invert, then desaturate (all on layer 2) local allShaders = {blurH, blurV, invertShader, desatShader} local allUniforms = { radius = 8.0, strength = 1.0 } pipeline:setLayerEffect(2, allShaders, allUniforms, { singleLayer = true }) ``` Uniforms are shared across all shaders. Each shader only receives the uniforms it declares; uniforms not present in a shader are silently skipped. ### Standard Uniforms The pipeline automatically sends these uniforms to effect shaders if they are declared: | Uniform | Type | Description | | ------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `tecs_PassIndex` | int | Zero-based index of the current pass in a multi-pass effect | | `tecs_SceneBelow` | Image | Texture of all layers composited below the current group | | `tecs_Depth` | Image | G-buffer depth texture (RGBA8). R channel holds `gl_FragCoord.z` (0.0 = near, 1.0 = far). Pixels with no geometry have depth = 1.0. | | `tecs_Normal` | Image | G-buffer normal texture (RGBA8). RGB = world normal (encoded as `n * 0.5 + 0.5`), A = lit marker. | | `tecs_Emission` | Image | G-buffer emission texture (RGBA8). RGB = emission color, applied additively after lighting. | Use `tecs_SceneBelow` for effects that need to reference the scene underneath, such as distortion or refraction: ```glsl extern Image tecs_SceneBelow; vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { // Sample the current layer vec4 pixel = Texel(tex, tc); // Sample the scene below with an offset for distortion vec2 offset = vec2(sin(tc.y * 20.0) * 0.01, 0.0); vec4 below = Texel(tecs_SceneBelow, tc + offset); return mix(below, pixel, pixel.a) * color; } ``` Use `tecs_Depth` for depth-based post-processing like fog: ```glsl extern Image tecs_Depth; vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { vec4 pixel = Texel(tex, tc); float depth = Texel(tecs_Depth, tc).r; vec3 fogColor = vec3(0.5, 0.6, 0.8); float fogAmount = smoothstep(0.3, 0.9, depth); return vec4(mix(pixel.rgb, fogColor, fogAmount), pixel.a) * color; } ``` ::: info G-Buffer Limitations Forward blend shapes (particles, translucent entities) render after lighting and are not written to the G-buffer. Their depth, normal, and emission values will be the clear defaults (depth = 1.0, normal = flat up, emission = black). Custom draws via the G-buffer callback also use clear defaults. ::: ### Writing Effect Shaders Effect shaders are standard LOVE2D pixel shaders. They receive the rendered layer (or accumulator) as the `tex` parameter: ```glsl -- Simple color inversion vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { vec4 pixel = Texel(tex, tc); return vec4(1.0 - pixel.r, 1.0 - pixel.g, 1.0 - pixel.b, pixel.a) * color; } ``` ```glsl -- Desaturation with configurable strength extern number strength; vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { vec4 pixel = Texel(tex, tc); float gray = dot(pixel.rgb, vec3(0.299, 0.587, 0.114)); vec3 desaturated = mix(pixel.rgb, vec3(gray), strength); return vec4(desaturated, pixel.a) * color; } ``` Custom uniforms are passed via the `uniforms` table and sent to the shader before each pass. ### Rendering Pipeline With Effects When no effects are active, the pipeline renders all 16 layers in a single pass. When effects are present, it switches to a multi-pass approach: ``` For each layer group: ┌─────────────────────────────────────────────────────┐ │ 1. G-buffer pass (geometry for this layer range) │ │ 2. Shadow mask + Lighting ──► litLayer │ │ 3. Forward blend pass (alpha shapes) ──► litLayer │ │ 4. Apply effect (if any): │ │ - Single layer: effect(litLayer) ──► accumulator│ │ - Layers below: composite ──► effect(accum) │ │ 5. Or no effect: litLayer ──► accumulator │ └─────────────────────────────────────────────────────┘ Final: draw accumulator to screen ``` ::: tip Example See the `layer-fx` example for an interactive demo with blur, invert, and desaturation effects that can be toggled per-layer at runtime. Run it with `make example-layer-fx`. ::: ## Performance Notes * Layers are processed sequentially; minimize layer count for best performance * Sparse layer usage is fine (layers 1, 5, 15 work as well as 1, 2, 3) * Layer configuration changes are cheap; use visibility toggling freely * Coordinate space changes per-layer have no performance cost * Parallax is computed on the GPU during culling with zero CPU overhead * Layer effects add a rendering pass per group; minimize the number of distinct effect boundaries * Multi-pass effects (e.g., separable blur) add one pass per shader in the list * Layer group computation is cached and only recalculated when effects are added or removed --- --- url: /tecs2d/rendering/lighting.md --- # Lighting Tecs provides a GPU-accelerated 2.5D lighting system with dynamic shadows. The system supports point lights, spotlights, and height-based shadow occlusion. ## Quick Start ```teal local gfx = require("tecs2d.gfx") local Transform = tecs.builtins.Transform -- Create a point light world:spawn( Transform(400, 300), gfx.Light.new({ radius = 200, intensity = 1.0, height = 0.3, r = 1.0, g = 0.9, b = 0.8 }) ) -- Create a shadow-casting circle world:spawn( Transform(300, 250), gfx.Circle(50), gfx.Color(0.8, 0.3, 0.2), gfx.Occluder() -- enables shadow casting ) ``` ## Light Component The `Light` component creates dynamic light sources. It accepts a configuration table: ```teal gfx.Light.new({ radius = 200, intensity = 1.0, height = 0.15, r = 1.0, g = 1.0, b = 1.0, penumbra = 0.5, direction = 0, coneAngle = 0, innerConeAngle = 0, }) ``` All fields are optional with sensible defaults. You can also use positional arguments for simple cases: ```teal -- Positional: radius, intensity, height, r, g, b gfx.Light(200, 1.0, 0.3, 1.0, 0.9, 0.8) ``` ### Properties | Property | Default | Description | | ---------------- | --------- | ---------------------------------------------------------------------------------- | | `radius` | 200 | Falloff distance in world units. Pixels beyond this receive no light | | `intensity` | 1.0 | Brightness multiplier. Values above 1.0 create overbright lighting | | `height` | 0.15 | Height above the 2D plane (0-1). See [Light Height](#light-height-and-25d-shading) | | `r`, `g`, `b` | 1.0 | Light color (0-1 per channel) | | `penumbra` | 0.5 | Shadow edge softness (0-1). 0 = hard shadows, 1 = maximum softness | | `direction` | 0 | Spotlight angle in radians (0 = right, pi/2 = down). Ignored for point lights | | `coneAngle` | 0 | Spotlight outer cone half-angle in radians. 0 = point light | | `innerConeAngle` | 0 | Spotlight inner cone half-angle. Full intensity inside, fades to outer cone | ### Modifying Light Properties After spawning, you can modify light properties directly: ```teal local light = world:get(entity, gfx.Light) light.radius = 300 light.intensity = 1.5 light.height = 0.5 light.r, light.g, light.b = 1.0, 0.8, 0.6 -- warm color light.penumbra = 0.5 -- harder shadow edges ``` ## Point Lights vs Spotlights By default, lights are omnidirectional point lights. Setting `coneAngle > 0` converts a light to a spotlight. ### Point Light (Default) Point lights emit in all directions: ```teal world:spawn( Transform(x, y), gfx.Light.new({ radius = 200, intensity = 1.0, height = 0.3 }) ) ``` ### Spotlight Spotlights emit in a directional cone: ```teal world:spawn( Transform(x, y), gfx.Light.new({ radius = 300, intensity = 1.5, height = 0.3, r = 1.0, g = 1.0, b = 0.9, direction = math.pi / 2, -- pointing down coneAngle = math.pi / 6, -- 30 degree half-angle (60 degree cone) innerConeAngle = math.pi / 12 -- soft edge falloff }) ) ``` | Property | Description | | ---------------- | -------------------------------------------------------------------- | | `direction` | Angle in radians (0 = right, pi/2 = down, pi = left) | | `coneAngle` | Outer cone half-angle. 0 = point light | | `innerConeAngle` | Inner cone for soft edges. Light is full intensity inside this angle | The light intensity falls off smoothly between `innerConeAngle` and `coneAngle`. ## Light Height and 2.5D Shading The `height` property (0.0 - 1.0, normalized) controls how lights interact with surfaces: * **Low height** (0-0.15): Creates dramatic side-lighting with strong shadows * **Medium height** (0.15-0.5): Balanced lighting * **High height** (0.5+): Even, overhead-style lighting ```teal -- Dramatic torchlight close to the ground gfx.Light.new({ radius = 150, intensity = 1.2, height = 0.05, r = 1.0, g = 0.6, b = 0.3 }) -- Overhead sunlight gfx.Light.new({ radius = 500, intensity = 0.8, height = 0.8, r = 1.0, g = 1.0, b = 0.95 }) ``` Height also affects normal-based shading. Low lights create more pronounced directional shading on surfaces with normal maps. ## Specular Highlights Sprites and tiles with specular maps (`_s.png` suffix) display shiny highlights when lit. The lighting system uses [Blinn-Phong shading](https://en.wikipedia.org/wiki/Blinn–Phong_reflection_model) to calculate specular reflections. ### Specular Map Format | Channel | Purpose | | ------- | ------------------------------------------------- | | RGB | Specular intensity/color (brighter = shinier) | | Alpha | Shininess (0 = broad highlights, 1 = sharp/tight) | ### Example Uses * **Metal armor**: Bright specular RGB, high alpha (sharp highlights) * **Wet surfaces**: Medium specular RGB, medium alpha * **Skin/cloth**: Zero or very low specular (matte surfaces) Specular maps are loaded automatically when a `sprite_s.png` file exists alongside the main sprite. For tilechunks, set the `specularMap` property directly. ::: tip Advanced Lighting Control For per-entity control over normals, specular, and emission in the G-buffer, use [Materials](./materials). Materials let you write custom fragment shaders that output to all G-buffer targets while staying fully GPU-batched. ::: ## Shadows and Occluders Entities with the `Occluder` component cast shadows. Without this component, entities are lit but don't block light. ### Basic Shadow Casting ```teal -- Circle that casts shadows world:spawn( Transform(x, y), gfx.Circle(50), gfx.Color(0.5, 0.5, 0.5), gfx.Occluder() ) -- Rectangle that casts shadows world:spawn( Transform(x, y), gfx.Rectangle(100, 200), gfx.Color(0.4, 0.3, 0.2), gfx.Occluder() ) -- Sprite that casts shadows (uses alpha channel) world:spawn( Transform(x, y), gfx.Sprite(spriteSheet, "tree"), gfx.Occluder({ alphaThreshold = 0.5 }) ) ``` ### Occluder Configuration The `Occluder` component accepts either a configuration table or positional arguments: ```teal -- Table style gfx.Occluder({ alphaThreshold = 0.5, -- for sprites: pixels below this alpha don't cast shadows height = 0.5, -- occluder height (0-1, normalized, default: 0.5) }) -- Positional style: height, alphaThreshold gfx.Occluder() -- all defaults (height = 0.5) gfx.Occluder(0.3) -- height = 0.3 gfx.Occluder(0.5, 0.5) -- height = 0.5, alphaThreshold = 0.5 ``` ### Occluder Height The `height` property (0.0 - 1.0) controls perspective-based shadow projection: ```teal -- Short occluder - casts shorter shadows from high lights world:spawn( Transform(x, y), gfx.Circle(30), gfx.Occluder({ height = 0.2 }) ) -- Tall occluder - casts longer shadows world:spawn( Transform(x, y), gfx.Circle(100), gfx.Occluder({ height = 0.8 }) ) ``` **Perspective projection:** * Shadow reach = `occluderHeight / lightHeight` * Higher lights cast shorter shadows from the same occluder * Occluders close to the light cast shorter shadows than distant ones * When `lightHeight <= occluderHeight`, full shadows are cast ### Sprite Shadows For sprites, the alpha channel determines the shadow silhouette: ```teal -- Tree sprite with alpha-tested shadows world:spawn( Transform(x, y), gfx.Sprite(treeSheet, "oak"), gfx.Occluder({ alphaThreshold = 0.5, -- pixels with alpha < 0.5 don't cast shadows height = 0.5 -- tree is medium height }) ) ``` ### Tile Shadows Tiles in Tiled maps can cast shadows by setting the `occluderHeight` custom property on tiles in your tileset. This allows specific wall or obstacle tiles to block light without converting them to sprites. **In Tiled:** 1. Open your tileset in the Tileset Editor 2. Select the tile(s) that should cast shadows 3. Add a custom property: `occluderHeight` (float) with a normalized height (0.0 - 1.0) ``` Tile Properties: occluderHeight: 0.3 -- This tile casts shadows at medium height ``` **How it works:** * When a tilemap loads, Tecs extracts `occluderHeight` from each tile's properties * During rendering, tiles with `occluderHeight > 0` are rendered to the shadow mask * The height determines which lights the tile blocks (same as sprite/shape occluders) **Per-chunk settings:** If a TileChunk contains any occluding tiles, you can customize the shadow behavior by adding an `Occluder` component to the chunk entity: ```teal -- Custom occluder settings for a chunk (via onTilemapLoaded callback) world:set(chunkEntity, gfx.Occluder({ height = 0.3, -- override default height })) ``` **Performance note:** Tile shadows are GPU-accelerated using the same dual-write culling architecture as sprites. Only chunks containing occluding tiles are processed in the shadow pass. ## Drop Shadows The `DropShadow` component projects a sprite's silhouette onto the ground as a dynamic shadow, stretched away from nearby light sources. Unlike `Occluder` shadows (which use raymarching), drop shadows are computed per-entity on the GPU and work well for characters, trees, and other sprites that need ground-contact shadows. ### Basic Usage ```teal -- Tree with a drop shadow world:spawn( Transform(x, y, 0, 2, 0, 3, 3), Sprite.fromSheet(treeSheet), gfx.Pivot(0.5, 1.0), gfx.DropShadow() ) -- Taller entity with darker shadow world:spawn( Transform(x, y, 0, 2), Sprite.fromSheet(characterSheet), gfx.Pivot(0.5, 1.0), gfx.DropShadow({ height = 0.8, opacity = 0.5 }) ) ``` ### Properties | Property | Default | Description | | ---------- | --------- | ----------------------------------------------------------- | | `height` | 0.5 | Entity height (0-1). Taller entities cast longer shadows | | `opacity` | 0.3 | Shadow darkness (0-1). Higher values produce darker shadows | You can use either table or positional syntax: ```teal gfx.DropShadow() -- defaults (height=0.5, opacity=0.3) gfx.DropShadow(0.8) -- height=0.8, opacity=0.3 gfx.DropShadow(0.5, 0.4) -- height=0.5, opacity=0.4 gfx.DropShadow({ height = 0.8 }) -- table style ``` ### How Drop Shadows Work Each frame, the GPU cull shader checks every `DropShadow` sprite against nearby lights using tile-based light binning (the same tile structure used by the lighting pass). For each light affecting the sprite: 1. The shadow is **stretched away** from the light, with length proportional to `height / lightHeight` 2. The shadow is **squashed vertically** to create a ground-contact perspective effect 3. The shadow **opacity** fades with distance from the light 4. Shadows from multiple lights accumulate (darkest wins) The shadows render to a standalone canvas and attenuate all lighting (ambient and dynamic). Emission is unaffected, so glowing objects shine through shadows. ### Drop Shadows vs Occluders | Feature | `DropShadow` | `Occluder` | | ----------------- | -------------------------------------------- | -------------------------------------- | | Shadow type | Ground-contact silhouette projection | Raymarched volumetric shadows | | Light interaction | Attenuates all lighting at shadow location | Blocks light along ray path | | Self-shadowing | Automatically prevented (two-pass stamp-out) | Automatically prevented (origin check) | | Best for | Characters, trees, props | Walls, pillars, large obstacles | | Performance | One extra sprite draw per light per entity | Raymarch steps per pixel per light | You can combine both on the same entity: an `Occluder` blocks light rays while a `DropShadow` adds a visible ground shadow. ### Performance Drop shadow rendering cost scales with the number of visible drop shadow sprites multiplied by the number of affecting lights. The system includes several optimizations: * **Tile-based light lookup**: each sprite only considers lights in its screen tile, not all visible lights * **Weak shadow culling**: light contributions below a threshold are skipped * **Half-resolution canvas**: the AO canvas defaults to 50% resolution with bilinear filtering, reducing fill-rate cost with no visible quality loss You can adjust the canvas resolution via `dropShadowScale`: ```teal love.run = tecs2d.run({ render = { dropShadowScale = 0.5, -- default; half resolution dropShadowScale = 1.0, -- full resolution (sharper, higher cost) dropShadowScale = 0.25, -- quarter resolution (softer, lower cost) }, }) ``` ## Ambient Light Set the base illumination for unlit areas: ```teal -- Dark ambient for dramatic lighting pipeline:setAmbientLight(0.1, 0.1, 0.15) -- Bright ambient for daytime scenes pipeline:setAmbientLight(0.4, 0.4, 0.45) -- Complete darkness pipeline:setAmbientLight(0, 0, 0) -- Get current ambient local r, g, b = pipeline:getAmbientLight() ``` ## Controlling Lighting Tecs provides multiple levels of control over lighting: pipeline-wide, per-layer, and per-entity. ### Pipeline Configuration Configure lighting behavior when creating the pipeline: ```teal love.run = tecs2d.run({ fps = 60, game = gamePlugin, render = { virtualHeight = 360, lightingMode = "deferred", -- "deferred" (default) or "none" shadowsEnabled = true, -- enable shadow casting (default: true) }, }) ``` | Option | Values | Description | | ---------------- | ---------------------- | --------------------------------------------------------------- | | `lightingMode` | `"deferred"`, `"none"` | Deferred enables full lighting; none renders at full brightness | | `shadowsEnabled` | `true`, `false` | Whether occluders cast shadows | ### Enabling/Disabling Lighting Toggle lighting at runtime: ```teal -- Disable lighting (everything renders at full brightness) pipeline:disableLighting() -- Re-enable lighting pipeline:enableLighting() -- Check current state if pipeline:isLightingEnabled() then -- lighting is active end ``` ### Per-Layer Lighting Mark entire layers as unlit so all entities on that layer render at full brightness: ```teal -- Option 1: Configure layer at setup pipeline:configureLayer(10, { name = "ui", unlit = true -- all entities on layer 10 ignore lighting }) -- Option 2: Toggle at runtime pipeline:setLayerUnlit("ui", true) -- disable lighting for UI layer pipeline:setLayerUnlit("ui", false) -- re-enable lighting -- Check if layer is unlit if pipeline:isLayerUnlit("ui") then -- layer renders at full brightness end ``` This is useful for: * UI layers that should always be visible * Background layers with pre-baked lighting * Debug overlays ### Per-Entity Unlit Mark individual entities as unlit with the `Unlit` tag component: ```teal -- Entity renders at full brightness regardless of lighting world:spawn( Transform(x, y), gfx.Sprite(uiTexture), gfx.Unlit ) ``` `Unlit` composes with the per-layer unlit mask: an entity is unlit if either the tag is present or the layer is configured `unlit = true`. Use this for: * Self-illuminating objects (glowing orbs, neon signs) * UI elements mixed with world entities * Debug markers ### Shadow Self-Prevention and Cross-Occluder Shadows Self-shadow prevention works automatically. The shadow mask stores both an occluder height (R channel, blurred) and an occluder flag (G channel, preserved from center pixel through blur). When the origin pixel is on an occluder, the raymarch skips hits until the ray exits the origin occluder (passes through empty space). This prevents self-shadow acne while still allowing cross-occluder shadows: ball A's shadow can darken ball B. ## Querying Light at a Position You can query how much light hits a specific world position, including shadow occlusion. This is useful for stealth mechanics, AI awareness, or any gameplay that depends on whether a position is lit or in shadow. ```teal -- Returns 0-1 luminance (0 = full darkness, 1 = full brightness) local brightness = pipeline:queryLightAt(worldX, worldY) if brightness < 0.1 then -- Player is hidden in shadow end ``` The first call lazily activates an intermediate render canvas. There is zero performance cost until `queryLightAt` is first called; after that, one extra full-screen blit per frame is added. The first frame after activation returns 0 while the canvas populates. In retro mode, the query reads from the virtual-resolution lit canvas (no extra cost). ## Performance Considerations ### Light Count The lighting system uses GPU compute shader culling, so off-screen lights have minimal cost. Visible lights are the primary performance factor. **Recommendations:** * 1-1000 visible lights: No concerns on modern GPUs * 1000-5000 visible lights: Still performant, monitor frame times * 5000+ visible lights: May need optimization depending on hardware ### Shadow Quality Shadow quality can be adjusted via pipeline configuration: ```teal local config = { -- 1.0 = pixel perfect, 0.5 = 4x fewer pixels -- Scaling down is the fastest way to improve performance shadowMaskScale = 1.0, -- Number of raymarch steps (higher = more accurate, lower = faster) -- Doubled automatically in pixel-perfect mode shadowSteps = 32, -- Expansion of the culling frustum -- Increase if shadows "pop in" at the edge of the screen shadowMargin = 200, } ``` Lower `shadowMaskScale` reduces VRAM usage and improves performance at the cost of shadow precision. --- --- url: /tecs2d/rendering/sprites.md --- # Sprites Tecs provides a complete sprite system with animated sprites from Aseprite, per-frame collision boxes, animation events, and [material](../materials) maps for advanced lighting. Sprites are rendered using GPU instancing for maximum performance. ## Quick Start ```teal local assets = require("tecs2d.assets") local manager = world.resources[assets] -- Load sprite sheet (async, returns handle, caches asset) local handle = manager:loadSpriteSheet("player.png") -- Create sprite from loaded sheet world:spawn( tecs.builtins.Transform(100, 100), gfx.Sprite.fromSheet(handle.value, "walk") ) ``` ## Creating Sprites ### From Asset Manager (Recommended) Use [`loadSpriteSheet`](/tecs2d/assets/api#loadspritesheet) for async loading with automatic caching. ```teal local assets = require("tecs2d.assets") local manager = world.resources[assets] -- Load sprite sheet (async, returns handle) local handle = manager:loadSpriteSheet("player.png") -- Spawn a Sprite and pass in option configuration world:spawn( tecs.builtins.Transform(200, 100), gfx.Sprite.fromSheet(handle.value, "attack", { speed = 1.5, -- 1.5x playback speed centered = true, -- Center on transform (default) pivotSlice = "feet", -- Use "feet" slice for pivot startTime = 0 -- Animation start time }) ) ``` For static images without animation, use [`loadStaticSheet`](/tecs2d/assets/api#loadstaticsheet): ```teal local bgHandle = manager:loadStaticSheet("background.png") world:spawn( tecs.builtins.Transform(0, 0), gfx.Sprite.fromSheet(bgHandle.value) ) ``` ### Convenience Shorthand (Sync) `fromAseprite` combines loading and sprite creation in one call. Useful for prototyping, but blocks the main thread: ```teal -- Loads sheet synchronously and creates sprite gfx.Sprite.fromAseprite("player.png", "walk") -- With options gfx.Sprite.fromAseprite("player.png", "attack", { speed = 1.5, pivotSlice = "feet" }) ``` ### From SpriteSheet (Sync) For loading a sheet once and creating multiple sprites: ```teal -- Load sheet synchronously (blocks main thread) local sheet = gfx.SpriteSheet.fromFile("enemies.png") -- Create multiple sprites from the same sheet local goblin = gfx.Sprite.fromSheet(sheet, "goblin_idle") local orc = gfx.Sprite.fromSheet(sheet, "orc_idle") ``` ### From Texture (Single Frame) For simple textures without animation: ```teal local texture = love.graphics.newImage("bullet.png") gfx.Sprite.fromTexture(texture) ``` ## Sprite Options | Option | Type | Default | Description | | -------------- | --------- | --------- | -------------------------------------- | | `speed` | number | 1.0 | Animation speed multiplier | | `centered` | boolean | true | Center sprite on transform position | | `pivotSlice` | string | nil | Name of Aseprite slice to use as pivot | | `startTime` | number | 0 | Animation start time (for staggering) | ## Sprite Methods ```teal local sprite = world:get(entityId, gfx.Sprite) -- Animation control sprite:setTag("run") -- Change animation tag sprite:playOnce("attack") -- Play a tag once, then freeze on the last frame sprite:pause() -- Pause at current frame sprite:resume() -- Resume from pause sprite:pauseAtEnd() -- Jump to last frame and pause sprite:pauseAtStart() -- Jump to first frame and pause sprite:gotoFrame(3) -- Jump to specific frame (0-indexed) -- Query state local tag = sprite:getTag() -- Current tag name local frame = sprite:getFrame() -- Current frame (0-indexed within tag) local absFrame = sprite:getAbsoluteFrame() -- Absolute frame (1-indexed in sheet) local w, h = sprite:getDimensions() -- Sprite dimensions local px, py = sprite:getPivot() -- Current pivot point local cx, cy = sprite:getCenter() -- Center point (0.5, 0.5) ``` ## Combining with Other Components Sprites work with [styling components](../styling): | Component | Description | | --------------------------------------- | ------------------------------------------------------ | | [`Color`](../styling#color) | RGBA tint applied to the sprite | | [`BlendMode`](../styling#blendmode) | Controls pixel blending (add, multiply, etc.) | | [`Unlit`](../styling#unlit) | Skip dynamic lighting (use for UI sprites) | | [`Pivot`](../styling#pivot) | Custom origin point (overrides `pivotSlice` option) | | [`Material`](../materials) | Per-entity GPU shader effects (dissolve, glow, etc.) | ```teal local sheet = manager:loadSpriteSheet("player.png").value world:spawn( tecs.builtins.Transform(100, 100), gfx.Sprite.fromSheet(sheet, "idle"), gfx.Color(1, 0.8, 0.8, 1), -- Tint gfx.Unlit, -- Skip lighting gfx.Pivot(0.5, 1.0) -- Custom pivot (overrides pivotSlice) ) ``` ## GPU Rendering Architecture Sprites are rendered using a high-performance GPU pipeline that can handle millions of sprites efficiently. ### Texture Buckets Sprite textures are automatically grouped into size-bucketed texture arrays: | Bucket | Max Size | Layers per Array | | -------- | ----------- | ------------------ | | Tiny | 16×16 | 512 | | Small | 64×64 | 256 | | Medium | 256×256 | 128 | | Large | 512×512 | 64 | | XL | 1024×1024 | 32 | | XXL | 2048×2048 | 16 | This architecture enables: * **Single draw call per bucket** - All sprites using textures in the same bucket are batched together * **Automatic allocation** - Textures are assigned to buckets based on their dimensions * **GPU culling** - Visibility culling runs entirely on the GPU via compute shaders ### DirectSprite for Large Textures Textures larger than 2048×2048 cannot use the bucket system. Use the `DirectSprite` component for these: ```teal world:spawn( tecs.builtins.Transform(0, 0), gfx.Sprite.fromTexture(hugeBackgroundTexture), gfx.DirectSprite -- Bypass bucket system, render directly ) ``` `DirectSprite` entities are rendered separately and don't benefit from instanced batching. ## Documentation | Topic | Description | | ------------------------------------------ | -------------------------------------------------------------- | | [Sheets](./sheets) | Loading sprite sheets, Aseprite export settings, material maps | | [Animation](./animation) | Tags, playback control, timing, directions | | [Slices](./slices) | Pivot points, per-frame pivots | | [Collisions](./collisions) | Physics integration from slices | | [Events](./events) | ChangeTag events | | [Tiling](./tiling) | RepeatedSprite for tiling | --- --- url: /tecs2d/rendering/sprites/sheets.md --- # Sprite Sheets Sprite sheets are single images containing multiple animation frames. Tecs uses Aseprite's JSON export format for frame data, tags, and slices. ## File Requirements Each sprite sheet requires: * **Image file**: `character.png` (or other image format) * **JSON file**: `character.json` (Aseprite export) The JSON file must have the same base name as the image. **Example directory structure:** ``` assets/ sprites/ player.png player.json player_n.png # Optional: normal map player_e.png # Optional: emission map player_s.png # Optional: specular map ``` ## Loading Sprite Sheets ::: tip Automatic Material Map Loading All sprite sheet loading methods automatically load optional material maps alongside the main image. If `player.png` is loaded, the loader will also try to load `player_n.png` (normal), `player_e.png` (emission), and `player_s.png` (specular). Missing material maps are silently ignored. ::: ### Synchronous Loading ```teal local gfx = require("tecs2d.gfx") local sheet = gfx.SpriteSheet.fromFile("assets/player.png") -- Load with auto-generated normal map (from luminance) local sheet = gfx.SpriteSheet.fromFile("assets/player.png", { generateNormal = true }) ``` ### Async Loading (Recommended) Use the asset system for non-blocking loads. See [Assets API](/tecs2d/assets/api#loadspritesheet) for full method documentation. ```teal local assets = require("tecs2d.assets") local assetManager = world.resources[assets] local handle = assetManager:loadSpriteSheet("assets/player.png") world:spawn( tecs.builtins.Transform(100, 100), gfx.Sprite.fromSheet(handle.value, "idle") -- Blocks on .value ) ``` ### Static Sheets (No JSON) If you need to load a texture that doesn't have any animation or an Aseprite JSON file, use [`loadStaticSheet`](/tecs2d/assets/api#loadstaticsheet): ```teal local assets = require("tecs2d.assets") local assetManager = world.resources[assets] local handle = assetManager:loadStaticSheet("assets/sprite.png") world:spawn( tecs.builtins.Transform(100, 100), gfx.Sprite.fromSheet(handle.value) ) ``` ## Aseprite Export Settings To export from Aseprite: 1. Go to **File > Export Sprite Sheet** 2. Configure the export: * **Layout**: Horizontal strip, Vertical strip, or Packed * **JSON Data**: Enable and set format to **Array** or **Hash** * **Meta > Frame Tags**: Enable * **Meta > Slices**: Enable (if using collision boxes or pivots) 3. Export to your assets folder **Required settings:** * JSON Data: Enabled (Array or Hash format) * Frame Tags: Enabled **Optional settings:** * Slices: Enable if using collision boxes or pivot points * Layers: Export visible layers only ## Material Maps Sprite sheets support material textures for advanced lighting: | Suffix | Map Type | Description | | -------- | ------------ | -------------------------------------- | | `_n.png` | Normal map | Per-pixel surface normals for lighting | | `_e.png` | Emission map | Self-illuminating areas (glow) | | `_s.png` | Specular map | Shininess/reflectivity | ### Normal Maps Normal maps give sprites depth and respond to lighting direction. **Creating normal maps:** 1. **Manual**: Paint normals in your art program (R=X, G=Y, B=Z) 2. **Aseprite plugin**: Use community normal map plugins 3. **Auto-generate**: Tecs can generate from luminance **Auto-generated normal maps:** ```teal -- Generate from sprite luminance at load time local sheet = gfx.SpriteSheet.fromFile("sprite.png", { generateNormal = true }) -- Or generate after loading local sheet = gfx.SpriteSheet.fromFile("sprite.png") sheet:generateNormalMap() ``` Auto-generation uses luminance (brightness) as height, creating a beveled effect. ### Emission Maps Emission maps define areas that glow regardless of lighting. The RGB values are added to the final color. Used for things like glowing eyes, neon signs, fire, etc. **File naming:** `sprite_e.png` ### Specular Maps Specular maps control surface reflectivity, creating shiny highlights when light hits at the right angle. Used for metal, wet surfaces, glass, etc. **File naming:** `sprite_s.png` **Channel encoding:** * **RGB**: Specular color/intensity (brighter = shinier) * **Alpha**: Shininess exponent (0 = broad highlights, 1 = tight/sharp highlights) ## SpriteSheet API ### Loading ```teal -- From Aseprite file (requires JSON, supports animation) local sheet = gfx.SpriteSheet.fromFile("sprite.png") local sheet = gfx.SpriteSheet.fromFile("sprite.png", { generateNormal = true }) -- From image file (single frame, no JSON needed, auto-loads material maps) local sheet = gfx.SpriteSheet.fromTextureFile("sprite.png") local sheet = gfx.SpriteSheet.fromTextureFile("sprite.png", { generateNormal = true }) -- From texture (single frame, auto-loads material maps if path provided) local sheet = gfx.SpriteSheet.fromTexture(loveTexture) local sheet = gfx.SpriteSheet.fromTexture(loveTexture, "sprite.png") -- with material maps local sheet = gfx.SpriteSheet.fromTexture(loveTexture, "sprite.png", { generateNormal = true }) -- From image data (for async loading, does not auto-load material maps) local sheet = gfx.SpriteSheet.fromImage(image, jsonData, "sprite.png") ``` ### Dimensions ```teal local sheetW, sheetH = sheet:getSheetSize() -- Total texture size local spriteW, spriteH = sheet:getSpriteSize() -- Individual frame size local image = sheet:getImage() -- Love2D texture ``` ### Frame Data ```teal local frame = sheet:getFrame(1) -- 1-indexed print(frame.x, frame.y) -- Position in atlas print(frame.w, frame.h) -- Frame dimensions print(frame.duration) -- Duration in seconds ``` ### Frame Tags ```teal -- Check if tag exists if sheet:hasFrameTag("walk") then local tag = sheet:getFrameTag("walk") print(tag.from, tag.to) -- Frame range (1-indexed) print(tag.direction) -- 0=forward, 1=reverse, 2=pingpong print(tag.duration) -- Total tag duration in seconds end ``` ### Slices ```teal local slice = sheet:getSlice("hitbox") if slice then print(slice.data) -- User data string from Aseprite for i, key in ipairs(slice.keys) do print(key.frame) -- Frame number this applies to print(key.x, key.y) -- Slice position print(key.w, key.h) -- Slice dimensions if key.hasPivot then print(key.pivotX, key.pivotY) end end end ``` ### Material Textures ```teal local normalTex = sheet:getNormalImage() -- Normal map or nil local emissionTex = sheet:getEmissionImage() -- Emission map or nil local specularTex = sheet:getSpecularImage() -- Specular map or nil -- Generate normal from luminance sheet:generateNormalMap() ``` ### Identifiers ```teal local id = sheet:getId() -- Unique sheet ID (for caching) local path = sheet:getPath() -- File path (for serialization) local tagId = sheet:getTagId("walk") -- Tag name to ID local tagName = sheet:getTagName(1) -- Tag ID to name ``` ## Builder API For non-Aseprite atlas formats (TexturePacker, ShoeBox, custom tools), use the builder API to construct sprite sheets programmatically: ```teal local gfx = require("tecs2d.gfx") -- Load your atlas image local image = love.graphics.newImage("atlas.png") -- Build the sprite sheet (pass path for material map auto-loading) local sheet = gfx.SpriteSheet.builder(image, "atlas.png") :frame(0, 0, 32, 32, 100) -- x, y, w, h, duration_ms :frame(32, 0, 32, 32, 100) :frame(64, 0, 32, 32, 100) :frame(96, 0, 32, 32, 100) :tag("idle", 0, 1, "forward") -- name, from, to (0-indexed), direction :tag("walk", 2, 3, "pingpong") :slice("hitbox", 4, 4, 24, 28) -- name, x, y, w, h :sliceWithPivot("feet", 0, 0, 32, 32, 16, 30) -- with pivot point :loadMaterialMaps() -- auto-load atlas_n.png, atlas_e.png, atlas_s.png :build() -- Use the sheet like any other world:spawn( tecs.builtins.Transform(100, 100), gfx.Sprite.fromSheet(sheet, "idle") ) ``` ### Builder Methods | Method | Description | | --------------------------------------------------------- | ----------------------------------------- | | `frame(x, y, w, h, duration?)` | Add a frame (duration in ms, default 100) | | `tag(name, from, to, direction?)` | Add animation tag (0-indexed frames) | | `slice(name, x, y, w, h)` | Add a slice region | | `sliceWithPivot(name, x, y, w, h, pivotX, pivotY)` | Add slice with pivot point | | `normalImage(image)` | Set normal map texture | | `emissionImage(image)` | Set emission map texture | | `specularImage(image)` | Set specular map texture | | `loadMaterialMaps(options?)` | Load \_n/\_e/\_s maps from filesystem | | `build()` | Create the SpriteSheet | ### Direction Values * `"forward"` (default): Play frames in order * `"reverse"`: Play frames in reverse order * `"pingpong"`: Play forward then reverse ### Converting from Other Formats To integrate with other atlas tools, parse their output format and call the builder methods: ```teal -- Example: parse a simple JSON atlas format local json = require("tecs.utils.json") local atlasData = json.parse(love.filesystem.read("atlas.json")) local builder = gfx.SpriteSheet.builder( love.graphics.newImage(atlasData.imagePath), atlasData.imagePath -- path enables loadMaterialMaps() ) for _, frame in ipairs(atlasData.frames) do builder:frame(frame.x, frame.y, frame.width, frame.height, frame.duration or 100) end for _, anim in ipairs(atlasData.animations) do builder:tag(anim.name, anim.start, anim["end"], anim.direction or "forward") end local sheet = builder:loadMaterialMaps():build() ``` ## Normal Map Format Normal maps use tangent-space encoding: | Channel | Axis | Range | Meaning | | --------- | ------ | ------- | ------------------------------- | | Red | X | 0-1 | Left (-1) to Right (+1) | | Green | Y | 0-1 | Up (-1) to Down (+1) | | Blue | Z | 0-1 | Into screen (0) to Out (+1) | | Alpha | - | 0-1 | 0 = no normal data, use default | A flat surface facing the camera has RGB = (0.5, 0.5, 1.0). Transparent pixels (alpha < 0.01) in the normal map fall back to the default normal (0.5, 0.5, 1.0), which represents a flat surface. ## Performance Notes * Sheets are cached by path; loading the same file returns the cached sheet * Material maps are optional; omitting them has no performance cost * Auto-generating normal maps happens at load time (slight delay) * Textures are automatically allocated into size-bucketed texture arrays for efficient GPU batching * All sprites within a size bucket (e.g., all 64×64 or smaller textures) are rendered in a single draw call * Textures larger than 2048×2048 require the `DirectSprite` component and are rendered separately --- --- url: /tecs2d/rendering/sprites/animation.md --- # Animation Sprites support frame-based animation using Aseprite frame tags. Each tag defines a named sequence of frames with timing and playback direction. ## Frame Tags Frame tags are named animation sequences defined in Aseprite (e.g., "idle", "walk", "attack"). ### Defining Tags in Aseprite 1. Select frames in the timeline 2. Right-click and choose **New Tag** 3. Name the tag and set properties: * **Direction**: Forward, Reverse, or Ping-pong * **Repeat**: Number of times to loop (0 = infinite) ### Using Tags in Code ```teal local gfx = require("tecs2d.gfx") -- Create sprite with initial tag world:spawn( tecs.builtins.Transform(100, 100), gfx.Sprite.fromAseprite("player.png", "idle") ) -- Change tag at runtime local sprite = world:get(entityId, gfx.Sprite) sprite:setTag("walk") ``` ### Empty Tag Passing an empty string (`""`) plays all frames in order: ```teal gfx.Sprite.fromAseprite("player.png", "") -- All frames ``` ## Playback Directions Tags support three playback directions: | Direction | Constant | Behavior | | ----------- | ---------- | ---------------------- | | Forward | `0` | Frames 1 → N, loop | | Reverse | `1` | Frames N → 1, loop | | Ping-pong | `2` | Frames 1 → N → 1, loop | ```teal local sheet = gfx.SpriteSheet.fromFile("player.png") local tag = sheet:getFrameTag("bounce") if tag.direction == 2 then -- Ping-pong print("This animation bounces back and forth") end ``` ## Playback Control ### Pause and Resume ```teal local sprite = world:get(entityId, gfx.Sprite) -- Pause at current frame sprite:pause() -- Resume from paused frame sprite:resume() -- Synchronized batch pause (all sprites pause at same visual frame) local currentTime = love.timer.getTime() for entity in entities do local spr = world:get(entity, gfx.Sprite) spr:pause(currentTime) end ``` ### Seek to Frame ```teal -- Jump to first frame and pause sprite:pauseAtStart() -- Jump to last frame and pause sprite:pauseAtEnd() -- Jump to specific frame (0-indexed within current tag) sprite:gotoFrame(3) ``` ### Query Current Frame ```teal -- Frame index within current tag (0-indexed) local frame = sprite:getFrame() -- Absolute frame number in sheet (1-indexed) local absFrame = sprite:getAbsoluteFrame() -- Current tag name local tag = sprite:getTag() ``` ## Speed Control ### At Creation ```teal gfx.Sprite.fromAseprite("player.png", "walk", { speed = 0.5 -- Half speed (slow motion) }) gfx.Sprite.fromAseprite("player.png", "run", { speed = 2.0 -- Double speed }) ``` ### Speed Values | Value | Effect | | ------- | ------------------------ | | `1.0` | Normal speed (default) | | `0.5` | Half speed (slow motion) | | `2.0` | Double speed | | `0.0` | Effectively paused | **Note:** Speed is set at sprite creation and cached in the instance. To change speed, create a new sprite or use different speed values. ## Frame Timing Each frame can have its own duration, set in Aseprite's timeline. ### Variable Frame Timing In Aseprite: 1. Select a frame in the timeline 2. Right-click and choose **Frame Properties** 3. Set the duration in milliseconds Tecs respects per-frame durations, enabling effects like: * Hold poses at the end of an attack * Quick anticipation frames before an action * Slower impact frames for emphasis ### Accessing Timing ```teal local sheet = gfx.SpriteSheet.fromFile("player.png") -- Single frame duration local frame = sheet:getFrame(1) print(frame.duration) -- Duration in seconds -- Total tag duration local tag = sheet:getFrameTag("attack") print(tag.duration) -- Total duration in seconds ``` ## Staggered Start Times To prevent synchronized animations looking unnatural, use `startTime`: ```teal -- Spawn crowd with staggered animations for i = 1, 20 do world:spawn( tecs.builtins.Transform(i * 30, 100), gfx.Sprite.fromAseprite("npc.png", "idle", { startTime = math.random() * 2.0 -- Random 0-2 second offset }) ) end ``` ## Animation Events For callbacks when animation tags change, see [Events](./events). ## Common Patterns ### One-Shot Animation Play an animation once and freeze on the last frame: ```teal local sprite = world:get(entityId, gfx.Sprite) sprite:playOnce("attack") ``` ### Hold Last Frame Play animation once, then switch to idle: ```teal local sprite = world:get(entityId, gfx.Sprite) sprite:playOnce("death", function(anim) anim:setTag("idle") end) ``` `playOnce` uses the same CPU timing data as sprite playback, so the callback runs when the animation reaches its terminal frame without needing GPU readback. ## Tiled Animated Tiles Tiles with animation defined in Tiled are automatically spawned as Sprite entities with globally-synced animation. Each animated tile includes a [TileSource](/tecs2d/tiled/tile-source) component that provides access to the original tile's metadata (properties, class, tileset info). See [TileSource Component](/tecs2d/tiled/tile-source) for querying and working with animated tiles from Tiled maps. --- --- url: /tecs2d/rendering/sprites/slices.md --- # Slices and Pivots Aseprite slices define regions within sprite frames for pivot points, collision boxes, and custom metadata. Slices can have different values per frame for animation-aware positioning. ## Creating Slices in Aseprite 1. Open your sprite in Aseprite 2. Go to **Frame > Slices** or press `Ctrl+Shift+9` 3. Create a new slice with the slice tool 4. Name the slice (e.g., "pivot", "hitbox", "feet") 5. Optionally set pivot and center points 6. Export with **Slices** enabled in JSON settings ## Pivot Points Pivot points control the origin for positioning and rotation. ### Default Centering By default, sprites are centered on the transform position: ```teal gfx.Sprite.fromAseprite("player.png", "idle", { centered = true -- Default: sprite center at transform }) ``` ### Slice-Based Pivots Use an Aseprite slice as the pivot point: ```teal gfx.Sprite.fromAseprite("player.png", "attack", { pivotSlice = "weapon_origin" -- Use this slice's pivot }) ``` In Aseprite, set the slice's pivot point: 1. Select the slice 2. In the slice properties, enable **Pivot** 3. Position the pivot within the slice bounds ### Top-Left Origin For classic sprite positioning (origin at top-left): ```teal gfx.Sprite.fromAseprite("player.png", "idle", { centered = false -- Origin at (0, 0) }) ``` ### Fallback Behavior When `pivotSlice` is specified but the slice or pivot isn't found: | `centered` | Fallback | | ------------ | ---------------------------- | | `true` | Use sprite center (0.5, 0.5) | | `false` | Use top-left (0, 0) | ## Per-Frame Pivots Slices can have different pivot points per frame, useful for: * Weapon rotation points that shift during swings * Feet positions for ground-aligned characters * Hand positions for held items ### Defining Per-Frame Pivots in Aseprite 1. Create a slice 2. Navigate to different frames 3. Adjust the slice position/pivot for each frame 4. Aseprite stores "keys" for frames where the slice changes The animation system automatically uses the correct pivot for each frame. ### Example: Rotating Sword ```teal -- In Aseprite: -- - Create a "sword_pivot" slice -- - On frame 1: pivot at sword handle -- - On frame 5: pivot shifts as sword swings world:spawn( tecs.builtins.Transform(100, 100, 5, 0, { rotation = 0 }), gfx.Sprite.fromAseprite("sword.png", "swing", { pivotSlice = "sword_pivot" }) ) -- Rotation will occur around the current frame's pivot ``` ## Accessing Slice Data ```teal local sheet = gfx.SpriteSheet.fromFile("player.png") local slice = sheet:getSlice("hitbox") if slice then -- User data from Aseprite print(slice.data) -- Iterate slice keys (per-frame data) for i, key in ipairs(slice.keys) do print("Frame:", key.frame) print("Position:", key.x, key.y) print("Size:", key.w, key.h) if key.hasPivot then print("Pivot:", key.pivotX, key.pivotY) end if key.hasCenter then print("Center:", key.centerX, key.centerY) end end end ``` ## Slice Key Properties | Property | Type | Description | | -------------------- | ------- | -------------------------------- | | `frame` | integer | Frame number this key applies to | | `x`, `y` | number | Slice position within frame | | `w`, `h` | number | Slice dimensions | | `hasCenter` | boolean | Whether center point is defined | | `centerX`, `centerY` | number | Center point coordinates | | `hasPivot` | boolean | Whether pivot point is defined | | `pivotX`, `pivotY` | number | Pivot point coordinates | ## Common Slice Patterns ### Feet Position Ground-aligned characters with feet at the bottom: ```teal -- In Aseprite: Create "feet" slice at character's feet with pivot world:spawn( tecs.builtins.Transform(100, groundY), -- Transform at ground level gfx.Sprite.fromAseprite("player.png", "idle", { pivotSlice = "feet" -- Feet touch the ground }) ) ``` ### Weapon Attach Point Attach point for weapons or effects: ```teal -- In Aseprite: Create "hand" slice at character's hand -- Get hand position for spawning weapon local sprite = world:get(playerId, gfx.Sprite) local frame = sprite:getAbsoluteFrame() local sheet = gfx.SpriteSheet.fromFile("player.png") local slice = sheet:getSlice("hand") -- Find key for current frame for _, key in ipairs(slice.keys) do if key.frame == frame then -- Spawn weapon at hand position local handX = playerX + key.x local handY = playerY + key.y break end end ``` ### Hit/Hurt Boxes Collision regions for combat: ```teal -- In Aseprite: Create "hitbox" and "hurtbox" slices -- Read hitbox for current frame local sprite = world:get(entityId, gfx.Sprite) local hitbox = getSliceForFrame(sheet, "hitbox", sprite:getAbsoluteFrame()) ``` ## Overriding with Pivot Component The `Pivot` component overrides slice-based pivots: ```teal -- This uses the Pivot component, not pivotSlice world:spawn( tecs.builtins.Transform(100, 100), gfx.Sprite.fromAseprite("player.png", "idle", { pivotSlice = "feet" -- Ignored when Pivot component exists }), gfx.Pivot(0.5, 1.0) -- Bottom-center pivot ) ``` Priority order: 1. `Pivot` component (highest) 2. `pivotSlice` option 3. `centered` option (default: true → center, false → top-left) --- --- url: /tecs2d/rendering/sprites/collisions.md --- # Sprite Collisions Tecs can automatically create physics collision shapes from Aseprite slices, allowing you to define collision boxes visually and have them applied to physics bodies. ## Quick Start ```teal local tecs = require("tecs") local physics = require("tecs2d.physics") local gfx = require("tecs2d.gfx") local world = tecs.newWorld() -- Initialize physics love.physics.setMeter(64) world:addPlugin(physics.new({ world = love.physics.newWorld(0, 800, true) })) -- Register a collision template gfx.SpriteCollision.addTemplate("PLAYER", { shape = "circle", restitution = 0.0, friction = 1.0, }) -- Spawn sprite with collision from slice world:spawn( tecs.builtins.Transform(100, 100), gfx.Sprite.fromAseprite("player.png", "idle"), gfx.SpriteCollision.new({ bodyType = "dynamic", fixedRotation = true, -- default for SpriteCollision dynamics slices = { collision = "PLAYER" -- Use "collision" slice with PLAYER template } }) ) ``` ## Defining Collision Slices in Aseprite 1. Open your sprite in Aseprite 2. Create a slice named "collision" (or any name you prefer) 3. Position and size the slice to define your collision box 4. Export with **Slices** enabled ::: tip Platformer Characters For platformer characters, place the collision slice at the character's feet (lower half). This prevents the head from catching on platforms. ::: ## SpriteCollision Component ```teal gfx.SpriteCollision.new({ bodyType = "dynamic", -- or "static" fixedRotation = true, -- dynamic only; defaults to true slices = { collision = "TEMPLATE_NAME", -- Slice name → template name hitbox = "HITBOX", -- Multiple slices supported } }) ``` ### Configuration | Property | Type | Description | | ---------- | ------ | ------------------------------------------------- | | `bodyType` | string | `"dynamic"` or `"static"` | | `fixedRotation` | boolean | Keeps auto-attached dynamic bodies upright. Defaults to `true`. | | `slices` | table | Map of slice names to templates or inline configs | ### Body Types | Type | Description | Use For | | ----------- | ------------------------------ | ----------------------------- | | `"dynamic"` | Affected by forces and gravity | Players, enemies, projectiles | | `"static"` | Immovable | Platforms, walls, terrain | ## Collision Templates Templates define reusable physics configurations. Using templates enables serialization since template names are stored instead of full configs. ### Registering Templates ```teal -- Register at startup gfx.SpriteCollision.addTemplate("PLAYER", { shape = "circle", restitution = 0.0, friction = 1.0, }) gfx.SpriteCollision.addTemplate("BOUNCY", { shape = "circle", restitution = 0.8, friction = 0.3, }) gfx.SpriteCollision.addTemplate("TRIGGER", { shape = "rectangle", sensor = true, -- Detects overlaps without collision }) ``` ### Template Properties | Property | Type | Default | Description | | ------------- | ------- | ------------- | ----------------------------------------------- | | `shape` | string | `"rectangle"` | `"circle"` or `"rectangle"` | | `sensor` | boolean | `false` | Detect overlaps without physical collision | | `restitution` | number | `0` | Bounciness (0 = none, 1 = full) | | `friction` | number | `0.3` | Surface friction | | `categories` | integer | `0xFFFF` | Collision category bitmask | | `mask` | integer | `0xFFFF` | Collision mask bitmask | | `groupIndex` | integer | `0` | Group index (-N never collides with same group) | ### Retrieving Templates ```teal local template = gfx.SpriteCollision.getTemplate("PLAYER") ``` ## Inline Configuration Instead of templates, define collision properties inline: ```teal gfx.SpriteCollision.new({ bodyType = "dynamic", slices = { collision = { shape = "circle", restitution = 0.0, friction = 1.0, } } }) ``` ## Multiple Slices Define multiple shapes from different slices: ```teal gfx.SpriteCollision.addTemplate("BODY", { shape = "circle", restitution = 0.0, }) gfx.SpriteCollision.addTemplate("HITBOX", { shape = "rectangle", sensor = true, -- No physical collision }) world:spawn( tecs.builtins.Transform(100, 100), gfx.Sprite.fromAseprite("player.png", "idle"), gfx.SpriteCollision.new({ bodyType = "dynamic", fixedRotation = true, slices = { collision = "BODY", -- Physical collision hitbox = "HITBOX", -- Damage detection sensor } }) ) ``` ## Shape Calculation ### Circle Shapes For circles, the radius is calculated as `min(width, height) / 2` from the slice bounds. ### Rectangle Shapes Rectangles use the full width and height of the slice bounds. ### Offset Calculation Shape positions are offset from the sprite's geometric center: ``` offsetX = (slice.x + slice.width/2) - (spriteWidth/2) offsetY = (slice.y + slice.height/2) - (spriteHeight/2) ``` A slice centered on the sprite has offset (0, 0). A slice at the sprite's feet has a positive Y offset. ## Sensors Sensors detect overlaps without causing physical collision: ```teal gfx.SpriteCollision.addTemplate("PICKUP_ZONE", { shape = "circle", sensor = true, }) ``` **Use cases:** * Hitboxes for combat * Trigger zones for events * Pickup detection for items * Ground detection for jumping ## Required Components `SpriteCollision` expects these systems/components to be available: | Component | Purpose | | ------------------------------------------- | ---------------------- | | `Sprite` | Provides slice data | | `physics` plugin | Creates Box2D bodies | When physics is available, `SpriteCollision` auto-attaches a default `physics.Collider` plus either `physics.RigidBody` or `physics.StaticBody` based on `bodyType`. ```teal world:spawn( tecs.builtins.Transform(x, y), gfx.Sprite.fromAseprite("player.png", "idle"), gfx.SpriteCollision.new({ bodyType = "dynamic", fixedRotation = true, -- optional; default is true slices = { ... }, }) ) ``` ## Collision Events Handle collision events using the physics event system: ```teal world:observe(playerId, physics.BeginContact, function(event: physics.BeginContact) print("Collided with:", event.other) -- Access slice name via shape user data local data = event.shape:getUserData() if data then print("Slice:", data.name) end end) world:observe(playerId, physics.EndContact, function(event) print("Stopped colliding with:", event.other) end) ``` ## Limitations ::: warning First Frame Only Collision shapes are created from the slice position in the **first frame** of the current animation tag. If your slice has per-frame keys, only the first frame is used. For animated collision shapes, you'll need to update shapes manually. ::: ## Example: Platformer Character ```teal gfx.SpriteCollision.addTemplate("PLAYER_BODY", { shape = "circle", restitution = 0.0, friction = 0.0, -- Low friction for responsive movement }) gfx.SpriteCollision.addTemplate("GROUND_SENSOR", { shape = "rectangle", sensor = true, }) local playerId = world:spawn( tecs.builtins.Transform(100, 300), gfx.Sprite.fromAseprite("player.png", "idle"), gfx.SpriteCollision.new({ bodyType = "dynamic", fixedRotation = true, slices = { collision = "PLAYER_BODY", feet = "GROUND_SENSOR", } }) ) -- Track ground contact local onGround = false world:observe(playerId, physics.BeginContact, function(event: physics.BeginContact) local data = event.shape:getUserData() if data and data.name == "feet" then onGround = true end end) world:observe(playerId, physics.EndContact, function(event: physics.EndContact) local data = event.shape:getUserData() if data and data.name == "feet" then onGround = false end end) ``` --- --- url: /tecs2d/rendering/sprites/events.md --- # Animation Events Sprites emit `gfx.ChangeTag` when their animation tag changes. Events are emitted to the entity's address in the world's event system. ## Available Events | Event | When It Fires | | --------------- | ----------------------------------------------- | | `gfx.ChangeTag` | When the animation tag changes via `setTag()` | ## ChangeTag Event `ChangeTag` fires when `setTag()` is called, including the initial tag set during spawn. ### Event Properties | Property | Type | Description | | -------- | ------- | -------------------------------------------- | | `entity` | integer | The entity ID | | `oldTag` | string | Previous animation tag (empty on first load) | | `newTag` | string | New animation tag | | `anim` | Sprite | Reference to the Sprite component | ### Examples ```teal world:observe(entityId, gfx.ChangeTag, function(event) print("Entity", event.entity, "changed from", event.oldTag, "to", event.newTag) end) ``` ```teal world:observe(entityId, gfx.ChangeTag, function(event) if event.newTag == "death" then world:remove(entityId, PlayerController) end end) ``` ```teal local sounds = { walk = love.audio.newSource("sounds/footsteps.ogg", "static"), attack = love.audio.newSource("sounds/sword.ogg", "static"), } world:observe(entityId, gfx.ChangeTag, function(event) local sound = sounds[event.newTag] if sound then sound:stop() sound:play() end end) ``` ## Event Lifetimes Direct constructors such as `gfx.ChangeTag(...)` allocate a fresh event instance. Internal sprite code uses `world:emit(entityId, gfx.ChangeTag, ...)`, which reuses world-local storage and should be treated as callback-local. Do not store references to event objects received from observers. ```teal local lastTag = "" world:observe(entityId, gfx.ChangeTag, function(event) lastTag = event.newTag end) ``` --- --- url: /tecs2d/rendering/sprites/tiling.md --- # Sprite Tiling The `RepeatedSprite` component tiles a sprite across an area, useful for repeating textures like terrain, backgrounds, or patterns. ![RepeatedSprite tiling a single sprite into a larger area](/images/repeat-sprite.png) ## Quick Start ```teal local gfx = require("tecs2d.gfx") -- Create a tiled ground texture world:spawn( tecs.builtins.Transform(0, 500), gfx.Sprite.fromTexture(grassTexture), -- what to repeat gfx.RepeatedSprite({ repeatX = true, -- Tile horizontally repeatY = false, -- Tile vertically width = 800, -- Total width to fill height = 32, -- Total height to fill }) ) ``` ## RepeatedSprite Component ### Properties | Property | Type | Default | Description | | ----------- | --------- | --------- | -------------------------------- | | `repeatX` | boolean | `true` | Tile horizontally | | `repeatY` | boolean | `true` | Tile vertically | | `width` | number | `0` | Total width of the tiled area | | `height` | number | `0` | Total height of the tiled area | ### Required Components | Component | Description | | ------------- | ---------------------------------------------- | | `Transform` | Position of the tiled area (top-left corner) | | `Sprite` | The texture to tile | ## Animated sprites `RepeatedSprite` works with [animated sprites](./animation): ```teal -- Animated water tiles world:spawn( tecs.builtins.Transform(0, waterY), gfx.Sprite.fromAseprite("water.png", "ripple"), gfx.RepeatedSprite({ repeatX = true, repeatY = false, width = levelWidth, height = 32 }) ) ``` All tiles share the same animation state. ## Transform Support Rotation and scale from `Transform` apply to the entire tiled rectangle as a unit. The geometry (quad) is transformed around the pivot point; the individual tiles inside remain axis-aligned relative to each other. ```teal world:spawn( tecs.builtins.Transform(400, 300, 1, 1, { rotation = math.pi / 6, -- 30 degree rotation scaleX = 1.5, scaleY = 1.5 }), gfx.Sprite.fromTexture(patternTexture), gfx.RepeatedSprite({ repeatX = true, repeatY = true, width = 200, height = 200 }) ) ``` ## Dynamic Resizing You can update the RepeatedSprite at runtime, just be sure to [mark the `RepeatedSprite` column dirty](/tecs/components/dirty-tracking) on the archetype to trigger a resync. ```teal local repeated = world:get(entityId, gfx.RepeatedSprite) repeated.width = newWidth repeated.height = newHeight world:markComponentDirty(entityId, gfx.RepeatedSprite) -- Trigger re-sync ``` ## Combining with Styling A `RepeatedSprite` entity can be styled with the various [styling components](/tecs2d/rendering/styling) like `Color`, `Unlit`, and blend modes. ```teal world:spawn( tecs.builtins.Transform(0, 0, 1), gfx.Color(0.2, 0.2, 0.3, 0.5), gfx.Unlit, gfx.Sprite.fromTexture(gridTexture), gfx.RepeatedSprite({ repeatX = true, repeatY = true, width = screenWidth, height = screenHeight }), ) ``` [Materials](/tecs2d/rendering/materials) can also be applied for custom effects: ```teal local water = gfx.newMaterial("water", { fragment = "shaders/water.glsl" }) world:spawn( tecs.builtins.Transform(0, 0, 1), gfx.Sprite.fromTexture(waterTexture), gfx.RepeatedSprite({ repeatX = true, repeatY = false, width = levelWidth, height = 64 }), water ) ``` ## Performance Notes * Tiled sprites are rendered efficiently using UV wrapping * The entire tiled area is a single draw call --- --- url: /tecs2d/rendering/materials.md --- # Materials Materials provide GPU-batched fragment shader injection for per-entity visual effects like dissolve, glow, and custom shading. ## Quick Start Register a material, then attach it to entities as a component: ```teal local gfx = require("tecs2d.gfx") -- Register a material from a fragment shader file local lava = gfx.newMaterial("lava", { fragment = "shaders/lava.glsl" }) -- Or use inline GLSL (strings not ending in .glsl are treated as source) local flash = gfx.newMaterial("flash", { fragment = [[ MaterialOutput material(MaterialInput i) { MaterialOutput o = standardMaterial(i); o.albedo.rgb = mix(o.albedo.rgb, vec3(1.0), i.params.x); return o; } ]], }) -- Use it like any other component world:spawn( tecs.builtins.Transform(100, 100), gfx.Rectangle(32, 32), lava ) ``` ## Writing a Material Shader A material shader is a Love2D compatible `.glsl` file containing a single function with this signature: ```glsl MaterialOutput material(MaterialInput i) { MaterialOutput o = standardMaterial(i); // Modify o as needed return o; } ``` ### MaterialInput The input struct provides context about the current fragment: ```glsl struct MaterialInput { // Entity color (from Color component, post-texture for sprites) vec4 color; // Texture coordinates (sprites) or normalized local coordinates vec2 uv; // World-space position of the fragment vec2 worldPos; // Normalized 0..1 position within the shape (0,0 = top-left, 1,1 = bottom-right) vec2 localPos; // Signed distance from shape edge (shapes only; 0 at edge, negative inside) float sdfDist; // Game time in seconds (respects time scale and pause) float time; // Per-entity parameters (p0, p1, p2, p3) vec4 params; }; ``` #### localPos Coordinate Spaces The `localPos` field provides normalized 0..1 coordinates within each shape type: | Shape | (0,0) | (1,1) | Notes | | ----------- | -------------------------- | ----------------------------- | -------------------------------------------------------- | | Rectangle | Top-left | Bottom-right | Aligned with shape bounds | | Circle | Top-left of bounding box | Bottom-right of bounding box | Use `sdfDist` for radial effects | | Ellipse | Top-left of bounding box | Bottom-right of bounding box | | | Arc | Top-left of bounding box | Bottom-right of bounding box | | | Line | Start point | End point | x = distance along line, y = across width (0.5 = center) | | Sprite | Top-left | Bottom-right | Matches UV coordinates | ### MaterialOutput The output struct controls what gets written to the G-buffer: ```glsl struct MaterialOutput { // Color output vec4 albedo; // Surface normal, encoded 0..1 (default: 0.5, 0.5, 1.0 = flat facing camera) vec3 normal; // 1.0 = receives lighting, 0.0 = fullbright/unlit float lit; // RGB = intensity, A = shininess vec4 specular; // Emission glow (RGB = color, A = intensity) vec4 emission; }; ``` ### standardMaterial() Call `standardMaterial(i)` to get the same output the shape would produce without a custom material. For sprites, this includes sampled normal, emission, and specular maps (from `_n`, `_e`, `_s` texture suffixes). For shapes, this includes computed normals (hemisphere for circles/ellipses, flat for rectangles/lines). Then modify only what you need: ```glsl MaterialOutput material(MaterialInput i) { MaterialOutput o = standardMaterial(i); o.albedo.rgb *= vec3(1.0, 0.5, 0.5); // Tint red, keep normal/specular/emission maps return o; } ``` This is the recommended starting point for most materials. A dissolve effect, hit flash, or color tint only needs to modify albedo or emission while preserving the standard surface properties. ### defaultMaterial() Call `defaultMaterial(i)` to get flat defaults: entity color as albedo, flat normal, fully lit, no specular/emission. Unlike `standardMaterial()`, this ignores any auto-loaded texture maps. Use this when you want full control over every G-buffer channel: ```glsl MaterialOutput material(MaterialInput i) { MaterialOutput o = standardMaterial(i); o.normal = vec3(0.5 + sin(i.time), 0.5, 1.0); // Custom animated normal return o; } ``` ### Full Template ```glsl MaterialOutput material(MaterialInput i) { MaterialOutput o; o.albedo = i.color; o.normal = vec3(0.5, 0.5, 1.0); // Flat normal o.lit = 1.0; o.specular = vec4(0.0); o.emission = vec4(0.0); return o; } ``` ### Discarding Fragments You can use `discard` in material shaders to fully reject a fragment: ```glsl if (someCondition) discard; ``` Setting `o.albedo.a = 0.0` also works. ## Vertex Displacement Materials can optionally include a vertex function that offsets entity positions in world space. This is useful for wind sway, water ripple, breathing animations, and other effects that move geometry without changing the entity's transform. Register a material with a vertex function. The fragment is optional; if omitted it defaults to `standardMaterial(i)`: ```teal local windSway = gfx.newMaterial("wind_sway", { vertex = "shaders/wind_sway_vert.glsl", }) ``` The vertex file contains a single `materialVertex` function: ```glsl // shaders/wind_sway_vert.glsl // params.x = sway amplitude vec2 materialVertex(vec2 worldPos, float time, vec4 params) { float sway = sin(time * 2.0 + worldPos.y * 0.05) * params.x; return worldPos + vec2(sway, 0.0); } ``` | Parameter | Type | Description | | ------------ | -------- | ------------------------------------------------------------------------------ | | `worldPos` | `vec2` | World-space vertex position (after all transforms: pivot, rotation, scale) | | `time` | `float` | Game time in seconds (same as `i.time` in fragment) | | `params` | `vec4` | Per-entity parameters (same as `i.params` in fragment) | Return the modified world position. Return `worldPos` unchanged for no displacement. The displacement applies to all vertices of the entity, so it moves the entire shape. It runs after rotation and scale but before screen-space conversion, so the offset is in world units. ## Per-Entity Parameters Each entity can pass 4 float parameters (p0-p3) to its material via `withParams()`: ```teal -- All entities share the "dissolve" material but each has its own threshold local dissolve = gfx.newMaterial("dissolve", { fragment = "shaders/dissolve.glsl" }) world:spawn( Transform(0, 0), gfx.Rectangle(32, 32), dissolve:withParams(0.3) ) world:spawn( Transform(50, 0), gfx.Rectangle(32, 32), dissolve:withParams(0.7) ) ``` Access parameters in the shader via `i.params`: ```glsl MaterialOutput material(MaterialInput i) { MaterialOutput o = standardMaterial(i); float threshold = i.params.x; // p0 float noise = fract( sin(dot(i.localPos, vec2(12.9898, 78.233))) * 43758.5453 ); if (noise < threshold) { o.albedo.a = 0.0; // Discard via alpha } return o; } ``` ::: tip GLSL vec4 swizzles `i.params` is a `vec4`, so you can access its components using any GLSL swizzle: `.x`/`.y`/`.z`/`.w`, `.r`/`.g`/`.b`/`.a`, or `.s`/`.t`/`.p`/`.q`. They all map to p0-p3 respectively. ::: To animate parameters at runtime, get the component and set the fields: ```teal local mat = world:get(entityId, gfx.Material) mat.p0 = newThreshold world:markComponentDirty(entityId, gfx.Material) -- Trigger GPU re-sync ``` ## Material Textures Materials can bind up to 4 custom textures: ```teal local frost = gfx.newMaterial("frost", { fragment = "shaders/frost.glsl", textures = { frostPattern = "textures/frost_noise.png", iceNormal = "textures/ice_normal.png", } }) ``` Access them in the shader as uniforms: ```glsl uniform Image frostPattern; uniform Image iceNormal; MaterialOutput material(MaterialInput i) { MaterialOutput o = standardMaterial(i); vec4 pattern = Texel(frostPattern, i.localPos); o.albedo = mix(i.color, vec4(0.8, 0.9, 1.0, 1.0), pattern.r); o.normal = Texel(iceNormal, i.localPos).rgb; return o; } ``` ## Supported Systems Materials work with all shape types and sprites: * Rectangle, Circle, Ellipse, Arc, Line * Sprites (animated and static) * Text Materials integrate with: * **UI clipping** (`ClipBounds`) - clipped fragments are discarded before the material runs * **Deferred lighting** - material output feeds the G-buffer; specular, normals, and emission all work ::: info Blend Modes Blend mode components (`BlendAdd`, `BlendMultiply`, etc.) are ignored on entities that have a Material, because materials render through the deferred G-buffer rather than the forward blend pass. True blend modes composite against what's already on screen, which isn't possible in the G-buffer. For glow effects, `o.emission` is a good alternative but won't behave identically to additive blending. ::: ## Examples ### Dissolve Effect ::: code-group ```teal [Usage] local dissolve = gfx.newMaterial("dissolve", { fragment = "shaders/dissolve.glsl" }) world:spawn( Transform(0, 0), gfx.Rectangle(32, 32), dissolve:withParams(0.3) -- p0 = dissolve threshold ) ``` ```glsl [dissolve.glsl] // p0 = threshold (0.0 = solid, 1.0 = fully dissolved) MaterialOutput material(MaterialInput i) { MaterialOutput o = standardMaterial(i); float noise = fract(sin(dot(i.localPos, vec2(12.9898, 78.233))) * 43758.5453); float threshold = i.params.x; if (noise < threshold) { o.albedo.a = 0.0; } // Orange glow at dissolve edge float edge = smoothstep(threshold - 0.05, threshold, noise); o.albedo.rgb = mix(vec3(1.0, 0.5, 0.0), o.albedo.rgb, edge); return o; } ``` ::: ### Flash White (Hit Effect) ::: code-group ```teal [Usage] local flash = gfx.newMaterial("flash", { fragment = "shaders/flash.glsl" }) world:spawn( Transform(0, 0), gfx.Sprite(sheet), -- p0 = flash amount (0.0 = normal, 1.0 = fully white) flash:withParams(0.5) ) ``` ```glsl [flash.glsl] // p0 = flash amount (0.0 = normal, 1.0 = fully white) MaterialOutput material(MaterialInput i) { MaterialOutput o = standardMaterial(i); o.albedo.rgb = mix(o.albedo.rgb, vec3(1.0), i.params.x); return o; } ``` ::: ### Wind Sway (Vertex Displacement) ::: code-group ```teal [Usage] -- Vertex-only material (fragment defaults to standardMaterial) local windGrass = gfx.newMaterial("wind_grass", { vertex = "shaders/wind_vert.glsl", }) world:spawn( Transform(100, 200), gfx.Sprite(grassSheet), windGrass:withParams(8.0) -- p0 = sway amplitude in pixels ) ``` ```glsl [wind_vert.glsl] // p0 = sway amplitude vec2 materialVertex(vec2 worldPos, float time, vec4 params) { float sway = sin(time * 1.5 + worldPos.x * 0.02) * params.x; return worldPos + vec2(sway, 0.0); } ``` ::: ## API Reference ### `gfx.newMaterial(name, opts)` Register a material and return a `Material` component value. | Parameter | Type | Description | | ----------------- | ----------- | --------------------------------------------------------------------------------------------------------- | | `name` | `string` | Unique material name | | `opts.fragment` | `string` | Path to `.glsl` file or inline GLSL source for `material()`. Defaults to `standardMaterial(i)` if omitted | | `opts.vertex` | `string` | Optional. Path to `.glsl` file or inline GLSL source for `materialVertex()` | | `opts.textures` | `table` | Optional. Map of uniform name to texture path (up to 4) | | `opts.unlit` | `boolean` | Optional. Force unlit rendering (default: false) | At least one of `fragment` or `vertex` must be provided. Strings ending in `.glsl` are loaded from file; all other strings are treated as inline GLSL source. Returns a `Material` component that can be added to entities. ### `Material:withParams(p0, p1?, p2?, p3?)` Returns a new `Material` value with the same `materialId` but different parameters. | Parameter | Type | Description | | ----------- | ---------- | ------------------------------------------------------------ | | `p0` | `number` | First parameter (accessed as `i.params.x` in shader) | | `p1` | `number` | Second parameter (accessed as `i.params.y`). Default: 0 | | `p2` | `number` | Third parameter (accessed as `i.params.z`). Default: 0 | | `p3` | `number` | Fourth parameter (accessed as `i.params.w`). Default: 0 | ```teal local dissolve = gfx.newMaterial("dissolve", { fragment = "shaders/dissolve.glsl" }) -- Each entity gets its own threshold (p0) and edge width (p1) world:spawn( Transform(0, 0), gfx.Rectangle(32, 32), dissolve:withParams(0.3, 0.05) ) world:spawn( Transform(50, 0), gfx.Rectangle(32, 32), dissolve:withParams(0.7, 0.1) ) ``` See [MaterialInput](#materialinput) and [MaterialOutput](#materialoutput) for struct definitions. ::: info It's all a single Material component This does not create a new component type: all materials share the single `Material` component. Querying for `Material` matches all entities regardless of which material or params they use. ::: ## Custom Shaders vs Materials vs Layer Effects Materials are an optimal approach to applying per-entity shaders. Tecs also supports applying post-processing shaders to layers and groups of layers via [Layer Effects](/tecs2d/rendering/layers#layer-effects). And if that isn't enough, you can always fall back to [Custom Drawing](/tecs2d/rendering/custom-drawing) with Love2D for full control over how an entity is rendered and the shader to apply. ### Precompiling Shaders A **shader variant** is the compiled GPU shader for a specific (material, shape type) pair. Each material needs a separate variant for each shape type it's used with (rectangle, circle, sprite, etc.) because each shape type has a different base shader that the material function is injected into. Variants are compiled on first use, which can cause a brief hitch on the first frame a material appears. To avoid this, call `precompileMaterials()` during a loading screen: ```teal local gfx = require("tecs2d.gfx") -- Register materials local lava = gfx.newMaterial("lava", { fragment = "shaders/lava.glsl" }) local frost = gfx.newMaterial("frost", { fragment = "shaders/frost.glsl" }) -- Compile all variants upfront (7 shape types per material) gfx.precompileMaterials() ``` ## Performance Notes * Materials are batched by ID: all entities sharing the same material are drawn in a single `drawIndirect` call. Each unique materialId adds one draw call per shape type that uses it. * Shader variants are compiled lazily on first use, which can cause a brief hitch. [Precompile](#precompiling-shaders) during a loading screen to avoid this. * Per-entity parameters use 16 bytes (4 floats) per entity in the GPU buffer * Maximum 255 unique materials per application --- --- url: /tecs2d/rendering/particles.md --- # Particle Emitter The `ParticleEmitter` component creates particle effects using Love2D's particle system. ## Quick Start ```teal local gfx = require("tecs2d.gfx") -- Define emitter configuration local fireConfig = gfx.ParticleEmitterConfig({ texture = love.graphics.newImage("particle.png"), bufferSize = 500, emissionRate = 50, lifetime = {0.5, 1.5}, speed = {50, 100}, spread = math.pi / 4, colors = { {1, 0.8, 0.2, 1}, -- Start: yellow {1, 0.3, 0.1, 0.5}, -- Mid: orange {0.2, 0.1, 0.1, 0} -- End: fade out }, sizes = {1, 0.5, 0}, rotation = {0, math.pi * 2}, spin = {-math.pi, math.pi} }) -- Spawn emitter at position world:spawn( tecs.builtins.Transform(200, 300, 5), gfx.ParticleEmitter(fireConfig) ) ``` ## Configuration | Property | Type | Description | | ------------------------ | -------------------- | ------------------------------------- | | `texture` | Texture | Particle texture | | `bufferSize` | integer | Maximum particle count | | `emissionRate` | number | Particles per second | | `lifetime` | `{min, max}` | Particle lifetime range (seconds) | | `speed` | `{min, max}` | Initial speed range (pixels/second) | | `spread` | number | Emission angle spread (radians) | | `direction` | number | Base emission direction (radians) | | `colors` | array of `{r,g,b,a}` | Color gradient over lifetime | | `sizes` | `{number...}` | Size gradient over lifetime (0-1) | | `rotation` | `{min, max}` | Initial rotation range (radians) | | `spin` | `{min, max}` | Rotation speed range (radians/second) | | `acceleration` | `{x, y}` | Linear acceleration (pixels/second²) | | `radialAcceleration` | `{min, max}` | Radial acceleration | | `tangentialAcceleration` | `{min, max}` | Tangential acceleration | ## Emitter Control ```teal local emitter = world:get(entityId, gfx.ParticleEmitter) local ps = emitter.system -- Start/stop emission ps:start() ps:stop() -- Check if emitting if ps:isActive() then ... end -- Get current particle count local count = ps:getCount() ``` --- --- url: /tecs2d/rendering/ui.md --- # Tecs UI Tecs UI provides building blocks for creating UI systems in Tecs. It's designed to enable custom layout engines rather than being a complete UI framework. * **Viewport-relative anchoring** - Position elements relative to screen/viewport edges * **Origin-based positioning** - Components can have configurable origins (top-left, center, etc.) * **Dynamic dimensions** - LayoutBox can pull dimensions from render components automatically * **Scrollable containers** - LayoutNode provides clipping and scrolling * **Helper functions** - Simple utilities for common layout patterns ## Quick Example ```teal local tecs = require("tecs") local tecs2d = require("tecs2d") local ui = require("tecs2d.ui") local gfx = require("tecs2d.gfx") local Transform = tecs.builtins.Transform local ChildOf = tecs.builtins.ChildOf local RelativeTransform = tecs.builtins.RelativeTransform local LayoutBox = ui.LayoutBox local LayoutNode = ui.LayoutNode -- Create a scrollable panel with top-left origin local panel = world:spawn( Transform(50, 50), gfx.Rectangle(300, 400), LayoutBox(gfx.Rectangle, nil, 0, 0), -- top-left origin LayoutNode(0, 0, 300, 800) -- scrollable content ) -- Add child elements for i = 1, 10 do world:spawn( ChildOf(panel), RelativeTransform.new({x = 10, y = (i - 1) * 50}), gfx.Text(font, "Item " .. i), LayoutBox(gfx.Text) ) end ``` ## Modules * [Anchor](./anchor) - Viewport-relative positioning * [LayoutBox](./layoutbox) - Dimensions and origin for UI positioning * [LayoutNode](./layoutnode) - Scrollable containers with clipping * [FitContent](./fitcontent) - Auto-size containers to fit children * [Helpers](./helpers) - Layout helper functions --- --- url: /tecs2d/rendering/ui/anchor.md --- # Anchor Positions entities relative to viewport bounds using anchor percentages and pixel offsets. Automatically resolves the correct viewport dimensions based on the entity's layer coordinate space (screen, virtual, or world). ## Basic Usage ```teal local ui = require("tecs2d.ui") local Anchor = ui.Anchor -- Bottom-left corner, 20px from edges world:spawn( Transform(0, 0, 0, HUD_LAYER), Anchor(0, 1, 20, -20), Text(font, "Score: 0"), Pivot(0, 1) ) -- Centered on screen world:spawn( Transform(0, 0, 0, HUD_LAYER), Anchor(0.5, 0.5, 0, 0), Text(font, "PAUSED") ) -- Top-right corner with offset world:spawn( Transform(0, 0, 0, HUD_LAYER), Anchor(1, 0, -20, 20), Text(font, "HP: 100"), Pivot(1, 0) ) ``` ## Fields | Field | Type | Description | | --------- | ------ | ---------------------------------------------------- | | `anchorX` | number | Horizontal anchor (0-1). 0=left, 0.5=center, 1=right | | `anchorY` | number | Vertical anchor (0-1). 0=top, 0.5=center, 1=bottom | | `offsetX` | number | Pixel offset from anchor X | | `offsetY` | number | Pixel offset from anchor Y | ## Constructor ```teal Anchor(anchorX, anchorY) -- No offset Anchor(anchorX, anchorY, offsetX, offsetY) -- With offset ``` ## How It Works Each frame, the `ui.ComputeAnchor` system resolves viewport bounds from the entity's layer, then writes to `Transform.x` and `Transform.y`: ``` transform.x = vpLeft + anchorX * vpWidth + offsetX transform.y = vpTop + anchorY * vpHeight + offsetY ``` The viewport bounds depend on the layer's coordinate space: | Space | Left/Top | Width/Height | | ----------- | ------------------- | ---------------------------- | | `"screen"` | 0, 0 | screenWidth, screenHeight | | `"virtual"` | 0, 0 | virtualWidth, virtualHeight | | `"world"` | camera visX1, visY1 | visX2 - visX1, visY2 - visY1 | For world-space layers, the anchor tracks the camera; as the camera moves or zooms, anchored entities reposition automatically. ## Anchored Parent with Children Anchor works well with `ChildOf` + `RelativeTransform` to create groups of elements anchored together: ```teal -- Parent anchored to bottom-left local hudRoot = world:spawn( Transform(0, 0, 0, HUD_LAYER), Anchor(0, 1, 20, -180) ) world:commit() -- Children positioned relative to parent world:spawn( Transform(0, 0, 0, HUD_LAYER), ChildOf(hudRoot), RelativeTransform(0, 0), Text(font, "Line 1") ) world:spawn( Transform(0, 0, 0, HUD_LAYER), ChildOf(hudRoot), RelativeTransform(0, 30), Text(font, "Line 2") ) ``` ## System Order `ui.ComputeAnchor` runs in PostUpdate after layout systems and before RelativeTransform: ``` PostUpdate: ui.ComputeLayoutBox ui.ComputeFitContent ui.ComputeAnchor -- resolves anchor → Transform.x/y ui.RelativeTransform -- composes parent + child transforms ``` This means Anchor sets the parent position, then RelativeTransform offsets children from that position. ## Requirements * Anchor only requires `Transform` on the same entity. No LayoutBox needed. * The `ui.plugin` is auto-installed by `tecs2d.run`. If not using `tecs2d.run`, install it manually: `world:addPlugin(ui.plugin)` * The render pipeline must be available as a world resource (created by `tecs2d.run` or `gfx.newPipeline`). --- --- url: /tecs2d/rendering/ui/layoutbox.md --- # LayoutBox Defines layout dimensions and origin for UI positioning. Supports both fixed dimensions and dynamic dimensions from render components. ## Basic Usage ```teal local ui = require("tecs2d.ui") local LayoutBox = ui.LayoutBox -- Fixed dimensions (centered by default) world:spawn( Transform(100, 100), Rectangle(50, 30), LayoutBox(50, 30) ) -- Dynamic dimensions from a render component world:spawn( Transform(100, 100), Rectangle(50, 30), LayoutBox(Rectangle) -- Pulls width/height from Rectangle ) -- Top-left origin (for UI elements) world:spawn( Transform(100, 100), Rectangle(50, 30), LayoutBox(Rectangle, nil, 0, 0) -- originX=0, originY=0 ) ``` ## Fields | Field | Type | Description | | ------------------- | ------- | ------------------------------------------- | | `width` | number | Width of the entity for layout | | `height` | number | Height of the entity for layout | | `offsetX` | number | Computed offset from origin | | `offsetY` | number | Computed offset from origin | | `originX` | number | Origin X (0-1). 0=left, 0.5=center, 1=right | | `originY` | number | Origin Y (0-1). 0=top, 0.5=center, 1=bottom | | `sourceComponentId` | integer | Component to pull dimensions from (0=fixed) | ## Origin System The origin determines where the entity's reference point is: * `(0.5, 0.5)` - Centered (default, backward compatible with games) * `(0, 0)` - Top-left (typical for UI elements) * `(1, 1)` - Bottom-right * `(0, 0.5)` - Left-center The red dots mark the Transform position. With centered origin (top row), the Transform is at the visual center of each shape. With top-left origin (bottom row), the Transform is at the top-left corner. ```teal -- Centered origin (default) LayoutBox(100, 50) -- originX=0.5, originY=0.5 LayoutBox(Rectangle) -- originX=0.5, originY=0.5 -- Top-left origin LayoutBox(100, 50, 0, 0) -- originX=0, originY=0 LayoutBox(Rectangle, nil, 0, 0) -- Custom origin LayoutBox(100, 50, 1, 0.5) -- right-center ``` When using `LayoutBox(Rectangle)` or similar, the component's `getRect()` provides dimensions, and the offset is computed from the origin you specify. ## Dynamic Dimensions LayoutBox can automatically extract dimensions from any component that implements `getRect()`: ```teal -- These all work: LayoutBox(Rectangle) LayoutBox(Circle) LayoutBox(Text) LayoutBox(Sprite) ``` The dimensions are updated each frame by the ComputeLayoutBox system, so changes to the source component are reflected automatically. This is useful for animated elements or text that changes at runtime. --- --- url: /tecs2d/rendering/ui/layoutnode.md --- # LayoutNode A container that provides clipping and scrolling for its children. Requires LayoutBox for dimensions. ## Basic Usage ```teal local ui = require("tecs2d.ui") local LayoutNode = ui.LayoutNode local LayoutBox = ui.LayoutBox -- Create a scrollable container local container = world:spawn( Transform(50, 50), Rectangle(200, 300), LayoutBox(Rectangle, nil, 0, 0), -- Top-left origin LayoutNode(0, 0, 200, 600) -- scrollX, scrollY, contentWidth, contentHeight ) ``` ## Fields | Field | Type | Description | | --------------- | ------ | ------------------------ | | `scrollX` | number | Horizontal scroll offset | | `scrollY` | number | Vertical scroll offset | | `contentWidth` | number | Total content width | | `contentHeight` | number | Total content height | ## Constructor ```teal LayoutNode(scrollX, scrollY, contentWidth, contentHeight) ``` All parameters are optional and default to 0. ## Requirements LayoutNode **requires** a LayoutBox component on the same entity. An error is thrown if LayoutBox is missing: ```teal -- This works world:spawn( Transform(0, 0), Rectangle(200, 300), LayoutBox(Rectangle), LayoutNode(0, 0, 200, 600) ) -- This throws an error world:spawn( Transform(0, 0), LayoutNode(0, 0, 200, 600) -- Error: LayoutNode requires LayoutBox ) ``` ## Scrolling Children of a LayoutNode are clipped to its bounds and offset by the scroll values: ```teal -- Update scroll position local layoutNode = world:get(containerId, LayoutNode) layoutNode.scrollY = layoutNode.scrollY + 10 -- Scroll down 10 pixels -- Clamp to valid range local layoutBox = world:get(containerId, LayoutBox) local maxScroll = math.max(0, layoutNode.contentHeight - layoutBox.height) layoutNode.scrollY = math.max(0, math.min(layoutNode.scrollY, maxScroll)) ``` --- --- url: /tecs2d/rendering/ui/clipbounds.md --- # ClipBounds Clips (hides) any part of an entity's rendering that falls outside a rectangular region. Works on all renderable types: sprites, shapes, text, lines, arcs, circles, ellipses, rectangles, and meshes. ClipBounds is a standalone component. You can add it directly to any entity; it does not require UI components. The [LayoutNode](./layoutnode) system uses ClipBounds internally to implement scroll container clipping, but you can use it independently for any rectangular masking. ## Basic Usage ```teal local gfx = require("tecs2d.gfx") local ClipBounds = gfx.ClipBounds -- Clip a sprite to a 200x100 region world:spawn( Transform(150, 75), Sprite("player"), ClipBounds(50, 25, 250, 125) -- minX, minY, maxX, maxY ) -- Clip a shape world:spawn( Transform(100, 100), Circle(50), ClipBounds(80, 80, 120, 120) -- Only the center 40x40 area is visible ) ``` ## Fields | Field | Type | Description | | ------ | ------ | -------------------------------- | | `minX` | number | Left edge of the clip region | | `minY` | number | Top edge of the clip region | | `maxX` | number | Right edge of the clip region | | `maxY` | number | Bottom edge of the clip region | All values are in **world-space coordinates**. ## Constructor ```teal ClipBounds(minX, minY, maxX, maxY) ``` All parameters are optional and default to 0. ## Updating at Runtime ClipBounds lives in the metadata buffer, so you must mark the entity dirty after modifying it. You can either modify in-place and mark dirty, or replace the component with `world:set()` (which marks dirty automatically): ```teal -- Option 1: modify in-place + mark dirty local clip = world:get(entityId, ClipBounds) clip.minX = newMinX clip.minY = newMinY clip.maxX = newMaxX clip.maxY = newMaxY world:markComponentDirty(entityId, ClipBounds) -- Option 2: replace the component (marks dirty automatically) world:set(entityId, ClipBounds(newMinX, newMinY, newMaxX, newMaxY)) ``` See [Dirty Tracking](/tecs/components/dirty-tracking) for more details and efficient bulk approaches. ## Automatic Clipping with LayoutNode If you use [LayoutNode](./layoutnode) for scrollable containers, ClipBounds is propagated automatically to all descendant entities. You do not need to manage it manually in that case. See the [LayoutNode documentation](./layoutnode) for details. --- --- url: /tecs2d/rendering/ui/fitcontent.md --- # FitContent Auto-sizes a container to fit its children with optional padding. ## Basic Usage ```teal local ui = require("tecs2d.ui") local FitContent = ui.FitContent local LayoutBox = ui.LayoutBox -- Container that auto-sizes to fit children with 10px padding local container = world:spawn( Transform(100, 100), Rectangle(0, 0), -- size set by FitContent LayoutBox(Rectangle, nil, 0, 0), FitContent(10, Rectangle) ) -- Add a child world:spawn( ChildOf(container), RelativeTransform.new({x = 0, y = 0}), Text(font, "Hello!"), LayoutBox(Text, nil, 0, 0) ) ``` ## Fields | Field | Type | Description | | ------------------- | ------- | --------------------------------------- | | `padding` | number | Uniform padding on all sides | | `paddingTop` | number | Top padding (overrides uniform) | | `paddingRight` | number | Right padding (overrides uniform) | | `paddingBottom` | number | Bottom padding (overrides uniform) | | `paddingLeft` | number | Left padding (overrides uniform) | | `adjustComponentId` | integer | Component to update with new dimensions | ## Constructor ```teal -- Uniform padding, no render adjustment FitContent(10) -- Uniform padding, adjust Rectangle component FitContent(10, Rectangle) -- Config table for per-side padding FitContent({ padding = 10, paddingTop = 20, adjust = Rectangle }) ``` ## How It Works Each frame, FitContent: 1. Measures the bounding box of all children with LayoutBox 2. Adds padding to calculate final dimensions 3. Updates the entity's LayoutBox width/height 4. Optionally updates a render component's width/height ## Requirements FitContent **requires** a LayoutBox component on the same entity. Children must also have LayoutBox for measurement. ```teal -- This works world:spawn( Transform(0, 0), Rectangle(0, 0), LayoutBox(Rectangle, nil, 0, 0), FitContent(10, Rectangle) ) -- Children need LayoutBox too world:spawn( ChildOf(container), RelativeTransform.new({x = 0, y = 0}), Text(font, "Content"), LayoutBox(Text, nil, 0, 0) -- Required for measurement ) ``` ## Dynamic Resizing FitContent recalculates every frame, so containers automatically resize when children change: ```teal -- Text changes → container resizes automatically local text = world:get(textEntity, Text) text:setText("Longer text content!") -- Container will grow on next frame ``` --- --- url: /tecs2d/rendering/ui/helpers.md --- # Layout Helpers Utility functions for common layout patterns. ## stackVertical Position children in a vertical stack: ```teal local ui = require("tecs2d.ui") -- Stack with 10px spacing, starting at Y=0 ui.stackVertical(world, {child1, child2, child3}, 10) -- Stack starting at Y=50 ui.stackVertical(world, children, 10, 50) ``` Children must have `RelativeTransform` and optionally `LayoutBox` components. The function updates each child's `RelativeTransform.y` based on the cumulative heights of preceding children. ## stackHorizontal Position children in a horizontal row: ```teal -- Row with 10px spacing, starting at X=0 ui.stackHorizontal(world, {child1, child2, child3}, 10) -- Row starting at X=20 ui.stackHorizontal(world, children, 10, 20) ``` ## measureContent Calculate the bounding box of children: ```teal local width, height = ui.measureContent(world, children) ``` Returns the total width and height needed to contain all children based on their `RelativeTransform` positions and `LayoutBox` dimensions. Useful for sizing containers to fit their content. --- --- url: /tecs2d/rendering/custom-drawing.md --- # Custom Drawing While Tecs uses a deferred, GPU-accelerated render pipeline for most things you'll need like [sprites](/tecs2d/rendering/sprites/), [shapes](/tecs2d/rendering/shapes), and [text](/tecs2d/rendering/text), you can still draw using standard Love2D drawing calls. ## Draw Phase The `Draw` phase lets you make custom love.graphics draw calls that participate in the world's [lighting](/tecs2d/rendering/lighting) and depth sorting alongside GPU-rendered entities. ```teal local tecs = require("tecs") local gfx = require("tecs2d.gfx") local pipeline = world.resources[gfx.PIPELINE] world:addSystem({ name = "custom.WorldDraw", phase = tecs.phases.Draw, run = function() -- Attach shader for lit drawing at layer 5 pipeline:worldShader():at(5, 0, 100, 250):attach() -- Draw in world coordinates (camera transform already applied) love.graphics.setColor(1, 0.5, 0, 1) love.graphics.circle("fill", 100, 200, 30) pipeline:detachWorldShader() end }) ``` ::: info Camera transforms already applied During `Draw`, the camera's position and zoom are already applied to the Love2D graphics state. All draw calls use **world coordinates**, the same coordinate space as GPU entities. Drawing at `(100, 200)` places your content at world position `(100, 200)`, automatically offset and scaled by the camera. ::: ### World Shader API To integrate with the G-buffer and lighting system, use `pipeline:worldShader()` to get a fluent configuration object. Chain methods to set depth sorting, lighting mode, and optional shader/normal map, then call `:attach()` to apply. Call `pipeline:detachWorldShader()` after drawing. Draw with light with a position and layer: ```teal pipeline:worldShader() :at(layer, z, x, bottomY) :attach() ``` Unlit draw (full brightness, no lighting): ```teal pipeline:worldShader() :at(layer) :unlit() :attach() ``` Draw with custom shader and normal map: ```teal pipeline:worldShader() :at(layer, z, x, bottomY) :shader(s) :normalMap(tex) :attach() ``` Always detach after drawing: ```teal pipeline:detachWorldShader() ``` #### `:at(layer, z?, x?, bottomY?)` Set the depth sorting position. Only `layer` is required; `z`, `x`, and `bottomY` default to 0. | Parameter | Description | | ----------- | -------------------------------------------------------------------- | | `layer` | [Render layer](/tecs2d/rendering/layers) (1-16) | | `z` | Z-index within layer (default 0) | | `x` | X coordinate for isometric depth sorting (default 0) | | `bottomY` | Bottom Y coordinate for depth sorting (default 0) | #### `:unlit()` Mark the draw as unlit. The draw will render at full brightness and not receive lighting. #### `:shader(loveShader)` Set a raw `love.graphics.Shader` for custom MRT-aware rendering. See [Custom Shader Requirements](#custom-shader-requirements) below. #### `:normalMap(texture)` Set a normal map texture for per-pixel lighting. #### `:attach()` Apply the world shader with the accumulated settings. Draw after this call. ### Custom Shader Requirements Custom shaders passed to `:shader()` must be **MRT-aware** to receive lighting (Multiple Render Targets). ::: warning MRT Output Required Custom shaders that don't output to `love_Canvases[1]` (the normal buffer) will not receive lighting. The entity will appear black or only show ambient light. ::: ::: tip Materials vs World Shaders For per-entity visual effects (dissolve, tint, glow), use [Materials](/tecs2d/rendering/materials) instead. Materials are GPU-batched and much faster. The `:shader()` API here is for manual CPU draws only (particles, physics debug, custom procedural rendering). ::: #### Tecs Uniforms When a custom shader is set via `:shader()`, Tecs automatically sends values for these uniforms if they are declared: | Uniform | Type | Description | | ---------------- | ------ | ---------------------------------------------------- | | `tecs_Depth` | float | Depth value for z-sorting (0-1, lower = closer) | | `tecs_NormalMap` | Image | Normal map texture (or flat normal fallback) | | `tecs_Lit` | float | 1.0 = receives lighting, 0.0 = unlit/fullbright | | `tecs_Time` | float | Game time in seconds (respects time scale and pause) | #### MRT Shader Template A complete shader for use with `:shader()`: ```glsl #pragma language glsl4 uniform Image MainTex; uniform float tecs_Depth; uniform Image tecs_NormalMap; uniform float tecs_Lit; uniform float tecs_Time; #ifdef VERTEX vec4 position(mat4 transform_projection, vec4 vertex_position) { vec4 result = transform_projection * vertex_position; result.z = tecs_Depth * result.w; return result; } #endif #ifdef PIXEL void effect() { vec4 albedo = Texel(MainTex, VaryingTexCoord.xy) * VaryingColor; if (albedo.a < 0.01) discard; vec3 normal = Texel(tecs_NormalMap, VaryingTexCoord.xy).rgb * 2.0 - 1.0; love_Canvases[0] = albedo; love_Canvases[1] = vec4(normal * 0.5 + 0.5, tecs_Lit); } #endif ``` When you call `:attach()` without `:shader()`, the built-in world shader is used. This shader: * Outputs to the G-buffer (albedo + normals) * Respects `:unlit()` for lit/unlit rendering * Applies the normal map if provided via `:normalMap()` * Sets depth for correct sorting with GPU entities ### Unlit Drawing For draws that should ignore lighting (full brightness): ```teal pipeline:worldShader() :at(layer, z, x, bottomY) :unlit() :attach() love.graphics.rectangle("fill", x, y, w, h) pipeline:detachWorldShader() ``` ### Visibility Culling For performance, cull draws outside the visible area using the camera's `isVisible` method: ```teal local cam = pipeline:getCamera() world:addSystem({ name = "custom.CulledDraw", phase = tecs.phases.Draw, run = function() for _, obj in ipairs(objects) do if cam:isVisible(obj.x, obj.y, obj.w, obj.h) then pipeline:worldShader() :at(obj.layer, 0, obj.x, obj.y + obj.h) :attach() love.graphics.setColor(obj.r, obj.g, obj.b, 1) love.graphics.rectangle("fill", obj.x, obj.y, obj.w, obj.h) pipeline:detachWorldShader() end end end }) ``` ## PostRender Phase Use `PostRender` for screen-space overlays that render after the entire GPU pipeline (no lighting, no depth sorting with world entities). ```teal world:addSystem({ name = "ui.Overlay", phase = tecs.phases.PostRender, run = function() -- Screen coordinates (no camera transform) love.graphics.setColor(1, 1, 1, 1) love.graphics.print("FPS: " .. love.timer.getFPS(), 10, 10) end }) ``` ## UI in World Space For UI that should use the [layer system](/tecs2d/rendering/layers) but not receive lighting, configure a layer as virtual space and unlit: ```teal pipeline:configureLayer(15, { name = "ui", space = "virtual", unlit = true }) -- Then in the Draw phase, draw to that layer world:addSystem({ name = "ui.HUD", phase = tecs.phases.Draw, run = function() pipeline:worldShader() :at(15) :unlit() :attach() -- Virtual coordinates (e.g., 0-320 x 0-180) love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 10, 10, 100, 20) pipeline:detachWorldShader() end }) ``` ## Depth Calculation The pipeline computes depth using a consistent formula for both GPU and CPU draws: ```teal local depth = pipeline:computeDepth(layer, z, x, bottomY) ``` Lower depth values render in front of higher values. The formula depends on the layer's [sort mode](/tecs2d/rendering/layers): * **topdown** (default): Layer, then Z-index, then Y position * **z**: Layer, then Z-index only (no spatial sorting) * **isometric**: Layer, then X + Y + Z combined ## Camera State During `Draw`, access camera state if needed: ```teal local camX = pipeline.drawCamX local camY = pipeline.drawCamY local zoom = pipeline.drawZoom ``` ## Performance Notes * CPU draws described on this page are slower than GPU-instanced rendering * Batch similar draws together when possible * Consider using the [Mesh component](/tecs2d/rendering/shapes#mesh) for custom geometry instead --- --- url: /tecs2d/assets.md --- # Tecs Assets Tecs Assets is a non-blocking asset loading and management system. It loads game assets like images, sounds, fonts, and shaders in background threads without freezing gameplay. * **Non-blocking threaded loading**: Uses worker threads to load assets without blocking the main thread * **Automatic memory management**: Uses weak references for cached assets, allowing automatic garbage collection * **Handle-based API**: Returns promise-like handles that can block on first access or be checked for completion ## Quickstart You can access the asset manager from any system: ```teal local tecs = require("tecs") local assets = require("tecs2d.assets") world:addSystem({ phase = tecs.phases.Startup, run = function() -- Get the asset manager instance local manager = world.resources[assets] -- Load assets (returns immediately with a handle) local playerImage = manager:loadImage("sprites/player.png") local jumpSound = manager:loadAudio("sounds/jump.wav", "static") local mainFont = manager:loadFont("fonts/main.ttf", { size = 16 }) -- Check if a handle is complete if playerImage.isComplete then -- Access the value (will error if load failed) love.graphics.draw(playerImage.value, 100, 100) end end, }) ``` The game plugin automatically creates an asset manager with a root assets folder of "./assets". Use `setRoot` to change the asset root directory: ```teal world.resources[assets]:setRoot("game-assets") ``` ## Handles All load methods return a `Handle` immediately. Handles are smart references to assets being loaded. ```teal local handle = manager:loadImage("player.png") -- You can check if the handle has an error if handle.err then print("Failed to load:", handle.err) end -- You can check if the handle is done if handle.isComplete then local image = handle.value end ``` You can access the handle value directly at any time, blocking until it's fully loaded: ```teal local image = handle.value -- blocks! ``` You can react when the handle is done loaded or has an error: ```teal -- Listen for completion handle:observe(function(h: assets.Handle) if h.err then print("Error:", h.err) else print("Loaded successfully") playerSprite = h.value end end) ``` You can transform handles into other types using `map()`: ```teal -- Transform an image handle into a particle system local particles = manager :loadImage("particle.png") :map(function(image: love.graphics.Image): love.graphics.ParticleSystem return love.graphics.newParticleSystem(image) end) ``` ### Handle Properties | Name | Type | Description | |--------------|-----------|-------------------------------------------------------------| | `isComplete` | `boolean` | `true` if the operation has completed | | `value` | `T` | The loaded data. Blocks on first access, errors if failed | | `err` | `string` | Error message if loading failed, nil otherwise | ### Blocking Behavior and Errors When you access `handle.value` for the first time: 1. If the asset is already loaded, it returns immediately 2. If still loading, it blocks until complete 3. Once loaded, the value is cached on the handle for instant future access 4. If loading failed, accessing `value` throws an error ### Memory Management Handles are cached in storage using a *weak reference*, allowing automatic garbage collection of unused resources. ```teal -- Assets are cached automatically local handle1 = manager:loadImage("player.png") local handle2 = manager:loadImage("player.png") -- Returns same handle -- When all references are released, the asset can be garbage collected handle1 = nil handle2 = nil collectgarbage() -- Asset may be collected if no other references exist -- Next load will create a new handle local handle3 = manager:loadImage("player.png") -- New load operation ``` Use the `pin()` method of a Handle to preload assets or to ensure it's never garbage collected: ```teal -- This asset won't be garbage collected manager:loadImage("player.png"):pin() ``` ## Supported Asset Types The asset manager provides built-in loaders for common asset types: | Method | Returns | Description | | -------------------------------------------------------- | --------------------- | ----------------------------------------------------------------- | | [`loadImage`](/tecs2d/assets/api#loadimage) | `Image` | PNG, JPG, and other image formats | | [`loadSpriteSheet`](/tecs2d/assets/api#loadspritesheet) | `SpriteSheet` | [Sprite sheets](/tecs2d/rendering/sprites/sheets) with animations | | [`loadStaticSheet`](/tecs2d/assets/api#loadstaticsheet) | `SpriteSheet` | Single images as [sprite sheets](/tecs2d/rendering/sprites/sheets) | | [`loadBMFont`](/tecs2d/assets/api#loadbmfont) | `BMFont` | [Bitmap font](/tecs2d/rendering/text) atlases (.fnt, .json) | | [`loadTiledMap`](/tecs2d/assets/api#loadtiledmap) | `TilemapData` | [Tiled](/tecs2d/tiled/) map editor exports | | [`loadFont`](/tecs2d/assets/api#loadfont) | `Font` | TrueType fonts (.ttf, .otf) | | [`loadImageFont`](/tecs2d/assets/api#loadimagefont) | `Font` | Image-based fonts | | [`loadAudio`](/tecs2d/assets/api#loadaudio) | `Source` | Audio files (WAV, OGG, MP3) | | [`loadShader`](/tecs2d/assets/api#loadshader) | `Shader` | GLSL shader files | | [`loadVideo`](/tecs2d/assets/api#loadvideo) | `Video` | Video files (OGV) | | [`loadJson`](/tecs2d/assets/api#loadjson) | `any` | JSON data files | | [`loadFile`](/tecs2d/assets/api#loadfile) | `string` | Raw text file contents | | [`loadCompressedImage`](/tecs2d/assets/api#loadcompressedimage) | `CompressedImageData` | Compressed image data (DDS, KTX, etc.) | | [`loadFileData`](/tecs2d/assets/api#loadfiledata) | `FileData` | Raw binary file contents | ### Game-Specific Assets For sprite sheets, BMFonts, and Tiled maps, the loaders automatically load all required files concurrently: * **[Sprite Sheets](/tecs2d/rendering/sprites/sheets)**: Loads the image, JSON metadata, and optional material maps (`_n.png`, `_e.png`, `_s.png`) * **[BMFonts](/tecs2d/rendering/text)**: Loads the font definition and its atlas texture * **[Tiled Maps](/tecs2d/tiled/)**: Loads the map, all tileset images, external tilesets, material maps, and image layers ### Custom Asset Types For game-specific asset types, use [`registerAssetHandler`](/tecs2d/assets/api#registerassethandler) to create custom loaders that integrate with the caching and threading system. ## Preloading assets To preload assets during startup, load and pin them in a `Startup` system: ```teal local tecs = require("tecs") local assets = require("tecs2d.assets") world:addSystem({ phase = tecs.phases.Startup, run = function() local manager = world.resources[assets] -- Use :pin() to ensure assets stay in memory manager:loadImage("player.png"):pin() manager:loadAudio("music.ogg", "stream"):pin() manager:loadFont("ui.ttf", { size = 16 }):pin() end, }) ``` ### InitialLoadComplete The `InitialLoadComplete` event is emitted on the first call to `update()` when there are no pending load operations. This means all assets queued during startup will have finished loading before the event fires. The event is only emitted once per AssetManager instance and is emitted even if nothing was ever loaded. ```teal world:observe(0, assets.InitialLoadComplete, function() print("All startup assets are ready!") end) ``` This is useful for implementing [loading screens](#loading-screens) or deferring gameplay until assets are available. ### Loading Screens Asynchronous asset loading can be used to implement animated loading screens with progress displays. ::: info See working example See the [Loading Screen Example](https://github.com/tecs-dev/tecs/tree/main/examples/assets) for a complete working example on GitHub. ::: --- --- url: /tecs2d/assets/api.md --- # API Reference Access the assets module: ```teal local assets = require("tecs2d.assets") ``` ## Module Functions ### assets.InitialLoadComplete Event type emitted when the initial asset load completes. ```teal assets.InitialLoadComplete: Event ``` This event is emitted on the first call to `update()` when there are no loading operations. This ensures that listeners have time to register before the event is emitted. The event is only emitted once per AssetManager instance. ```teal -- Register listener before any update calls world:observe(0, assets.InitialLoadComplete, function(_e: assets.InitialLoadComplete) print("Initial loading complete!") -- Transition from loading screen to main menu end) -- The event will be emitted on the first update() when nothing is loading ``` ### assets.new Creates a new asset manager with the specified root directory. ```teal function assets.new(root: string): AssetManager ``` **Parameters:** * `root`: The root directory for all asset paths **Returns:** * A new `AssetManager` instance **Example:** ```teal local manager = assets.new("assets") ``` ### assets.newAssetHandler Creates a new asset handler for custom asset types. ```teal function assets.newAssetHandler(): AssetHandler ``` **Returns:** * A new `AssetHandler` instance that can be used with `load` and `registerAssetHandler` **Example:** ```teal local LEVEL_DATA_HANDLER : AssetHandler = assets.newAssetHandler() -- Register the handler manager:registerAssetHandler(LEVEL_DATA_HANDLER, function(path: string): assets.Handle return manager:loadFile(path):map(parseLevelData) end) ``` ## Types ### AssetStats Statistics returned by `AssetManager:getStats()`. ```teal type AssetStats = { completedCount: integer runningCount: integer currentAssetCount: integer pinnedAssetCount: integer } ``` **Fields:** * `completedCount`: Total number of assets that have finished loading since the manager was created * `runningCount`: Number of assets currently being loaded in background threads * `currentAssetCount`: Number of assets currently held in the cache (may be less than completedCount due to garbage collection) * `pinnedAssetCount`: Number of assets explicitly pinned to prevent garbage collection ### FontConfig Configuration for loading TrueType fonts. ```teal type FontConfig = { size?: number hinting?: HintingMode dpiscale?: number } ``` **Fields:** * `size` (optional): Font size in pixels * `hinting` (optional): One of "normal", "light", "mono", "none" * `dpiscale` (optional): DPI scale factor for high-DPI displays ### ImageFontConfig Configuration for loading bitmap/image fonts. ```teal type ImageFontConfig = { glyphs?: string extraSpacing?: number } ``` **Fields:** * `glyphs` (optional): String containing all glyphs in order as they appear in the image * `extraSpacing` (optional): Additional pixels between characters ## AssetManager The main asset loading and management class. ### loadFile Loads a text file and returns its contents as a string. ```teal function AssetManager:loadFile(path: string): Handle ``` **Parameters:** * `path`: Path to the file relative to the root directory **Returns:** * A `Handle` for the file contents **Example:** ```teal local configHandle = manager:loadFile("config.json") -- Later... local configText = configHandle.value -- Blocks if needed ``` ### loadFileData Loads a file as binary data. ```teal function AssetManager:loadFileData(path: string): Handle ``` **Parameters:** * `path`: Path to the file relative to the root directory **Returns:** * A `Handle` for the loaded [Love2D FileData](https://love2d.org/wiki/FileData) ### loadImage Loads an image file. ```teal function AssetManager:loadImage(path: string): Handle ``` **Parameters:** * `path`: Path to the image file relative to the root directory **Returns:** * A `Handle` for the loaded [Love2D Image](https://love2d.org/wiki/Image) **Example:** ```teal local playerSprite = manager:loadImage("sprites/player.png") playerSprite:pin() -- Keep in memory ``` ### loadFont Loads a TrueType font. ```teal function AssetManager:loadFont( path: string, config?: FontConfig ): Handle ``` **Parameters:** * `path`: Path to the font file relative to the root directory * `config`: Optional font configuration **Returns:** * A `Handle` for the loaded [Love2D Font](https://love2d.org/wiki/Font) **Example:** ```teal local uiFont = manager:loadFont("fonts/ui.ttf", { size = 16, hinting = "normal" }) ``` ### loadImageFont Loads a bitmap/image font. ```teal function AssetManager:loadImageFont( path: string, config?: ImageFontConfig ): Handle ``` **Parameters:** * `path`: Path to the image font file relative to the root directory * `config`: Optional image font configuration **Returns:** * A `Handle` for the loaded image [Love2D Font](https://love2d.org/wiki/Font) ### loadAudio Loads an audio file. ```teal function AssetManager:loadAudio( path: string, sourceType: SourceType, streamType?: string ): Handle ``` **Parameters:** * `path`: Path to the audio file relative to the root directory * `sourceType`: Either "static" (for sound effects) or "stream" (for music) * `streamType` (optional): Source of the stream data, either "file" or "memory" **Returns:** * A `Handle` for the loaded [Love2D audio Source](https://love2d.org/wiki/Source) **Example:** ```teal local jumpSound = manager:loadAudio("sounds/jump.wav", "static") local bgMusic = manager:loadAudio("music/theme.ogg", "stream") ``` ### loadShader Loads a GLSL shader file. ```teal function AssetManager:loadShader(path: string): Handle ``` **Parameters:** * `path`: Path to the shader file relative to the root directory **Returns:** * A `Handle` for the loaded [Love2D Shader](https://love2d.org/wiki/Shader) ### loadVideo Loads a video file for playback. ```teal function AssetManager:loadVideo(path: string): Handle