Build games with LuaJIT

Typed. GPU-driven. One data model.

A cactus and an armadillo in the desert sun

Live inspection

Inspect, freeze, and edit a running game through the built-in MCP server.

ECS built for LuaJIT

An archetype-based ECS with FFI components, contiguous columns, and a dirty model the GPU reads.

Batteries included

Physics, audio, particles, text, retained UI, sequences, sprite sheets, and hot reload share the ECS.

Static typing

Teal checks component, query, system, and engine APIs before the game runs.

Install

The tecs command carries the compiler, engine, type definitions, and project template. It needs no separate Lua, LuaRocks, Teal, or C compiler installation.

brew install tecs-dev/tap/tecs
scoop bucket add tecs https://github.com/tecs-dev/scoop-bucket
scoop install tecs
brew install tecs-dev/tap/tecs

Create a game and run it:

tecs new my-game && cd my-game && tecs run

The Tecs CLI carries the project toolchain in one file. Contributors can build this repository through Cargo; getting started covers that workflow.

Entities are the interface

A drawn quad, a light, a sound, a physics body: each one is an entity carrying components, and the subsystem that cares for it finds it by query. A game holds no draw list, no voice handle and no body pointer, so what works on one of them works on all of them. A snapshot saves the scene, the profiler reports where the frame went, and the debug server inspects and edits any of it while the game runs.

The host owns the loop. An entry file returns an application, and the plugin it carries registers work instead of driving frames.

local Transform2D <const> = tecs.Transform2D
local gfx <const> = tecs.gfx

return tecs.newApplication({
    window = {title = "Spin", width = 1280, height = 720},
    ambientLight = {0.05, 0.05, 0.08},
    plugin = function(world: tecs.World, app: tecs.Application)
        -- Something on screen is an entity. Nothing issues a draw call for it.
        world:spawn(
            Transform2D.new({
                x = 640, y = 360, scaleX = 64, scaleY = 64
            }),
            gfx.Tint(0.85, 0.4, 0.3, 1.0),
            gfx.Renderable2D()
        )

        -- So is the light falling on it, placed by the same Transform2D.
        world:spawn(
            Transform2D.new({x = 520, y = 300}),
            gfx.PointLight2D(120, 600, 1.0, 0.9, 0.7, 3.0)
        )

        local movers <const> = world:newQuery({
            include = {Transform2D, gfx.Renderable2D},
        })

        world:addSystem({
            name = "game.Spin",
            phase = tecs.ecs.phases.Update,
            run = function(dt: number)
                for archetype, length in movers:iter() do
                    local transforms <const> = archetype:getMut(
                        Transform2D
                    )
                    for row = 1, length do
                        transforms[row].rotation = transforms[row].rotation + dt
                    end
                end
            end,
        })
    end,
})

ECS examples

local world = tecs.ecs.newWorld()

-- Define typed components with Teal
local record Position is tecs.ecs.Component
    x: number
    y: number
    metamethod __call: function(self, x?: number, y?: number): Position
end

tecs.ecs.newFFIComponent({
    name = "Position",
    container = Position,
    fields = {{"x", "float"}, {"y", "float"}},
})

local record Velocity is tecs.ecs.Component
    x: number
    y: number
    metamethod __call: function(self, x?: number, y?: number): Velocity
end

tecs.ecs.newFFIComponent({
    name = "Velocity",
    container = Velocity,
    fields = {{"x", "float"}, {"y", "float"}},
})

-- Query and update entities
local query = world:newQuery({include = {Position, Velocity}})

world:addSystem({
    phase = tecs.ecs.phases.Update,
    run = function(dt: number, _world: tecs.World)
        for archetype, len in query:iter() do
            local positions = archetype:getMut(Position)
            local velocities = archetype:get(Velocity)
            for row = 1, len do
                positions[row].x = positions[row].x + velocities[row].x * dt
            end
        end
    end,
})

world:spawn(Position(100, 100), Velocity(10, 0))
local world = tecs.ecs.newWorld()
local ChildOf = tecs.ecs.ChildOf

