mudlet-map-renderer
v2.6.1
Published
A rendering library for [Mudlet](https://www.mudlet.org/) map data. Takes Mudlet's JSON map format and renders interactive, zoomable maps using Konva, with SVG and PNG export support.
Readme
mudlet-map-renderer
A rendering library for Mudlet map data. Takes Mudlet's JSON map format and renders interactive, zoomable maps using Konva, with SVG and PNG export support.

Install
yarn add mudlet-map-rendererQuick start
import { MudletMapReader } from 'mudlet-map-binary-reader';
import { MapRenderer, MapReader, createSettings } from 'mudlet-map-renderer';
// Read a Mudlet binary map file and export renderer-compatible data
const map = MudletMapReader.read('map.dat');
const { mapData, colors } = MudletMapReader.export(map);
const mapReader = new MapReader(mapData, colors);
// Create an interactive renderer attached to a DOM element
const container = document.getElementById('map') as HTMLDivElement;
const renderer = new MapRenderer(mapReader, createSettings(), container);
// Display an area (area ID, z-level)
renderer.drawArea(42, 0);
// Show the player position (centers the viewport on the room)
renderer.setPosition(1234);Usage
Settings
Create a settings object with createSettings() and customize before passing to the renderer:
import { createSettings } from 'mudlet-map-renderer';
const settings = createSettings();
settings.roomSize = 0.8;
settings.roomShape = 'circle'; // 'rectangle' | 'circle' | 'roundedRectangle'
settings.backgroundColor = '#1a1a2e';
settings.lineColor = 'rgb(200, 220, 255)';
settings.gridEnabled = true;
settings.emboss = true;
settings.areaName = true;
// Player position marker
settings.playerMarker.strokeColor = '#ff6600';
settings.playerMarker.fillColor = '#ff6600';
settings.playerMarker.fillAlpha = 0.3;
settings.playerMarker.matchRoomShape = true;
const renderer = new MapRenderer(mapReader, settings, container);Settings are a shared mutable object. To change settings after the renderer is already running, modify the properties and call refresh():
// Change appearance at runtime
renderer.settings.roomShape = 'circle';
renderer.settings.emboss = true;
renderer.refresh();
// Background color has its own update method (CSS-only, no scene rebuild)
renderer.settings.backgroundColor = '#1a1a2e';
renderer.updateBackground();Navigation
// Switch to a different area and z-level
renderer.drawArea(areaId, zIndex);
// Move player position (auto-switches area if needed)
renderer.setPosition(roomId);
// Move player without centering the viewport
renderer.setPosition(roomId, false);
// Update the marker without triggering area switch logic
renderer.updatePositionMarker(roomId);
// Center viewport on a room (with animation)
renderer.centerOn(roomId);
// Center instantly (no animation)
renderer.centerOn(roomId, true);
// Remove the position marker
renderer.clearPosition();Highlights and paths
// Highlight rooms with a color
renderer.renderHighlight(roomId, '#ff0000');
renderer.renderHighlight(otherRoomId, 'rgba(0, 255, 0, 0.5)');
// Remove a specific highlight
renderer.removeHighlight(roomId);
// Check if a room is highlighted
if (renderer.hasHighlight(roomId)) { /* ... */ }
// Clear all highlights
renderer.clearHighlights();
// Render a path through a list of room IDs
renderer.renderPath([101, 102, 103, 104], '#66E64D');
// Clear all paths
renderer.clearPaths();Viewport control
// Zoom
renderer.setZoom(1.5);
renderer.zoomToCenter(2.0);
console.log(renderer.getZoom());
// Fit the entire area in view
renderer.fitArea();
// Get current viewport bounds (in map coordinates)
const bounds = renderer.getViewportBounds();
// { minX, maxX, minY, maxY }
// Control resize behavior
renderer.centerOnResize = true; // re-center on window resize
renderer.minZoom = 0.1;Events
renderer.on('roomclick', ({ roomId, position }) => {
console.log(`Clicked room ${roomId} at (${position.x}, ${position.y})`);
});
renderer.on('roomcontextmenu', ({ roomId, position }) => {
// Right-click or long-press on a room
});
renderer.on('areaexitclick', ({ targetRoomId, position }) => {
// Clicked an area exit — navigate to the target room's area
renderer.setPosition(targetRoomId);
});
renderer.on('mapclick', () => {
// Clicked on empty space
});
renderer.on('zoom', ({ zoom }) => {
console.log(`Zoom level: ${zoom}`);
});
renderer.on('pan', (bounds) => {
// Viewport moved
});
// Remove a listener
const handler = ({ roomId }) => { /* ... */ };
renderer.on('roomclick', handler);
renderer.off('roomclick', handler);Culling
For large maps, spatial culling hides off-screen rooms for better performance:
// "indexed" (default) — bucket-based spatial index, best for large maps
// "basic" — simple bounds check per room
// "none" — render everything (useful for debugging)
renderer.setCullingMode('indexed');Big maps (LOD + skeleton reader)
Very dense planes (tens of thousands to millions of rooms on one z-level) can't be drawn as one vector scene. Two core features combine to handle them:
Raster LOD overview (settings.lodEnabled, works with any reader): when
the current plane holds more rooms than settings.lodRoomBudget (default
16 000) and the zoom is far enough out that a viewport could exceed that
budget, the vector scene is replaced by a pixel overview — one filled box per
room, painted on an underlay below the grid. Zooming in switches back to full
vector detail. The switch is zoom-based (pan-invariant, no mid-pan flips) and
reported through the lod renderer event
({mode: 'vector' | 'roomsOnly' | 'raster', planeRoomCount, visibleEstimate, hitTestActive}).
Position marker, highlights, paths and overlays stay visible in both raster
and roomsOnly modes.
Between settings.lodExitBudget (default 12 000) and lodRoomBudget, a
third tier — roomsOnly — drops exit lines while rooms keep rendering as
real vector shapes (env colour, border, shape): a bridge between full detail
and the flat raster pixels. Exit pairing and exit-line shape building are
typically the single largest share of a dense rebuild's cost (exit count
often runs ~2x room count in a well-connected area), so this tier buys back
most of that cost while still showing actual room geometry. Set
lodExitBudget above lodRoomBudget (or to Infinity) to disable this tier
and jump straight from full vector to raster.
Above settings.lodHitTestBudget (default 10 000 rooms, same unit as
lodRoomBudget) the scene still renders at full vector detail (with or
without exits) but the hit-test index is skipped, so clicks/hover stop
resolving to a room until you zoom in a bit further — rebuilding that index
is a real cost on a large scene, and precise pointer picking rarely matters
that zoomed out. Rebuild-on-pan also pads the viewport generously (50% per
side): panning inside that padding is free, so it buys a lot more distance
between rebuilds without making any single rebuild bigger — the LOD decision
compensates for the padding so the actual room count at each tier's flip
stays pinned near its configured budget regardless.
SkeletonMapReader (mudlet-map-renderer/bigmap): an IMapReader over a
compact typed-array MapSkeleton instead of a room object graph. Planes
materialise only the rooms inside the current viewport; the interactive
backend detects this (ViewportDataSource) and pushes padded camera bounds
before every build, rebuilding on pan only when the camera leaves the padding.
import { MapRenderer, createSettings } from 'mudlet-map-renderer';
import { buildSkeleton, SkeletonMapReader } from 'mudlet-map-renderer/bigmap';
// Eager path: the map parses fine, it's the rendering that chokes.
const reader = new SkeletonMapReader(buildSkeleton(mapData, colors));
const settings = { ...createSettings(), lodEnabled: true };
const renderer = new MapRenderer(reader, settings, container);
renderer.on('lod', ({ mode, visibleEstimate }) => console.log(mode, visibleEstimate));For maps too large to parse eagerly (hundreds of MB), produce the
MapSkeleton out-of-process — e.g. a Web Worker that streams the binary map
and transfers the typed arrays zero-copy. demo/streaming/ is a reference
implementation. MapSkeleton is a stable seam: coordinates are raw Mudlet map
space (the reader converts once at construction), bulk rooms live in parallel
Int32Array columns, and only rooms with visual detail (symbols, custom
lines, special exits…) are materialised in full.
Notes: exports through a viewport-narrowed reader only contain the applied
viewport (call reader.setViewport(...) with larger bounds first if you need
everything); lodEnabled is currently a no-op for the OffscreenCanvas
backend; decorator styles with coordinate transforms (e.g. isometric) are not
applied to the raster overview.
OffscreenCanvas rendering (Web Worker)
For large maps, an opt-in backend moves per-frame rasterisation into a Web
Worker via OffscreenCanvas, keeping the main thread responsive during
pan/zoom. The default Konva backend is unchanged — pass the worker backend to
MapRenderer's backendFactory parameter:
import { MapRenderer, createSettings } from 'mudlet-map-renderer';
import { createOffscreenBackend } from 'mudlet-map-renderer/offscreen';
const renderer = new MapRenderer(
mapReader,
createSettings(),
container,
createOffscreenBackend(container), // ← opt in
);Everything else (drawing, navigation, styles, highlights, paths, hit-testing,
events) works as usual. Hit-testing and getDrawnExits() stay synchronous; the
one caveat is that exportCanvas() returns undefined (use the headless
exporters instead). Image labels, the ambient-light overlay, and live effects
are all supported.
See docs/offscreen-rendering.md for the full guide — architecture, feature support, limitations, benchmarks, and the API.
Styles
A Style is a target-agnostic visual transformer. One style drives the
interactive canvas and every exporter — set it once and it applies to SVG,
PNG, and anything else you export.
import {
compose, identityStyle,
Parchment, Blueprint, Neon, Sketchy, Isometric,
} from 'mudlet-map-renderer';
// Single style
renderer.setStyle(Parchment);
// Compose a chain (left → right = inner → outer)
renderer.setStyle(compose(
Parchment,
Sketchy({ jitter: 0.012, color: '#4a3728' }),
Isometric({ rotation: 30, depth: 0.18 }),
));
// Clear the current style
renderer.setStyle(identityStyle);
// or
renderer.clearStyle();Built-in styles:
| Style | Effect |
|---|---|
| Parchment | Warm sepia / aged-paper palette |
| Blueprint | White lines on deep blue |
| Neon | Glowing neon outlines on dark background |
| Construction | Safety-yellow hazard stripes on asphalt, orange exits |
| SciFi | Holographic cyan glow on void black |
| StainedGlass | Saturated jewel panes framed by fat near-black leading |
| GraphPaper | Pale rooms inked in navy — old-school D&D graph paper |
| Topographic | Earthy elevation palette with concentric contour rings |
| Sketchy({ jitter, color }) | Hand-drawn pencil wobble |
| Watercolor({ bleed?, layers?, alpha? }) | Translucent edge-bled washes that pool on overlap |
| Isometric({ rotation?, depth? }) | 2:1 iso projection with optional cubes |
| GradientRooms(opts?) | Replace flat fills with a vertical gradient |
Custom styles extend BaseStyle<Inner> and override only the draw calls they
transform — see the built-ins for examples.
Export
Exporters are plug-ins: a new output format is a new Exporter<T> class,
not a new MapRenderer method.
import {
SvgExporter, PngExporter, PngBlobExporter, CanvasExporter,
} from 'mudlet-map-renderer';
// SVG (string)
const svg = renderer.export(new SvgExporter());
const svgCentered = renderer.export(new SvgExporter({ roomId: 1234, padding: 5 }));
const svgTyped = renderer.export(new SvgExporter({
overlays: {
position: { roomId: 1234 },
highlights: [{ roomId: 100, color: '#ff0000' }],
paths: [{ locations: [101, 102, 103], color: '#00ff00' }],
},
}));
// PNG data URL
const pngUrl = renderer.export(new PngExporter({ pixelRatio: 2 }));
// PNG as Blob
const blob = await renderer.export(new PngBlobExporter({ pixelRatio: 2 }));
// Headless PNG bytes at a specific size (portable — works in browser + Node)
const png = renderer.export(new PngBytesExporter({
width: 1920,
height: 1080,
roomId: 1234,
padding: 5,
}));
// fs.writeFileSync('out.png', png!); // Node
// new Blob([png!], { type: 'image/png' }); // Browser
// Canvas handle (if you need to draw more on it, attach to DOM, etc.)
const canvas = renderer.export(new CanvasExporter({
width: 1920,
height: 1080,
}));Style + export compose: the style currently applied with setStyle is passed
to every exporter, so the SVG, the PNG, and the on-screen canvas stay in sync.
Writing a new exporter — e.g. PDF — means shipping a class that implements
Exporter<Uint8Array>. No changes to MapRenderer.
Headless rendering (no DOM)
For server-side or offscreen rendering (Node.js), install canvas alongside this package and import Konva's canvas backend once at startup. canvas is a peer dependency — it isn't shipped transitively because browsers don't need it.
yarn add canvasimport "konva/canvas-backend"; // must run before creating a MapRenderer
import { MapRenderer, createSettings, SvgExporter, PngBytesExporter } from "mudlet-map-renderer";
// Omit the container argument for headless rendering.
const renderer = new MapRenderer(mapReader, createSettings());
renderer.drawArea(42, 0);
renderer.state.positionRoomId = 1234; // mark player position without auto-centering
const svg = renderer.export(new SvgExporter({ padding: 5 }));
const png = renderer.export(new PngBytesExporter({ width: 1920, height: 1080 }));SVG export doesn't actually hit Konva, so if you only need SVG you can skip both the canvas install and the konva/canvas-backend import. PNG / canvas exporters and anything that builds a Konva stage do require them.
Overlays
Two kinds, picked by what you need:
SceneOverlay— target-agnostic, appears in every output (interactive canvas + every exporter). Use for static scene content (badges, annotations).LiveEffect— interactive canvas only; receives a Konva layer and viewport updates so it can run its own animation loop. Skipped by exporters.
import type { SceneOverlay, LiveEffect } from 'mudlet-map-renderer';
// Scene overlay — emits target-agnostic Shapes; appears in every output
class BadgeOverlay implements SceneOverlay {
render(state, bounds) {
return { type: 'circle', cx: 5, cy: 5, radius: 0.4, paint: { fill: '#ff0' } };
}
}
renderer.addSceneOverlay('badge', new BadgeOverlay());
renderer.removeSceneOverlay('badge');
// Live effect — gets a Konva layer for animation; interactive canvas only
class Pulse implements LiveEffect {
attach(layer) { /* add Konva shapes */ }
updateViewport(bounds, scale) { /* react to pan/zoom */ }
destroy() { /* cleanup */ }
}
renderer.addLiveEffect('pulse', new Pulse());
renderer.removeLiveEffect('pulse');Cleanup
Call destroy() when you're done with a renderer to release all resources — DOM event listeners, Konva stages, and internal subscriptions:
const renderer = new MapRenderer(mapReader, settings, container);
// ... use the renderer ...
// Tear down completely
renderer.destroy();This is important in SPAs and frameworks like React where components mount and unmount. Without calling destroy(), event listeners on window and the container element will leak.
React example
import { useEffect, useRef } from 'react';
import { MapRenderer, MapReader, createSettings } from 'mudlet-map-renderer';
function MudletMap({ mapData, envData, areaId, roomId }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const mapReader = new MapReader(mapData, envData);
const renderer = new MapRenderer(mapReader, createSettings(), containerRef.current);
renderer.drawArea(areaId, 0);
if (roomId) renderer.setPosition(roomId);
return () => {
renderer.destroy();
};
}, [mapData, envData, areaId, roomId]);
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
}Pathfinding
import { MapReader, PathFinder, MapGraph, computePathData } from 'mudlet-map-renderer';
const mapReader = new MapReader(mapData, envData);
const pathFinder = new PathFinder(mapReader);
// Find shortest path between two rooms (Dijkstra or A*)
const roomIds = pathFinder.findPath(startRoomId, endRoomId, 'astar');
if (roomIds) {
// Render the path on the map
renderer.renderPath(roomIds, '#66E64D');
// Or compute detailed path geometry for custom rendering
const pathData = computePathData(mapReader, renderer.settings, roomIds, '#66E64D');
}Exploration mode
Show only rooms the player has visited (fog of war):
const mapReader = new MapReader(mapData, envData);
// Enable exploration with a set of visited room IDs
const visitedRooms = mapReader.decorateWithExploration(new Set([100, 101, 102, 200]));
// Add newly visited rooms as the player explores
visitedRooms.add(103);
// Areas automatically filter to only show visited rooms
const renderer = new MapRenderer(mapReader, createSettings(), container);
renderer.drawArea(areaId, 0);
// Disable exploration mode (show all rooms again)
mapReader.clearExplorationDecoration();
renderer.refresh();Map data format
The renderer expects data produced by mudlet-map-binary-reader, which reads Mudlet's binary map.dat format and exports renderer-compatible structures:
import { MudletMapReader } from 'mudlet-map-binary-reader';
const map = MudletMapReader.read('map.dat');
const { mapData, colors } = MudletMapReader.export(map);
// mapData: MapData.Map (Area[])
// colors: MapData.Env[]MapReader accepts these two values directly:
const mapReader = new MapReader(mapData, colors);The binary reader can also write exported data to files for static use:
MudletMapReader.export(map, 'output'); // writes .js files
MudletMapReader.exportJson(map, 'map.json');API reference
MapRenderer
| Method | Description |
|--------|-------------|
| drawArea(id, zIndex) | Display an area at a z-level |
| setPosition(roomId, center?) | Set player position (auto-switches area) |
| updatePositionMarker(roomId) | Update marker without area switch |
| clearPosition() | Remove position marker |
| centerOn(roomId, instant?) | Center viewport on a room |
| renderHighlight(roomId, color) | Highlight a room |
| removeHighlight(roomId) | Remove a highlight |
| clearHighlights() | Clear all highlights |
| renderPath(roomIds, color?) | Draw a path |
| clearPaths() | Clear all paths |
| setZoom(zoom) | Set zoom level |
| zoomToCenter(zoom) | Zoom keeping center fixed |
| fitArea() | Fit the full area in view |
| setStyle(style) | Apply a visual style (interactive + exporters) |
| clearStyle() | Remove the current style |
| export(exporter) | Run an Exporter<T> and return its output |
| addSceneOverlay(id, overlay) | Add a target-agnostic overlay |
| removeSceneOverlay(id) | Remove a scene overlay |
| addLiveEffect(id, effect) | Add an interactive-only animated effect |
| removeLiveEffect(id) | Remove a live effect |
| refresh() | Force a full re-render |
| on(event, handler) | Subscribe to an event |
| off(event, handler) | Unsubscribe from an event |
| setCullingMode(mode) | Set culling strategy |
| destroy() | Release all resources |
Exporters
| Exporter | Output |
|---|---|
| SvgExporter({ roomId?, padding?, overlays? }) | string — SVG document |
| PngExporter({ pixelRatio? }) | string — PNG data URL (current viewport) |
| PngBlobExporter({ pixelRatio? }) | Promise<Blob> — PNG Blob (browser only) |
| PngBytesExporter({ width, height, roomId?, padding?, overlays?, mimeType?, quality? }) | Uint8Array — headless PNG/JPEG bytes; portable (browser + Node) |
| CanvasExporter({ width, height, roomId?, padding?, overlays? }) | ExportCanvas — canvas handle; reframes to fit |
Styles
| Style | Constructor / Usage |
|---|---|
| Parchment | setStyle(Parchment) |
| Blueprint | setStyle(Blueprint) |
| Neon | setStyle(Neon) |
| Construction | setStyle(Construction) |
| SciFi | setStyle(SciFi) |
| StainedGlass | setStyle(StainedGlass) |
| GraphPaper | setStyle(GraphPaper) |
| Topographic | setStyle(Topographic) |
| Sketchy(opts) | setStyle(Sketchy({ jitter, color })) |
| Watercolor(opts?) | setStyle(Watercolor({ bleed?, layers?, alpha? })) |
| Isometric(opts) | setStyle(Isometric({ rotation?, depth? })) |
| GradientRooms(opts?) | setStyle(GradientRooms()) |
| compose(...) | Chain multiple styles into one |
| identityStyle | Pass-through; equivalent to clearStyle() |
PathFinder
| Method | Description |
|--------|-------------|
| findPath(from, to, algorithm?) | Find shortest path ('dijkstra' or 'astar') |
MapReader
| Method | Description |
|--------|-------------|
| getArea(areaId) | Get an area by ID |
| getAreas() | Get all areas |
| getRoom(roomId) | Get a room by ID |
| getRooms() | Get all rooms |
| decorateWithExploration(visited?) | Enable fog of war |
| clearExplorationDecoration() | Disable fog of war |
License
MIT
