Coordinate systems
forge uses three coordinate spaces. Always be explicit about which you’re in.
The three spaces
Section titled “The three spaces”| Space | Unit | Origin | Used by |
|---|---|---|---|
| Cell | integer cells | (0, 0) top-left of grid | ECS components, game logic, g.line_of_sight, g.move_tile |
| World | design pixels | (0, 0) top-left of design viewport | Sprite positions (pos_c), Pixi rendering inside app.render.world |
| Canvas | screen pixels | (0, 0) top-left of canvas buffer | DOM pointer events (clientX/Y after rect-translate), gl.readPixels |
Converting between them
Section titled “Converting between them”Cell ↔ World
Section titled “Cell ↔ World”import { g } from "./grid.ts";const world_pos = g.cell_to_world(cx, cy); // returns cell CENTER: { cx*tile + tile/2, cy*tile + tile/2 }const cell = g.world_to_cell(world_x, world_y); // floor-based, works for any point in the cellcell_to_world returns the center of the cell, not the top-left. Sprites with anchor: { x: 0.5, y: 0.5 } render correctly at this position with no additional offset.
Canvas ↔ World
Section titled “Canvas ↔ World”import { event_to_world, coord_transform } from "@f0rbit/forge/pixi";
// From a DOM PointerEvent (handles getBoundingClientRect + CSS-pixel→canvas-buffer DPR scaling)const world = event_to_world(e, canvas, app.camera);
// From raw canvas coordsconst world = app.camera.screen_to_world({ x: canvas_x, y: canvas_y });
// Inverseconst canvas = app.camera.world_to_screen({ x: world_x, y: world_y });
// Snapshot of the current transformconst t = coord_transform(app.camera);// → { scale, offset, view, canvas_to_world, world_to_canvas }Common pitfalls
Section titled “Common pitfalls””Why is container.toLocal() returning canvas coords unchanged?”
Section titled “”Why is container.toLocal() returning canvas coords unchanged?””forge uses a two-stage RenderTexture pipeline. app.render.world lives in design-pixel space and is rendered offscreen; the fit-to-canvas scale lives on the surface_sprite on app.stage. app.render.world.worldTransform is identity. Use app.camera.screen_to_world instead — it reads from the camera’s viewport, which always has the correct scale + offset.
”Why do my click coords land in the wrong cell?”
Section titled “”Why do my click coords land in the wrong cell?””Three common causes:
- You’re computing
fit_scale = Math.min(canvas.w / world.w, canvas.h / world.h)manually — wrong forpixel_perfect: truecameras (which floor the scale to an integer) and wrong forextendmode (where the viewport extends beyond design dimensions). Useapp.camera.screen_to_worldinstead. - You’re adding
+ g.tile / 2to coords retrieved frompos_c—pos_cis already cell-center; the addition double-shifts. - You’re using CSS pixels (
e.clientX - rect.left) instead of canvas-buffer pixels —event_to_worlddoes the conversion automatically.
”Sprite anchor + cell position confusion”
Section titled “”Sprite anchor + cell position confusion””pos_c for a cell-aligned entity is g.cell_to_world(cx, cy) = cell CENTER. With anchor: { x: 0.5, y: 0.5 }, the sprite renders centered on the cell. Both halves of this convention are required to be consistent — change one without the other and sprites shift by half a tile.
Roundtrip guarantee
Section titled “Roundtrip guarantee”const cell = { x: 5, y: 3 };const world = g.cell_to_world(cell.x, cell.y);const canvas = app.camera.world_to_screen(world);const back_world = app.camera.screen_to_world(canvas);const back_cell = g.world_to_cell(back_world.x, back_world.y);// → { x: 5, y: 3 } ✓Tested in test/pixi/coords.test.ts and test/pixi/camera.test.ts (per-cell roundtrip across full viewport).