-- Create a parent entity
local parent: integer = world:spawn(
    tecs.ecs.Name("parent"), tecs.Transform2D(100, 100)
)

-- ChildOf has cascadeDelete; despawning parent despawns children too.
local child1: integer = world:spawn(
    ChildOf(parent), tecs.ecs.RelativeTransform2D(20, 0)
)

local child2: integer = world:spawn(
    ChildOf(parent), tecs.ecs.RelativeTransform2D(-20, 0)
)

-- Walk children of a specific parent
world:targets(
    parent,
    ChildOf,
    function(childId: integer) print("child:", childId) end
)

-- Despawning parent cascades to child1 and child2
world:despawn(parent)
local world = tecs.ecs.newWorld()

-- Define a custom event
local record DamageEvent is tecs.events.Event
    target: integer
    amount: number
    metamethod __call: function(
        self, target: integer, amount: number
    ): DamageEvent
end

-- Wire up the event init hook (it mutates a pre-allocated instance)
DamageEvent.init = function(
    e: DamageEvent, target: integer, amount: number
)
    e.target = target
    e.amount = amount
end
tecs.events.newEvent(DamageEvent)

-- Observe events anywhere in your game (0 = world-level)
world:observe(
    0,
    DamageEvent,
    function(e: DamageEvent)
        local health: Health = world:get(e.target, Health)
        if health then
            health.current = health.current - e.amount
            if health.current <= 0 then
                world:despawn(e.target)
            end
        end
    end
)

-- Emit events from systems
world:emit(0, DamageEvent, enemyId, 25)

Reference

The host loads tecs before the entry file. A game can use these names without a require:

  • tecs.assets - loading bytes, images and sounds off the main thread
  • tecs.audio - voices, groups, keyed limits, fades, pitch, loop points, streaming, devices
  • tecs.data - typed stores, JSON, byte encodings, DEFLATE, transcoding, UTF-8, UUIDs, hashes and checksums
  • tecs.ecs - worlds, components, queries, systems, events and resources
  • tecs.events - typed events and address-based message buses
  • tecs.gfx - the camera, the components, the renderer, text, and the vocabularies below
  • tecs.input - gameplay input, gamepads and standalone sensors
  • tecs.io - binary I/O, nonblocking sockets, HTTP, and external tools
  • tecs.log - named, leveled platform logging
  • tecs.math - angle math and two-dimensional geometry
  • tecs.physics - Rapier 2D, solved across a shared thread pool
  • tecs.platform - platform events, operating-system services, time, and windows
  • tecs.regex - compiled regular expressions over Lua byte strings
  • tecs.runtime - process-wide polling for asynchronous work
  • tecs.sequence - timelines with the tween runtime inside them
  • tecs.ui - retained layout, scrolling, clipping, and interaction over existing drawing components
  • tecs.workers - typed background jobs

Inside one of those, one level and no deeper:

On tecs itself, because no one module owns them:

tecs.ecs

Worlds, components, queries, systems, events and resources. One table with two ways in: a game reads it off tecs like any other module, and an engine module writes require("tecs.ecs"), because tecs is the aggregator that pulls every engine module in and a module tecs exports cannot also depend on tecs. These are the concepts behind the names.

  • Overview - the model, in one page
  • Archetypes - cache-friendly storage for millions of entities
  • Builtins - names, transforms, hierarchy, TTL, pause, disable, state events
  • Bundles - reusable entity templates and batch spawning
  • Components - table, tag, scalar and FFI data containers
  • Dirty tracking - change-gated systems and GPU synchronization
  • Events - type-safe pub/sub and entity lifecycle events
  • Mutation model - the normative rules for reads, writes and dirty bits
  • Phases - ordered phase scheduling
  • Plugins - modular, shareable game mechanics
  • Profiling - where a frame went
  • Queries - reusable filters with archetype iteration, callbacks and grouping
  • Relationships - links, hierarchies, relative transforms, cascade deletion
  • Save games - snapshots, component codecs, migrations, resource handlers
  • States - stack-based game states with transition events
  • Systems - phase scheduling, dependencies and run conditions
  • World - entities, resources and the state stack