IsoTransform, placement, and pathfinding.
Isometric grid
Isometric games place objects on a diamond-shaped grid rather than a flat square one. This page explains what that placement means, how the grid converts tiles to world positions, and how to check, preview, and path across it.
What isometric placement is
In a top-down square grid, one tile is one square of floor. In an isometric view, the same logical tile is drawn as a diamond: the camera looks at the world from an angle, so “north-east” on the ground runs up-right on screen. Characters, crates, and walls still occupy integer tile cells — only the way cells map to world and screen positions changes.
Isometric placement exists so a Skyforge station deck can read as 2.5D: sprites sort by depth, props line up on the diamond lattice, and gameplay still reasons in simple tile coordinates.
Two components cooperate:
IsoTransformplaces one entity on the diamond lattice and handles depth sorting for its sprite.IsoGriddefines the lattice itself: tile size, origin, and which cells are occupied.
IsoTransform versus Transform2D
Ordinary Transform2D places an entity at a free world position [x, y] with any rotation and scale. That is right for floating HUD-adjacent props and parallax art.
IsoTransform places an entity at a tile and derives its world position from the grid. It is right for anything that should sit on the lattice: units, buildings, pickups on the deck.
Transform2D | IsoTransform | |
|---|---|---|
| Position means | World units | Tile coordinates |
| Depth sorting | By sorting_order and layer | Also by isometric depth, so upper tiles draw behind lower ones |
| Use for | Free placement, cameras, effects | Anything standing on the grid |
Both still interact with the same sprite draw order: sorting_order, then layer name, then isometric depth.
Grid configuration
{
"IsoGrid": {
"tile_width": 64,
"tile_height": 32,
"origin": [0, 0],
"size": [40, 40]
}
}
tile_widthis the full diamond width in world units.tile_heightis the full diamond height in world units. The 2:1 ratio is the classic isometric diamond.originis the world position of tile(0, 0).sizeis[columns, rows]of cells the grid owns.
Tile and world conversion
Gameplay thinks in tiles; rendering and physics think in world units. The demi.grid module converts between them.
local Grid = require("demi.grid")
print(Grid.available(grid)) -- boolean
local wx, wy = Grid.tile_to_world(grid, tx, ty)
local tx, ty = Grid.world_to_tile(grid, wx, wy)
local tile = Grid.get_tile(grid, tx, ty)
local occupant = Grid.entity_at(grid, tx, ty)
| Call | Arguments | Returns |
|---|---|---|
available | grid id | true when the grid is configured and ready, otherwise false. |
tile_to_world | grid id, tile tx, ty | Two scalars wx, wy — the world position of that tile’s center on the diamond. |
world_to_tile | grid id, world wx, wy | Two scalars tx, ty — the tile under that world point. |
get_tile | grid id, tx, ty | Tile ID number at the cell, or 0 when empty. |
entity_at | grid id, tx, ty | Entity handle occupying the cell, or nil when the cell is free. |
Placing a crate the player just bought in Skyforge’s market:
local Grid = require("demi.grid")
local wx, wy = Grid.tile_to_world(grid, 12, 7)
-- wx, wy → world position of tile (12, 7)
local tx, ty = Grid.world_to_tile(grid, wx, wy)
-- tx, ty → 12, 7 (round-trips back to the same cell)
tile_to_worldreturns two scalars suitable for aTransform2Dposition.world_to_tilereturns two scalars in tile units, truncated to the cell that contains the point.- Use
tile_to_worldto spawn; useworld_to_tileto interpret mouse clicks.
Placement checks and previews
Building games need to answer “may I build here?” before committing, and show a ghost while the player aims.
local Grid = require("demi.grid")
if Grid.can_place(grid, tx, ty) then
Grid.set_preview(grid, tx, ty, 17)
else
Grid.clear_preview(grid)
end
| Call | Arguments | Returns |
|---|---|---|
can_place | grid id, tx, ty | true when the cell accepts a new occupant (in bounds, not blocked, unoccupied); otherwise false. |
set_preview | grid id, tx, ty, tile ID to ghost | Nothing. Draws a translucent preview at the cell. |
clear_preview | grid id | Nothing. Removes the ghost immediately. |
Clear the preview whenever the player cancels placement or the cursor leaves the grid so no ghost is left behind.
Grid paths
Units walking across the deck need routes around walls and other units.
local Grid = require("demi.grid")
local path, diagnostic = Grid.path(grid, from, to)
-- path array of { x, y } world points, or nil on failure
-- diagnostic table explaining why a path could not be built
fromandtoare tile or world endpoints accepted by the grid’s path request.pathis an array of{ x, y }world points to walk in order, ornilwhen no route exists.diagnosticis a table describing the failure (blocked start, unreachable goal). Successful calls may return no diagnostic.
Guard with if path then before iterating — a blocked corridor is a nil path, not an empty array.
Independent navigation grids
demi.navigation creates independently owned logical grids. Each grid stores its own blockers, traversal costs and dimensions. Use the world default grid when you want to share Tilemap navigation and debug visualization; create another grid for a separate map or terrain surface.
local Navigation = require("demi.navigation")
local nav = assert(Navigation.create_grid(32, 32, 2, 0, 0))
-- Alternatively: local nav = Navigation.default_grid()
nav:set_blocked(3, 4, true)
nav:set_cost(5, 5, 2)
local path, diagnostic = nav:path(0, 0, 8, 8, true)
for _, point in ipairs(path) do
print(point[1], point[2], point.world_x, point.world_y)
end
| Call | Behavior |
|---|---|
Navigation.create_grid(width, height, cell_size, origin_x?, origin_y?) | Returns a new grid, or nil for invalid dimensions, scale or origin. Origins default to zero. |
Navigation.default_grid() | Returns the world grid shared with Tilemap navigation and debug display. |
nav:configure(width, height, cell_size, origin_x?, origin_y?) | Reconfigures this grid and clears its blockers/costs; returns success. |
nav:set_blocked(column, row, blocked) / nav:blocked(column, row) | Sets or reads a blocked cell. Coordinates are zero-based; out-of-bounds cells are blocked. |
nav:set_cost(column, row, cost) | Sets a finite traversal cost of at least 1; returns success. |
nav:path(start_col, start_row, goal_col, goal_row, diagonal?) | Returns an array including start and goal, or an empty array on failure, plus a diagnostic string. Success is OK. Diagonals default to false and do not cut blocked corners. |
nav:world_to_cell(x, y) / nav:cell_to_world(column, row) | Returns two coordinates, or nil,nil outside the grid. World coordinates describe cell centres. |
nav:clear() / nav:available() | Clear disables the grid until configured again; available reports whether it is configured. |
Path points contain numeric column/row entries [1], [2] plus world_x and world_y fields. These are logical planar coordinates: a ground navigator can map the second axis to world Z and obtain height separately.
Ground agents in lightweight 3D
The demi.navigation.ground package adds incremental terrain surveys and agents driven by CharacterController3D. It uses independent native grids, reports blocked/unreachable routes, pauses disabled agents and replans when footprint obstacles change. Gameplay chooses jobs and priorities. This supports heightfield ground movement; it is not a multilevel navmesh or crowd-avoidance system.
local Ground = require("demi.navigation.ground")
local surface = Ground.create({terrain="terrain", columns=64, rows=64,
cell_size=2, origin={0,0}, ignored_entity="engineer"})
local agent = surface:agent("engineer", {speed=3})
-- Update the survey until ready, then issue a destination once.
surface:update(64)
if surface:ready() then agent:move_to({30,0,40}, 3) end
-- In fixed update: agent:update(dt)
The actor needs a transform, collider and CharacterController3D. See the package README for lifecycle, resurvey and obstacle updates.
Development migration
The old demi.navigation2d service is replaced by demi.navigation. Use Navigation.default_grid() for the former world grid and change calls to colon methods, or create independently owned grids. There is no legacy alias. Opening a script regenerates the editor’s native LuaLS annotations and removes obsolete native module stubs; custom annotation files remain. Native grid algorithms and Tilemap integration remain shared.
Choosing tools
- One unit moving A to B on an open deck:
Grid.path. - Fleets routing around dynamic hazards with costs:
demi.navigation. - Build-mode ghosts:
can_place+set_preview. - Mouse picking into tile coordinates:
world_to_tile. - Depth-correct sprite stacking:
IsoTransform.
Level content itself comes from Tilemaps, and the art drawn on each cell is standard sprite work.
Ground Navigation 0.3 follows clear straight segments instead of every grid centre. Route simplification checks all crossed survey cells, corner neighbours and obstacle footprints expanded by agent radius. Movement can take arbitrary angles while the grid remains an internal search representation. A zero-radius goal uses the requested world position; blocked exact endpoints report PATH_GOAL_BLOCKED. Checks use cached survey data during planning. This is conservative path simplification, not a multilevel navmesh or curved steering; the character controller still handles collision between samples.