Skip to content

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:

FieldTypeDefaultDescription
fpsnumber60Target frames per second
gamefunction(tecs.World)(required)A plugin function that receives the world and sets up your game
renderRenderConfigdefaultsRender pipeline configuration
hotReloadHotReloadConfigdisabledOptional development hot reload using one build stamp file and snapshots

See 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

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

NameTypeDescription
exitCodenumber (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 PhaseLove2D Integration
tecs.phases.PostStartupSteps timer after initialization
tecs.phases.UpdateCalls love.update() if defined
tecs.phases.FixedFirstMarks entry to fixed timestep phases
tecs.phases.FixedLastClears latched input, marks exit from fixed phases
tecs.phases.RenderFirstClears screen, resets graphics state
tecs.phases.RenderCalls love.draw() if defined
tecs.phases.RenderLastPresents rendered frame

Input resource

Input is available through the input module:

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

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 for more information.

Event observation

Love2D events are translated into Tecs events and can be observed like any other Tecs event. See Love2D 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
})

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