Working with Tilemaps and STI
STI (Simple Tiled Implementation) is a library that bridges Tiled Map Editor and LÖVE. It loads maps exported from Tiled and gives you a clean Lua API to draw them, query tiles, manage layers, and integrate physics. If you are building a 2D game in LÖVE and want to handcraft your levels in a visual editor, STI is the standard solution.
This tutorial covers STI from loading a map to working with tile properties, custom layers, and the Box2D physics plugin.
What You’ll Need
- LÖVE 11.x — STI is tested and works well with LÖVE 11. Avoid older versions unless you have a specific reason.
- Tiled Map Editor 1.2.x — The recommended version for compatibility. Newer Tiled releases may export formats that require map re-exporting.
- STI library — Grab the latest release from the STI GitHub repository. Drop the
stifolder into your LÖVE project so you canrequireit.
No package manager needed — STI is a single Lua file with no external dependencies beyond LÖVE itself.
Setting Up Tiled
Before writing any code, create a map in Tiled. Install Tiled, then:
- Create a new map — use an orthogonal grid. Set your tile size (for example 32×32 pixels) and map dimensions (for example 20 tiles wide × 15 tiles tall).
- Add a tileset — import an image containing your tiles. Tiled calls this an image tileset. Place your tile image in your project’s
assetsfolder so LÖVE can find it at runtime. - Draw your level — paint tiles onto the map using the Stamp tool. You can add multiple layers (Background, Ground, Walls, etc.) to separate visual elements.
- Set tile properties — right-click a tile in the tileset panel and choose Tile Properties. Add keys like
solid = trueordamage = 10. These become available in STI. - Export as Lua — go to File → Export As, pick Lua files (*.lua), and save the file inside your LÖVE project. The path matters: it must be relative to
love.filesystem.
A typical project layout looks like this:
mygame/
├── main.lua
├── conf.lua
├── map.lua ← exported from Tiled
└── assets/
└── tiles.png ← tileset image
Keep your tileset image in a folder accessible to LÖVE and use the relative path when Tiled asks for it during tileset creation. STI resolves tileset image paths relative to the map file location.
Loading and Drawing the Map
In your LÖVE game, require STI and load the map:
local sti = require("sti")
function love.load()
-- Path is relative to love.filesystem (inside your .love archive or folder)
map = sti("map.lua")
end
Draw the map every frame with map:draw():
function love.draw()
map:draw()
end
The draw() method accepts optional translation and scale arguments:
function love.draw()
-- Draw the map with 320px offset and 2× scale
map:draw(320, 320, 2, 2)
end
To draw a specific layer instead of the whole map, use map:drawLayer():
function love.draw()
map:drawLayer(map.layers["Ground"])
map:drawLayer(map.layers["Walls"])
end
Drawing layers individually gives you control over render order, which matters when you draw sprites between layers.
Animations
Tile animations in Tiled (such as animated water or blinking lights) are handled automatically by STI. Call map:update(dt) inside your love.update function:
function love.update(dt)
map:update(dt)
end
STI tracks animation timers and swaps tile IDs as time passes. You do not need to manually trigger or manage animations — just call update each frame and pass the delta time.
Working with Tile Properties
Tile properties let you attach data to individual tiles in Tiled. Access them with map:getTileProperties():
-- Pass pixel coordinates (tile col 5, row 3 at 32px/tile = 160px, 96px)
local props = map:getTileProperties("Walls", {x = 160, y = 96})
if props.solid then
-- this tile blocks movement
end
Important: Tiled uses 1-indexed coordinates. Passing 0 for either x or y does not raise an error — it silently returns nothing useful. Always verify your tile coordinates start at 1 when iterating.
To change a tile at runtime, use map:setLayerTile():
-- Set tile at column 5, row 3 on the "Ground" layer to tile ID 12
map:setLayerTile("Ground", 5, 3, 12)
Tile IDs (gid) correspond to the global tile ID assigned by Tiled. When you query a tile, the returned table includes this gid. You can read it back and reuse it elsewhere.
Object Layers and Custom Properties
Tiled object layers store shapes like rectangles, ellipses, and polygons. STI exposes them through map.layers["Objects"].objects:
local objects = map.layers["Objects"].objects
for _, obj in ipairs(objects) do
print(obj.name, obj.x, obj.y, obj.width, obj.height)
if obj.properties.spawn then
-- spawn an enemy here
end
end
Each object has x, y, width, height, and a properties table populated from Tiled. Use objects to place spawn points, define trigger zones, or annotate level design without rendering them as tiles.
Custom Layers for Sprites
STI lets you attach your own update and draw logic to the map via custom layers. Call map:addCustomLayer() with a name and optional insert position:
map:addCustomLayer("Sprites", 3)
map.layers["Sprites"].update = function(self, dt)
-- move sprites, check collisions, etc.
end
map.layers["Sprites"].draw = function(self)
for _, sprite in ipairs(self.sprites) do
sprite:draw()
end
end
Custom layers live inside map.layers alongside regular tile layers, so you can draw them in the correct order by calling map:drawLayer(map.layers["Sprites"]) if needed, or let the layer’s own draw callback handle it. Remember that you must define both update and draw yourself — STI does not provide defaults.
Collision Detection with Box2D Plugin
STI includes a Box2D plugin that generates collision shapes from your tile map automatically. Load it by passing the plugin name during initialization:
local sti = require("sti")
function love.load()
map = sti("map.lua", { "box2d" })
local world = love.physics.newWorld(0, 0)
map:box2d_init(world)
-- optional: set collision categories per layer
map:box2d_setLayer("Walls", "wall", world)
end
The map:box2d_init(world) call creates collision bodies for every tile that has the solid property set to true in Tiled. Your player or enemy can then collide against these bodies using standard Box2D queries.
One thing to watch out for: scaling the map with map:draw(tx, ty, sx, sy) does not update the Box2D collision bodies automatically. If you need a scaled world, you must scale your Box2D world coordinates manually or create a separate collision setup at the desired scale.
Common Pitfalls
Here are the most frequent issues you will encounter with STI:
Wrong path. STI resolves map paths relative to love.filesystem. If you run your game as a .love archive, the map file must be inside the archive. If you get a black screen with no errors, double-check the path.
Tile Collections are not supported. Tiled supports two tileset types: image tilesets and tile collections. STI only works with image tilesets — the ones backed by a single sprite sheet. If your tileset shows individual files instead of a grid, switch to an image tileset in Tiled.
Coordinates are 1-indexed. Tiled numbers tiles starting at 1. Passing 0 for an x or y coordinate silently fails. Use map:convertPixelToTile(x, y) and map:convertTileToPixel(tx, ty) when converting between pixel space and tile space to avoid off-by-one errors.
Tiled version upgrades. If you upgrade Tiled, re-export your maps. Format changes between versions can break loading. Keep a known-good version of Tiled around if you have a mature map file.
Scale and Box2D are independent. Drawing a scaled map with map:draw(tx, ty, sx, sy) does not scale the collision bodies. Your physics simulation runs in tile-pixel coordinates; handle scaling at render time or adjust your world gravity and body sizes to match.
Custom layer callbacks need manual definition. When you call map:addCustomLayer, STI creates the layer entry but does not populate update or draw. You must assign these yourself or the layer does nothing.
See Also
- Love2D Getting Started — Set up your LÖVE environment and run your first game loop
- Collision Detection — Detect and respond to collisions in 2D space
With those issues in mind, STI gives you a fast path from a Tiled map to a rendered, queryable, physics-enabled game world. Start with a simple static map, get it drawing, then layer in animations, tile queries, and Box2D collisions one step at a time.