A procedural, coordinate-based sector map for UrsaMU — Simplex topology, sparse DBO overlays, and a Latin-1 split-pane renderer.
- A topology engine that turns any
(x, y, z)into a deterministic biome via two Simplex noise fields and a Whittaker matrix. - A sparse persistence model: no rooms are pre-materialized. Procedural terrain is the default; the DBO collection
map.overlaysstores only authored overrides. - A native-UI renderer that emits a 78-column briefing using UrsaMU's
header/divider/footerhelpers, plus a+mapcommand and aDESCFORMAThandler.
Default config, centre (144, 219, 0), two authored overlays, three contacts. Generated by
deno task showcase.
==============================================================================
%chSECTOR 4A: JABIIM TRENCHES%cn
==============================================================================
LOC: (144, 219) Z: 0
t ~ ~ ~ t t T ~ ~ ~ T T T T t | %chTOPOGRAPHY & CLIMATE%cn
t t t t t t t ~ ~ T T T T T t | dense brush crowds the line of sight To the
t t t t t t t ~ + T T T T T ~ | north, open water stretches outward To the
t t t t t t t T # T T T T T t | west, the plain runs out toward the horizon
t ~ = t t t t ~ ~ ~ ~ ~ T T t |
= = = t t t t t ~ ~ ~ T T T T |
. = = t t t t t t ~ ~ ~ T T t |
%chNOTABLE INFRASTRUCTURE%cn
------------------------------------------------------------------------------
# (145, 219) Forward Command Bunker (Republic)
+ (145, 220) Unsecured Munitions Cache
%chSECTOR CONTACTS%cn
------------------------------------------------------------------------------
Lemuel is operating in an AT-RT Walker.
RC-1138 "Boss" (Republic) holds a defensive perimeter.
2x B2-Super Battle Droid (Hostile) is advancing through the brush.
%chADJACENT SECTORS%cn
------------------------------------------------------------------------------
N (Deep Water), S (Deep Water), E (Brush), W (Plains)
==============================================================================
deno add jsr:@ursamu/map-pluginThen drop into your game's plugin loader:
import mapPlugin from "@ursamu/map-plugin";import { loadPlugin } from "ursamu";
import mapPlugin from "@ursamu/map-plugin";
await loadPlugin(mapPlugin); // registers +map, wires DESCFORMAT
// In-game: +map -> renders the sector at your state.coord (or 0,0,0)| Command | Lock | What it does |
|---|---|---|
+map / +map/here |
connected |
Renders the procedural sector centred on the caller's active MapEntity. |
+map/embark <vehicle> |
connected |
Board a map-capable object in the same room. |
+map/disembark |
connected |
Step out of the current vehicle into its current location. |
+map/launch |
connected |
From inside a map-capable vehicle, create a MapEntity and enter the map. |
+map/land |
connected |
Destroy the MapEntity, return the vehicle to lastDock. |
+move <dir> |
connected |
n/s/e/w/ne/nw/se/sw/u/d — walk the caller's active entity. |
+map/link <entityId> |
connected |
Take remote control of a scout / structure that names you as controllerId. |
+map/unlink |
connected |
Release any remote link. |
+map/spectate <entityId> |
admin | See the map through an entity's eyes; read-only. |
+map/unspectate |
admin | Exit spectate mode. |
+map/stats |
admin | Dump entity / fog DBO summary. |
+map/jump <x> <y> [z] |
admin | Teleport the caller's active entity to a coordinate. |
See docs/entities.md, docs/embarkation.md, docs/fog-of-war.md.
Help text registered with the engine (verbatim from commands.ts):
+map[/<switch>] [<args>] — View the procedural sector map.
Switches:
/here Centre the map on your current coordinate (default).
/jump <x> <y> [z] Teleport your map cursor to a coordinate (builder+).
Examples:
+map Render the sector around you.
+map/here Same as bare +map.
+map/jump 120 -40 Jump to (120, -40, 0).
+map
-> getPlayerCoord(state) [state.ts]
-> DESCFORMAT handler [format.ts]
-> topology.sample(coord) [topology.ts]
-> getOverlaysInRegion() [state.ts]
-> renderMap(input) [renderer.ts]
-> u.send(output)
The plugin uses UrsaMU's three-phase lifecycle. Phase 1 (module load): importing ./commands.ts registers +map via addCmd. Phase 2 (init): registerFormatHandler("DESCFORMAT", descFormatHandler) wires the renderer into the format-attribute pipeline. Phase 3 (remove): unregisterFormatHandler cleanly detaches. See docs/architecture.md.
The MapConfig shape (schemas.ts):
| Field | Type | Purpose |
|---|---|---|
noise.elevation |
MapNoiseConfig |
Seed, scale, and octaves for the elevation field (0..1). |
noise.moisture |
MapNoiseConfig |
Seed, scale, and octaves for the moisture field (0..1). |
biomes |
BiomeDefinition[] |
id, name, glyph, color, traversal, phrase fragments. |
legend |
MapLegend |
Glyph categories — terrain / infrastructure / entities. |
matrix |
WhittakerCell[] |
Elevation + moisture ranges -> biome id. First match wins. |
viewportWidth |
number? |
Minimap columns; must be odd (default 15). |
viewportHeight |
number? |
Minimap rows; must be odd (default 7). |
sectors |
Record<string, { name, aabb }>? |
Named regions used for header titles. |
See docs/configuration.md for the Whittaker matrix deep-dive.
Procedural is the default. Every call to +map (or any DESCFORMAT resolution against a coord-bearing target) runs topology.sample() over the viewport — no rooms are created, no DBO records are written. The same coordinate is the same biome for the lifetime of the seed.
setOverlay(overlay) is the only way to make a coordinate persistent. Overlays live in the DBO collection map.overlays (constant OVERLAY_COLLECTION in schemas.ts), keyed by `${x},${y},${z}`. Overlay records carry optional glyph, biome, name, kind, faction, desc. Reads through getOverlaysInRegion(min, max) reject any AABB whose span exceeds REGION_MAX_TILES = 4096.
MapEntity + map-capable gating. Mobile pieces of the world (vehicles, squads, scouts) are persisted as MapEntity rows in the map.entities DBO collection — players never carry coordinates themselves. A MapEntity is only reachable from in-game when its containerId references a MAP_CAPABLE object the player has embarked, or when the player's state.mapControlling names the entity as a remote link. Without one of those, the player has no map presence and every map command refuses. See docs/entities.md for the full model.
safeTextinrenderer.tsstrips%c?color codes and rewrites[/]to(/)so authored content cannot inject native-UI control sequences.parseCoord(commands_internals.ts) accepts only finite integers and is range-checked downstream.validateOverlayrejects non-integer coords,|x| > 1_000_000, multi-character glyphs, strings over 80 chars (2048 fordesc), and any string containing[or].getOverlaysInRegionthrows when the requested AABB spans more thanREGION_MAX_TILES = 4096cells.
See docs/security.md.
The handler is registered on the DESCFORMAT format-attribute hook. Resolution order:
- Softcode
@desc— if the target has aDESCattribute, the engine uses it. As a defensive belt-and-braces, the handler also callsu.attr.get(target.id, "DESC")and returnsnullif it finds one. - Our handler — fires when the target has a
state.coordor themapflag. Returns the rendered sector. - Engine default — used when our handler returns
null.
Because the handler returns null for any target without state.coord and no map flag, it is safe to register globally.
The plugin exposes a set of registries and helpers so sibling plugins (combat systems, encounter tables, AI GM bridges, mobile companion apps) can extend the map without forking.
A realm is a logical map id. Coords carry an optional realm field; when
absent, they belong to "default". The plugin uses realms throughout —
overlay lookups, entity scans, fog memory, and topology sampling all scope
by realm.
import { realmOf, DEFAULT_REALM } from "@ursamu/map-plugin";
realmOf({ x: 0, y: 0, z: 0, realm: "tatooine" }); // "tatooine"
realmOf({ x: 0, y: 0, z: 0 }); // "default"import { registerMapConfig, getMapConfig } from "@ursamu/map-plugin";
registerMapConfig("tatooine", tatooineCfg);
// later: `getMapConfig("tatooine")` (and the cached TopologyEngine) drive
// rendering whenever a coord with realm "tatooine" is sampled.Unregistered realms fall back to defaultMapConfig. Re-registering a realm
invalidates its cached TopologyEngine.
moveCoord(u, playerId, from, delta, opts?) performs a single-step move
with traversal cost + guard veto. Siblings register guards to veto with a
reason:
import { registerMoveGuard, moveCoord, N } from "@ursamu/map-plugin";
registerMoveGuard(({ playerId, to }) =>
isEncumbered(playerId)
? { allow: false, reason: "encumbered" }
: { allow: true }
);
const result = await moveCoord(u, playerId, currentCoord, N);
// { ok: false, blocked: "encumbered", cost: 1, biome, from, to } on veto
// { ok: true, cost, biome, from, to } on success+move runs the same guard chain via runMoveGuards(ctx), so guards fire
on both player- and entity-driven moves.
If +move doesn't fit your game ("go", "drive", "pilot", "rush", "jump"),
register your own command and use entityStep as the engine. It runs the
full validation pipeline (bounds, overlay block, occupant stacking,
impassable, guards) and emits the same map:player:moved /
map:player:blocked events as the built-in.
import { addCmd } from "ursamu";
import {
entityStep,
getActiveEntity,
STEP_DIRECTIONS,
} from "@ursamu/map-plugin";
addCmd({
name: "drive",
pattern: /^drive\s+(\S+)/i,
lock: "connected",
exec: async (u) => {
const dir = STEP_DIRECTIONS[u.cmd.args[0].toLowerCase()];
const active = await getActiveEntity(u);
if (!dir || !active) return u.send("usage: drive <dir>");
const r = await entityStep(u, active.entity, dir);
u.send(r.ok
? `Your ${active.entity.name} rolls to (${r.to.x}, ${r.to.y}).`
: `Stalled: ${r.reason ?? r.blocked}.`);
},
});To suppress the bundled +map / +move, configure the plugin in either the
main engine config or a plugin-local override file.
Engine config — config/config.json (preferred, deployer-facing):
{
"plugins": {
"map": {
"defaultCommands": {
"map": true,
"move": false
}
}
}
}Plugin-local — config/map.json (defaults the plugin ships with):
{
"defaultCommands": {
"map": true,
"move": false
}
}true / missing = register; false = skip. Engine config wins per-key over
the local file, so operators always have the final say without touching the
plugin's repo. The rest of the extension API stays live regardless of these
toggles.
Precedence (high → low):
- explicit
opts:registerDefaultCommands({ move: false }) - env var
URSAMU_MAP_DISABLE_DEFAULT_COMMANDS=1(kills both — useful for tests / CI) - engine config
plugins.map.defaultCommandsinconfig/config.json - plugin-local
config/map.json - hardcoded defaults (both register)
Emitted events (via gameHooks):
| Event | Payload | Fires when |
|---|---|---|
map:player:moved |
{ playerId, from, to, biome, cost } |
moveCoord succeeds |
map:player:blocked |
{ playerId, from, to, reason, biome } |
terrain / overlay / guard veto |
import { registerRenderLayer, registerInfoLine } from "@ursamu/map-plugin";
registerRenderLayer("encounters", ({ viewport, realm }) =>
encountersIn(viewport, realm).map((e) => ({
coord: e.coord,
glyph: "!",
authored: true,
}))
);
registerInfoLine(({ realm }) =>
`Faction: ${activeFactionFor(realm)}`
);Layers paint in registration order; later wins at the same coord. Re-registering the same name replaces. Info lines append below "ADJACENT SECTORS" in a new "INTEL" section. Each provider is sandboxed — a thrown provider is logged and skipped, render still completes.
Nested regions with metadata. getRegion returns the deepest match;
getRegionPath returns the deepest-to-outermost chain.
import { getRegion, getRegionPath } from "@ursamu/map-plugin";
const cfg = getMapConfig("tatooine");
getRegion(cfg, { x: 0, y: 0, z: 0, realm: "tatooine" });
// → { slug: "moseisley", name: "Mos Eisley", parent: "huttspace", tags: ["spaceport"], ... }
getRegionPath(cfg, coord).map((r) => r.name).join(" — ");
// → "Mos Eisley — Hutt Space — Outer Rim"Legacy MapConfig.sectors still works; it auto-converts into single-level
regions when regions isn't set.
import { findPath, getTraversalCost } from "@ursamu/map-plugin";
const path = findPath(from, to, {
overlays: await getOverlaysInRegion(min, max),
maxCost: 64,
avoid: (c) => isHostileTile(c),
});A* on the grid. Honors BiomeDefinition.traversal + overlay
blocksMovement / kind === "blocked". Diagonals on by default (cost
×√2). Returns null when unreachable within maxCost or
maxIterations. Cross-realm and cross-z queries return null.
Bearer-authenticated routes under /api/v1/map/:
GET /api/v1/map/realm/:id/render?center=x,y&radius=N— JSON tile grid (parity with the in-game renderer).GET /api/v1/map/player/:id—{ realm, coord, biome }.POST /api/v1/map/overlay— admin-locked, author tile.DELETE /api/v1/map/overlay?x=&y=&z=— admin-locked, clear tile.
All routes return 401 before any DB / topology work when the bearer resolves to a null user.
After upgrading from v2.x, run once:
import { migrateToV3 } from "@ursamu/map-plugin";
await migrateToV3();
// { overlays: { inspected, rewritten, skipped }, fog: { ... } }Rewrites pre-v3 "x,y,z" DBO ids/keys into the v3 "realm:x,y,z" form.
Idempotent.
| Task | Description |
|---|---|
deno task test |
Run the Deno test suite (-A --unstable-kv). |
deno task check |
Type-check index.ts. |
deno task lint |
Lint sources (excludes tools/). |
deno task showcase |
Render the canonical Jabiim Trenches snapshot to stdout. |
ursamu-map-plugin/
index.ts plugin entry; init/remove lifecycle
commands.ts +map command + switches
commands_internals.ts parseCoord and shared helpers
schemas.ts types + constants (single source of truth)
topology.ts Simplex + Whittaker engine
state.ts DBO overlays, player coord, validators
format.ts DESCFORMAT handler (renderer input assembly)
renderer.ts Latin-1 split-pane renderer
entities.ts MapEntity DBO + access predicates
fog.ts fog-of-war, visibility, memory pruning
mapconfig.ts per-realm MapConfig + TopologyEngine registry
regions.ts nested region resolution
move.ts moveCoord, move-guard registry, runMoveGuards
extensions.ts render-layer + info-line provider registries
pathfinding.ts getTraversalCost + findPath (A*)
routes.ts /api/v1/map REST surface
migrate.ts v3 DBO key migration helpers
config.default.ts bundled default biomes / matrix / noise
config/config.json runtime overrides
help/map.md in-game help
ursamu.plugin.json plugin manifest
deno.json JSR + tasks
tools/showcase.ts snapshot generator
tests/
map_render.snapshot.txt
security.test.ts
showcase.test.ts
docs/ architecture, configuration, security
UrsaMU >= 2.5.2. The plugin imports createNoise (per-instance Noise class) added in 2.5.2, plus header / divider / footer and registerFormatHandler / unregisterFormatHandler from 2.3.0. Zero npm dependencies — all noise + PRNG comes from the engine.
- Chunk-key index for
map.fogandmap.entitiesso per-render region queries stop full-scanning the collections. - Vehicle stacking / collision rules — currently two entities can share a tile silently.
- Line-of-sight for stealth detection: tie
MapEntity.hiddeninto per-viewer probabilistic spotting rather than a binary toggle. - REST routes (read-only sector lookup, builder overlay CRUD) are not implemented.
getOverlaysInRegionstill uses an in-memory full scan; a shared chunk index would cover overlays too.
MIT.