DEMIENGINEDOCUMENTATION Everything is free
2D & isometric · Game systems

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:

  • IsoTransform places one entity on the diamond lattice and handles depth sorting for its sprite.
  • IsoGrid defines 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.

Transform2DIsoTransform
Position meansWorld unitsTile coordinates
Depth sortingBy sorting_order and layerAlso by isometric depth, so upper tiles draw behind lower ones
Use forFree placement, cameras, effectsAnything 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_width is the full diamond width in world units.
  • tile_height is the full diamond height in world units. The 2:1 ratio is the classic isometric diamond.
  • origin is the world position of tile (0, 0).
  • size is [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)
CallArgumentsReturns
availablegrid idtrue when the grid is configured and ready, otherwise false.
tile_to_worldgrid id, tile tx, tyTwo scalars wx, wy — the world position of that tile’s center on the diamond.
world_to_tilegrid id, world wx, wyTwo scalars tx, ty — the tile under that world point.
get_tilegrid id, tx, tyTile ID number at the cell, or 0 when empty.
entity_atgrid id, tx, tyEntity 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_world returns two scalars suitable for a Transform2D position.
  • world_to_tile returns two scalars in tile units, truncated to the cell that contains the point.
  • Use tile_to_world to spawn; use world_to_tile to 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
CallArgumentsReturns
can_placegrid id, tx, tytrue when the cell accepts a new occupant (in bounds, not blocked, unoccupied); otherwise false.
set_previewgrid id, tx, ty, tile ID to ghostNothing. Draws a translucent preview at the cell.
clear_previewgrid idNothing. 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
  • from and to are tile or world endpoints accepted by the grid’s path request.
  • path is an array of { x, y } world points to walk in order, or nil when no route exists.
  • diagnostic is 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
CallBehavior
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.