Skip to content

Map & zones

The map describes a game’s spatial layout: regions (zones), single spots (points), and decorations (annotations). Everything is authored in a local meters frame and projected onto real coordinates when the gamemaster places it.

interface MapDefinition {
zones: MapZone[];
points?: MapPoint[];
annotations?: MapAnnotation[];
}

Zone and point geometry use MetersXY:

interface MetersXY { x: number; y: number } // x = east, y = north, in meters

So from: { x: -150, y: -100 }, to: { x: 150, y: 100 } is a 300 m × 200 m rectangle centered on the layout origin. The GM chooses the real-world center at placement; the engine projects meters → latitude/longitude there.

interface MapZone {
id: string;
name?: string;
color?: string; // hex; outline + fill on the GM canvas
shapes?: MapShape[];
placeable?: boolean;
required?: boolean;
exclusion?: boolean;
navigable?: boolean; // default true
minArea?: number; // square meters
maxArea?: number;
teamId?: string;
blockedBy?: { key: string; value: unknown };
confinedBy?: { key: string; value: unknown };
zones?: MapZone[]; // nested children
}
  • id — unique; how rules reference the zone (zoneId: play_area).
  • placeable — top-level zones with this flag get a Place/Re-place control in the GM console. Children inherit their parent’s placement.
  • required — the game won’t start until this zone is placed and has shapes. Same gate applies to required points.
  • navigable — default true. When false, player position updates that would land inside the zone are rejected (an unconditional wall).
  • blockedBy / confinedBy — conditional movement gates keyed on player state. blockedBy keeps matching players out; confinedBy keeps matching players in. Shape { key, value } (state key or teamId).
  • teamId — tags the zone as belonging to a team (drives visuals and GM affordances; gameplay enforcement is still up to your rules).
  • minArea / maxArea — square-meter constraints checked at start.
  • zones — nested child zones (see Hierarchy).
  • A zone with no shapes and no children is a placeholder the GM fills at runtime.

A zone’s geometry is one or more MapShape — a Shape with an id and optional exclusion:

type Shape =
| { kind: "polygon"; vertices: MetersXY[] }
| { kind: "rectangle"; from: MetersXY; to: MetersXY }
| { kind: "rectangle"; center: MetersXY; halfWidth: number; halfHeight: number }
| { kind: "circle"; center: MetersXY; radius: number }
| { kind: "circle"; through: [MetersXY, MetersXY, MetersXY] };
type MapShape = Shape & { id: string; exclusion?: boolean };

Multiple shapes in one zone union together into its effective area.

shapes:
- { id: a, kind: rectangle, from: { x: -35, y: -35 }, to: { x: 35, y: 35 } }
- { id: b, kind: circle, center: { x: 0, y: 0 }, radius: 20 }

from/to (opposite corners) or center/halfWidth/halfHeight.

center/radius or through (three points the circle passes through).

An array of vertices.

A shape with exclusion: true subtracts from its containing zone — a cutout. Players standing inside a cutout count as outside the zone, and random item spawns avoid it.

# Pirate's Booty: keep loot off the ships by punching them out of the island.
shapes:
- { id: treasure_island-shape-0, kind: rectangle, from: { x: -35, y: -35 }, to: { x: 35, y: 35 } }
- { id: treasure_island-exclude-red, kind: circle, center: { x: -35, y: 35 }, radius: 15, exclusion: true }

Source: apps/wage-engine/src/games/pirates_booty/game.yaml.

Zones nest via zones. A child is positioned relative to its parent’s frame, so placing (or moving/scaling) the parent moves the whole subtree rigidly. This is how most multi-zone games are a single GM drag:

# King of the Hill: one placeable play area with the hill nested in the middle.
zones:
- id: play_area
placeable: true
required: true
shapes: [{ id: play_area-shape-0, kind: rectangle, from: { x: -150, y: -100 }, to: { x: 150, y: 100 } }]
zones:
- id: hill
name: The Hill
shapes: [{ id: hill-shape-0, kind: circle, center: { x: 0, y: 0 }, radius: 20 }]

Source: apps/wage-engine/src/games/king_of_the_hill/game.yaml.

A MapPoint is a single named coordinate — a flag spawn, a goal.

interface MapPoint {
id: string;
name?: string;
required?: boolean;
parentZoneId?: string; // auto-places with that zone
shape?: { center: MetersXY }; // default position (omit to have the GM drop it)
}
  • With parentZoneId + shape.center, the point auto-places when its parent zone is placed (CTF’s flag points work this way).
  • With no shape.center, the GM drops the point at runtime.
  • required: true gates the start like a required zone.
points:
- { id: blue_flag, name: Blue Flag, parentZoneId: blue_base, shape: { center: { x: -170, y: 0 } } }

Source: apps/wage-engine/src/games/capture_the_flag/game.yaml.

Purely visual map decorations with no gameplay effect — brief your players, mark a no-go area, draw an arrow.

interface MapAnnotation {
id?: string;
kind: "line" | "arrow" | "rectangle" | "circle" | "text";
name?: string; // GM-facing label
from?: MetersXY; to?: MetersXY; // line / arrow
center?: MetersXY; radius?: number; // circle
at?: MetersXY; text?: string; // text
color?: string;
style?: "solid" | "dashed";
}

Rules never reference annotations; clients just render them in a decoration layer. The GM can also add annotations live from the console’s drawing tools.