Skip to content

Move Tile

g.move_tile() steps an entity one cell in a direction with axis-sliding collision: it tries dx first, then dy from the resulting cell. This prevents corner-cutting through diagonal walls — the canonical correctness detail every reimplementation has to rediscover.

import { grid } from "@f0rbit/forge/grid";
const g = grid({ cols: 20, rows: 11, tile: 16 });
const r = g.move_tile(world, player, { dx: 1, dy: 0 }, {
blocked_by: (c) => walls.has(g.key(c.x, c.y)),
});
if (r.ok && r.value.moved) {
if (r.value.to.x === exit.x && r.value.to.y === exit.y) {
// reached the exit
}
}
type TileMoveOpts<P extends { x: number; y: number } = { x: number; y: number }> = {
blocked_by: (cell: Cell) => boolean;
slide?: boolean; // default true
pos?: Component<P>; // defaults to forge's `pos_c`
};
type TileMoveResult = {
from: Cell;
to: Cell;
moved: boolean;
};
type Grid = {
// ...
move_tile: <P extends { x: number; y: number } = { x: number; y: number }>(
w: World,
id: Id,
dir: { dx: -1 | 0 | 1; dy: -1 | 0 | 1 },
opts: TileMoveOpts<P>,
) => Result<TileMoveResult, EngineError>;
};

opts.pos defaults to forge’s canonical pos_c. Pass it explicitly only if your game uses a custom position component:

const my_pos_c = component<{ x: number; y: number }>("my_pos");
g.move_tile(w, id, dir, { blocked_by, pos: my_pos_c });

With slide: true (the default), the move is decomposed:

  1. Try cell (cur.x + dx, cur.y). If blocked_by returns false and it’s in bounds, move X.
  2. From the (possibly updated) cell, try (nx, cur.y + dy). If non-blocked, move Y.

This means a diagonal { dx: 1, dy: 1 } move can “slide” along a wall — push into a corner and you’ll still move along the open axis. A pure 4-way game using { dx: ±1, dy: 0 } or { dx: 0, dy: ±1 } gets the obvious behaviour.

With slide: false, the diagonal cell is checked atomically — a single blocker cancels the move.

movedMeaning
truePosition was updated; to is the new cell.
false, from === toFully blocked, OR direction was (0, 0).
false, from !== toCurrently impossible (slide always commits if any axis moved).

The to cell is the resolved cell — read it to react (“did I just step onto the exit / pickup / damaging tile?”):

if (r.ok && r.value.moved) {
on_step(r.value.to);
}

g.move_tile propagates the kernel’s error union — entity_not_found or component_missing if id doesn’t have the position component. Otherwise it’s ok({ from, to, moved }).

Tile movement usually wants a periodic gate so taps register as discrete steps. Combine with schedule.add (with every) and ticks_per_step:

import { ticks_per_step } from "@f0rbit/forge/grid";
const every = ticks_per_step(8, ctx.time.fixed_dt); // 8 cells/sec
schedule.add("update", movement_system, { every, name: "movement" });

The standalone move_tile export is removed. Drop the pos_c and grid positional args:

// v0.2.0
import { move_tile } from "@f0rbit/forge/grid";
import { pos_c } from "@f0rbit/forge";
const r = move_tile(w, id, pos_c, g, { dx, dy }, { blocked_by });
// v0.3.0
const r = g.move_tile(w, id, { dx, dy }, { blocked_by });