Skip to content

Tile Collision

Tecs Tiled supports automatic physics collision generation from per-tile collision shapes defined in Tiled tilesets. The system uses polygon tracing to merge adjacent tiles into optimized Box2D chain shapes for high-performance static collision.

Defining Collision Shapes in Tiled

In the Tiled editor, you can define collision shapes on individual tiles in your tileset:

  1. Open your tileset in Tiled
  2. Select a tile
  3. Open the Tile Collision Editor (View > Views and Toolbars > Tile Collision Editor)
  4. Draw collision shapes (rectangles, polygons) on the tile

These shapes are stored in the tile's objectgroup property and automatically parsed when the tilemap loads.

Enabling Collision

Collision is opt-in via the Tilemap component's collision config:

teal
local tiled = require("tecs2d.tiled")
local physics = require("tecs2d.physics")

-- First, initialize physics
world:addPlugin(physics.new({
    world = love.physics.newWorld(0, 300, true)
}))

-- Then load tilemap with collision enabled
world:spawn(
    tiled.Tilemap.new({
        path = "maps/level1.tmj",
        collision = {
            enabled = true,       -- Create physics bodies for tiles with collision data
            mergeShapes = true,   -- Merge adjacent shapes into chain shapes (default: true)
        }
    }),
    tecs.builtins.Transform(0, 0)
)

Physics Plugin Required

The physics plugin must be initialized before loading tilemaps with collision enabled. If physics is not available, an error will be thrown.

Configuration Options

OptionTypeDefaultDescription
enabledbooleanfalseEnable collision body creation
mergeShapesbooleantrueMerge adjacent shapes into chain shapes
filterfunction(tile, gid, x, y)nilOptional filter to include/exclude specific tiles

Filter Function

The filter function receives:

  • tile: The TileData object with properties and collision shapes
  • gid: The global tile ID
  • x, y: Tile coordinates (1-indexed)

Return true to include the tile's collision, false to exclude:

teal
collision = {
    enabled = true,
    filter = function(tile, gid, tileX, tileY)
        -- Only include tiles with "solid" property
        return tile.properties and tile.properties.solid == true
    end
}

Performance

The collision system uses polygon tracing to minimize the number of physics bodies:

ApproachBodies CreatedPerformance
Per-tile rectangles (naive)One per tilePoor
Chain shapes (tiled)~1-10 totalExcellent
Hybrid (chain + isolated)FewExcellent

Chain shapes also eliminate "ghost collisions" at tile boundaries, which is a common problem when using multiple adjacent box colliders.

How It Works

  1. Edge Extraction: For each tile with collision data, extract the edges of its collision shapes
  2. Internal Edge Removal: Remove edges shared between adjacent tiles (internal boundaries)
  3. Polygon Tracing: Trace remaining edges to form closed polygons
  4. Chain Shape Creation: Convert each polygon to a Box2D chain shape

Accessing Collision Bodies

The created physics bodies are stored on the Tilemap component:

teal
local tilemap = world:get(entityId, tiled.Tilemap)
if tilemap.collisionBodies then
    for _, body in ipairs(tilemap.collisionBodies) do
        -- Access Box2D body
        local shapes = body:getShapes()
    end
end

You can also access the physics state via the world resource:

teal
local physics = require("tecs2d.physics")
local state = world.resources[physics]
local physicsWorld = state.world  -- The Box2D world

Cleanup

Collision bodies are automatically destroyed when the tilemap entity is despawned. You can also manually destroy them:

teal
local tiled = require("tecs2d.tiled")

if tilemap.collisionBodies then
    tiled.collision.destroyColliders(tilemap.collisionBodies)
    tilemap.collisionBodies = nil
end

Custom Properties

Tile properties defined in Tiled remain accessible for game-specific logic:

teal
local tile = tilemap.data:getTileProperties(layerIndex, tileX, tileY)
if tile and tile.properties then
    local height = tile.properties.h        -- Custom height property
    local response = tile.properties.response  -- Custom collision response
end

The collision system doesn't interpret these properties; they're available for your game logic.

Supported Shapes

ShapeSupport
RectangleFull support, merged into chain shapes
PolygonFull support, merged into chain shapes
EllipseApproximated as circle, not merged
PolylineNot supported (open shapes can't form colliders)

Debug Rendering

To visualize collision shapes, use the gfx.physicsDebug plugin. This renders collision shapes through the render pipeline so they respect camera zoom and position:

teal
local gfx = require("tecs2d.gfx")
local physics = require("tecs2d.physics")

-- Initialize physics with debug enabled
world:addPlugin(physics.new({
    world = love.physics.newWorld(0, 300, true),
    debug = true,  -- Enable debug state
}))

-- Add physics debug rendering
world:addPlugin(gfx.physicsDebug.new())

Toggle debug rendering at runtime:

teal
local state = world.resources[physics]
state.debug = not state.debug  -- Toggle on/off

Example

teal
local tecs = require("tecs")
local gfx = require("tecs2d.gfx")
local physics = require("tecs2d.physics")
local tiled = require("tecs2d.tiled")

local function gamePlugin(world)
    -- Initialize physics first
    world:addPlugin(physics.new({
        world = love.physics.newWorld(0, 300, true),
        debug = false,
    }))

    -- Optional: Add physics debug rendering
    world:addPlugin(gfx.physicsDebug.new())

    -- Load tilemap with collision enabled
    world:spawn(
        tiled.Tilemap.new({
            path = "maps/level.tmj",
            collision = {
                enabled = true,
                mergeShapes = true,
            }
        }),
        tecs.builtins.Transform(0, 0)
    )
end