@tamb/gamegrid
v1.0.0
Published
A 2D HTML grid for creating web games (or other things that could use a 2D matrix)
Maintainers
Readme
GameGrid
A 2D HTML Grid for Creating Web Games
or other things that could use a 2D matrix
Docs & demo (GitHub Pages): site home · API reference · interactive demo
Goals
- 2D grid in memory with coordinates and movement rules
- Hooks: callbacks, middleware, and DOM-optional rendering
- TypeScript types included
- Have fun with it
Demo (Parcel app)
Try the published demo on GitHub Pages, or run it locally (below).
The demo/ package depends on this library via "@tamb/gamegrid": "file:.." so npm install inside demo/ always picks up the built dist/ next to it (no npm pack tarball).
Demo scripts (run from repo root)
| Script | Purpose |
|--------|---------|
| npm run demo | Clean lib + demo artefacts, build library, install demo deps, compile Handlebars, start Parcel |
| npm run demo:safe | Same as demo, but runs tests before building |
| npm run demo:dev | Start Parcel only (after demo:prepare or a prior demo run) |
| npm run demo:test | Run library Vitest suite |
| npm run demo:prepare | npm run build + npm install in demo/ |
| npm run demo:build | Production Parcel build in demo/dist |
| npm run demo:link | Build, npm link the library, install demo deps, link @tamb/gamegrid in the demo |
| npm run demo:link:lib | Build + npm link only (registers @tamb/gamegrid globally) |
Fresh start:
npm run demoFast iteration after library changes (rebuild dist/, then reload Parcel):
npm run build && npm run demo:devnpm link workflow (optional):
npm run demo:link
npm run demo:devAfter a change to the library, run npm run build again so dist/ updates; Parcel will pick it up on reload when using file:.. or a npm link symlink.
API documentation (TypeDoc)
Browse the published API reference on GitHub Pages, or generate HTML locally:
npm run docsHTML lands in gh-pages/docs/ (open gh-pages/docs/index.html locally). The TypeDoc landing page uses docs/API.md (overview + table of contents); the full README stays on GitHub.
Combined demo + docs bundle for GitHub Pages:
npm run gh-pagesThat clears gh-pages/docs and gh-pages/demo, npm run build, reinstalls demo/ deps, runs TypeDoc to gh-pages/docs, and parcel build to gh-pages/demo/. The checked-in gh-pages/index.html links to demo/output.html and docs/.
GitHub Pages
Live site: https://tamb.github.io/game-grid/ (API docs at /docs/, demo at /demo/output.html).
Use gh-pages/ as the site root /: keep index.html and .nojekyll tracked. Generated gh-pages/docs/ and gh-pages/demo/ are gitignored (so they won't show up in git status) — editors may hide gitignored folders; this repo sets explorer.excludeGitIgnore to false in .vscode/settings.json so gh-pages/demo stays visible locally. Confirm with ls gh-pages/demo after npm run gh-pages.
TSDoc tip: {@link …} tags must appear in normal comment text. Wrapping the whole {@link …} in inline code (Markdown backticks) stops TypeDoc from resolving links in the generated HTML.
Coordinates
Movement and state use [x, y]: column (x), then row (y). The backing matrix is a normal 2D array: matrix[row][col] i.e. matrix[y][x]. Methods like getCell([x, y]), setActiveCell(x, y, …), and getState().activeCoords all follow that convention.
The class
Install @tamb/gamegrid. The default export is GameGrid. Many constants and types are named exports (see Public exports).
import type { IGameGrid } from "@tamb/gamegrid";
import GameGrid from "@tamb/gamegrid";
// Optional second argument: container to render into immediately.
const grid: IGameGrid = new GameGrid(config, rootElement);
// Headless (no DOM): omit the container.
const memory: IGameGrid = new GameGrid(config);When you pass a container in the constructor, render(container) runs immediately. Otherwise call render(element) later. Headless mode sets refs.cells to your matrix reference and state.rendered to false.
config: IConfig
export interface IConfig {
options?: IOptions;
matrix: ICell[][];
state?: IDefaultState | IState;
}options: IOptions
export type MiddlewareFn = (
gamegridInstance: IGameGrid,
patch: StatePatch,
) => void;
export interface IOptions {
id?: string;
/** Dispatches custom events here; defaults to `window`. */
eventTarget?: EventTarget;
arrowControls?: boolean;
wasdControls?: boolean;
infiniteX?: boolean;
infiniteY?: boolean;
clickable?: boolean;
rewindLimit?: number;
middlewares?: {
pre?: MiddlewareFn[];
post?: MiddlewareFn[];
};
callbacks?: {
onMove?: (gamegridInstance: IGameGrid, newState: IState) => void;
onLand?: (gamegridInstance: IGameGrid, newState: IState) => void;
onBlock?: (gamegridInstance: IGameGrid, newState: IState) => void;
onCollide?: (gamegridInstance: IGameGrid, newState: IState) => void;
onDettach?: (gamegridInstance: IGameGrid, newState: IState) => void;
onBoundary?: (gamegridInstance: IGameGrid, newState: IState) => void;
onBoundaryX?: (gamegridInstance: IGameGrid, newState: IState) => void;
onBoundaryY?: (gamegridInstance: IGameGrid, newState: IState) => void;
onWrap?: (gamegridInstance: IGameGrid, newState: IState) => void;
onWrapX?: (gamegridInstance: IGameGrid, newState: IState) => void;
onWrapY?: (gamegridInstance: IGameGrid, newState: IState) => void;
onZoomSet?: (gamegridInstance: IGameGrid, newState: IState) => void;
onZoomCleared?: (gamegridInstance: IGameGrid, newState: IState) => void;
onZoomEdge?: (gamegridInstance: IGameGrid, newState: IState) => void;
onZoomExit?: (gamegridInstance: IGameGrid, newState: IState) => void;
onRegionChange?: (gamegridInstance: IGameGrid, newState: IState) => void;
};
/** Cell `type` values you cannot step onto; you stay on the previous cell. */
blockOnType?: string[];
/** Cell `type` values that trigger collision when entered (you still move unless also blocked). */
collideOnType?: string[];
/**
* If non-empty, only these `type` values are enterable (in addition to `blockOnType`).
* If omitted or empty, any non-blocked cell is enterable.
*/
moveOnType?: string[];
/** Default whether zoom transitions animate. Overridden by per-call `IZoomOptions.animate`. */
animateZoom?: boolean;
/** When zoom is set, keep movement inside the zoom window (default `true`). */
constrainToZoom?: boolean;
/** Opt-in region tracking (`2` = quadrants, `3` = ninths). */
regionDivisions?: number;
/** CSS transition duration (ms) for zoom slide. Default: `300`. */
zoomSlideDuration?: number;
/** When `true` with `regionDivisions`, `ZOOM_EDGE` auto-zooms to the adjacent region. Default: `false`. */
slideZoomOnEdge?: boolean;
/** Appended to `.gamegrid__viewport` when zoom is active. */
zoomViewportClasses?: string[];
activeClasses?: string[];
cellClasses?: string[];
containerClasses?: string[];
rowClasses?: string[];
}Default options (before your config.options spread):
{
arrowControls: true,
wasdControls: false,
infiniteX: false,
infiniteY: false,
clickable: true,
rewindLimit: 20,
blockOnType: [cellTypeEnum.BARRIER],
collideOnType: [cellTypeEnum.INTERACTIVE],
moveOnType: [],
animateZoom: false,
constrainToZoom: true,
zoomSlideDuration: 300,
slideZoomOnEdge: false,
}Use cellAttributes on ICell for per-cell attributes; activeClasses / cellClasses / containerClasses / rowClasses append classes on render.
matrix: ICell[][]
Rows of cells. Each ICell must include type (see cellTypeEnum). Optional render, cellAttributes, etc.
export interface ICell extends IRef {
type: string;
render?: (context: ICellContext) => HTMLElement;
cellAttributes?: string[][];
eventTypes?: { onEnter: string; onExit: string };
coords?: number[];
}
interface ICellContext {
coords: number[];
cell: ICell;
gamegrid: IGameGrid;
}state: IState
export interface IState {
activeCoords: number[];
prevCoords: number[];
moves: number[][];
rendered?: boolean;
currentDirection?: string;
zoom: IZoomBounds | null;
region: IRegionTile | null;
}
export type StatePatch = Partial<IState> & Record<string, unknown>;setStateSync(patch) shallow-merges a StatePatch into state. StatePatch still allows arbitrary extra keys for your own bookkeeping.
Initial merge uses INITIAL_STATE from the package (actual export lives in src/enums.ts):
export const INITIAL_STATE: IState = {
activeCoords: [0, 0],
prevCoords: [0, 0],
rendered: false,
moves: [],
currentDirection: directionEnum.DOWN,
zoom: null,
region: null,
};Middleware
pre runs before the merge; you can mutate the patch object in place before it is merged.
post runs after the merge; use gamegridInstance.getState() for the full merged IState. The second argument remains the patch passed to setStateSync.
Refs
export interface IRefsObject {
container: HTMLElement | null;
rows: IRow[];
cells: ICell[][];
}
export interface IRow extends IRef {
index: number;
cells: ICell[];
}IRow can carry a current HTMLDivElement for the row when rendered. IRefs is a deprecated alias for IRefsObject.
IGameGrid (instance API)
export interface IGameGrid {
refs: IRefsObject;
options: IOptions;
render(container: HTMLElement): void;
/** Rebuild DOM from current `matrix` and re-apply active cell UI. Requires a prior render. */
refresh(): void;
/** Tear down listeners and DOM when rendered; always emits DESTROYED. */
destroy(): void;
getOptions(): IOptions;
setOptions(newOptions: IOptions): void;
getState(): IState;
setStateSync(obj: StatePatch): void;
getActiveCell(): ICell;
getPreviousCell(): ICell;
getCell(coords: readonly [number, number] | number[]): ICell;
getAllCellsByType(type: string): ICell[];
setActiveCell(x: number, y: number, direction?: string): void;
getMatrix(): ICell[][];
setMatrix(matrix: ICell[][]): void;
moveUp(): void;
moveRight(): void;
moveDown(): void;
moveLeft(): void;
getZoom(): IZoomBounds | null;
setZoom(bounds: IZoomBounds, options?: IZoomOptions): void;
clearZoom(options?: IZoomOptions): void;
getZoomAround(center: readonly [number, number] | number[], radiusX: number, radiusY?: number): IZoomBounds;
getQuadrantZoom(quadrant: ZoomQuadrant): IZoomBounds;
getFractionZoom(divisions: number, tileX: number, tileY: number): IZoomBounds;
zoomAround(center: readonly [number, number] | number[], radiusX: number, radiusY?: number, options?: IZoomOptions): void;
zoomQuadrant(quadrant: ZoomQuadrant, options?: IZoomOptions): void;
zoomFraction(divisions: number, tileX: number, tileY: number, options?: IZoomOptions): void;
getRegionAt(coords: readonly [number, number] | number[], divisions?: number): IRegionTile;
getActiveRegion(): IRegionTile | null;
}The GameGrid class implements IGameGrid. The mounted root element is refs.container after render; it stays null on headless constructions until render runs.
Events
Events are bubbling CustomEvents. Their detail objects implement IGameGridEventDetail: at minimum { gameGridInstance: IGameGrid } (plus any extra keys you pass if you call fireGameGridEvent yourself). For typing listeners, use GameGridDOMEvent (CustomEvent<IGameGridEventDetail>).
By default the grid dispatches on window. Set options.eventTarget (for example a dedicated EventTarget) so multiple grids do not all share the global bus.
gameGridEventsEnum is an identical compatibility alias — use either name.
export const gridEventsEnum = {
// Dispatched after GameGrid.render wires the container (`detail` follows IGameGridEventDetail).
RENDERED: "gamegrid:grid:rendered",
// Dispatched at the end of construction (after optional initial render).
CREATED: "gamegrid:grid:created",
// Dispatched from GameGrid.destroy; fires even if the grid stayed headless / unmounted.
DESTROYED: "gamegrid:grid:destroyed",
// Keyboard / pointer path: onMove already ran; these fire before setActiveCell.
MOVE_LEFT: "gamegrid:move:left",
MOVE_RIGHT: "gamegrid:move:right",
MOVE_UP: "gamegrid:move:up",
MOVE_DOWN: "gamegrid:move:down",
// Target rejected by blockOnType or moveOnType allow-list; coords roll back.
MOVE_BLOCKED: "gamegrid:move:blocked",
// Entered a collideOnType cell (movement may still succeed).
MOVE_COLLISION: "gamegrid:move:collide",
// Left a collide-type cell from the square occupied before this move attempt.
MOVE_DETTACH: "gamegrid:move:dettach",
// Finished block/collide/boundary/wrap resolution; mirrors callbacks.onLand.
MOVE_LAND: "gamegrid:move:land",
// Aggregate finite-edge clamp — axis BOUNDARY_X / BOUNDARY_Y first when relevant.
BOUNDARY: "gamegrid:move:boundary",
// X-axis requested outside row span when infiniteX is off — coordinate clamped.
BOUNDARY_X: "gamegrid:move:boundary:x",
// Y-axis requested outside matrix height when infiniteY is off — coordinate clamped.
BOUNDARY_Y: "gamegrid:move:boundary:y",
// Aggregate infinite wrap — WRAP_X / WRAP_Y first when relevant.
WRAP: "gamegrid:move:wrap",
// Horizontal infinite teleport; runs alongside callbacks.onWrapX.
WRAP_X: "gamegrid:move:wrap:x",
// Vertical infinite teleport; runs alongside callbacks.onWrapY.
WRAP_Y: "gamegrid:move:wrap:y",
// Zoom viewport lifecycle
ZOOM_SET: "gamegrid:zoom:set",
ZOOM_CLEARED: "gamegrid:zoom:cleared",
ZOOM_EDGE: "gamegrid:zoom:edge",
ZOOM_EXIT: "gamegrid:zoom:exit",
REGION_CHANGE: "gamegrid:region:change",
};This mirrors src/enums.ts (same keys and string literals). Import gridEventsEnum or gameGridEventsEnum from @tamb/gamegrid rather than duplicating. The published TypeDoc site (or npm run docs locally) expands the same members with full cross-links.
Instantiation quick start
import GameGrid, { gridEventsEnum, type GameGridDOMEvent } from "@tamb/gamegrid";
const gg = new GameGrid(
{
matrix: myMatrix,
state: { activeCoords: [0, 0] },
options: { wasdControls: true },
},
document.querySelector("#root")!,
);
gg.moveDown();
window.addEventListener(gridEventsEnum.MOVE_LAND, (e: Event) => {
const ce = e as GameGridDOMEvent;
console.log(ce.detail.gameGridInstance);
});For a grid created without a container, call render(el) when you want DOM.
Zoom
Zoom defines a viewport window over the full matrix. Coordinates stay world [x, y] — zoom does not create a submatrix or remap the origin.
| Term | Meaning |
|------|---------|
| zoom | Current viewport bounds (IZoomBounds on state) |
| region | A tile from partitioning the grid (regionDivisions; quadrants when 2) |
| zoom edge | Active cell tried to move past the zoom window while constrainToZoom is enabled |
| zoom exit | Active cell left the zoom window while constrainToZoom is disabled |
| region change | Active cell moved from one region tile to another (e.g. SE → SW) |
| zoom slide | Moving the zoom window with animate: true (built-in CSS transform on .gamegrid__viewport) |
When zoom is active, the grid renders only cells inside the zoom window inside a .gamegrid__viewport wrapper. Useful CSS hooks:
| Class / attribute | When |
|-------------------|------|
| gamegrid--zoomed | Container while zoom is set |
| gamegrid__viewport | Viewport wrapper (always after render) |
| gamegrid__cell--zoom-edge | Perimeter cells of the zoom window |
| gamegrid--zoom-animating | During CSS slide transition |
| data-gamegrid-zoom | Viewport; "minX,minY,maxX,maxY" |
| data-gamegrid-region | Viewport; quadrant label when regionDivisions: 2 |
import GameGrid, { gridEventsEnum, type ZoomQuadrant } from "@tamb/gamegrid";
const gg = new GameGrid({
matrix: largeMap,
options: {
regionDivisions: 2,
animateZoom: true,
slideZoomOnEdge: true,
constrainToZoom: true,
zoomSlideDuration: 300,
},
});
gg.zoomQuadrant("se");
// Manual edge slide (when slideZoomOnEdge is false):
target.addEventListener(gridEventsEnum.ZOOM_EDGE, (e) => {
const { gameGridInstance } = e.detail;
gameGridInstance.zoomQuadrant("sw", { animate: true });
});With slideZoomOnEdge: true, the library auto-advances to the adjacent region on ZOOM_EDGE — no listener required.
Public exports
Besides the default GameGrid, the package re-exports:
- Types:
IConfig,IOptions,IState,IGameGrid,IGameGridEventDetail,GameGridDOMEvent,ICell,ICellContext,IRefsObject,IRow,IDefaultState,IZoomBounds,IZoomOptions,IRegionTile,ZoomQuadrant,MiddlewareFn,StatePatch, and deprecatedIRefs - Values:
gridEventsEnum,gameGridEventsEnum,cellTypeEnum,classesEnum,directionEnum,directionClassEnum,INITIAL_STATE,keycodeEnum
cellTypeEnum values are constants on an object (not an enum). classesEnum and directionEnum are TypeScript enums. Example:
import GameGrid, {
cellTypeEnum,
classesEnum,
directionEnum,
gridEventsEnum,
} from "@tamb/gamegrid";
// cell — const object:
cellTypeEnum.OPEN;
// enums:
classesEnum.GRID;
directionEnum.DOWN;
// Event name strings — see [Events](#events) for the full map
gridEventsEnum.BOUNDARY === "gamegrid:move:boundary";