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 });Slide semantics
Section titled “Slide semantics”With slide: true (the default), the move is decomposed:
- Try cell
(cur.x + dx, cur.y). Ifblocked_byreturns false and it’s in bounds, move X. - 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.
Result interpretation
Section titled “Result interpretation”moved | Meaning |
|---|---|
true | Position was updated; to is the new cell. |
false, from === to | Fully blocked, OR direction was (0, 0). |
false, from !== to | Currently 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);}Errors
Section titled “Errors”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 }).
Cadence
Section titled “Cadence”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/secschedule.add("update", movement_system, { every, name: "movement" });Migration from v0.2.0
Section titled “Migration from v0.2.0”The standalone move_tile export is removed. Drop the pos_c and grid positional args:
// v0.2.0import { 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.0const r = g.move_tile(w, id, { dx, dy }, { blocked_by });