Maps Elements Editor
The Maps Elements Editor lets you move, duplicate, reorder, delete and resize the elements of a generated map directly in the admin panel, from the Maps Wizard or from a room view.
Generated maps are built from elements (trees, houses, tents, spots) placed by the Tile Map Generator. Instead of opening the map JSON in Tiled to fix a placement, you open the editor on the map preview, drag the elements around, duplicate or delete them, adjust their paint order and save. The editor always works on the un-merged copy of the map kept in generate-data/generated, and every save creates a backup you can roll back to.
Opening the editor
- From the Maps Wizard: after you click "Generate" in the Maps Wizard, the maps selection page shows an "Edit Map Elements" button next to each generated map preview (main maps and sub-maps). Click it to enter edit mode on that preview.
- From a room: on the room view page of the admin panel, the "Edit Map Elements" button is shown next to the map_filename field. It is hidden when the room has no map.
- The button is a toggle: while the editor is open it reads "Close Map Editor", and clicking it again closes the editor and restores the normal preview behaviour (click to open the zoomed image).
Editing elements
Hover and drag
- Hover an element: every tile of every layer of that element is highlighted in blue.
- Drag an element: press the mouse button over it and the drag starts immediately. A green ghost of the element tiles follows the cursor, aligned to the tile grid. Only the dragged element is overlaid, so other elements on the same cells are not affected.
- The ghost turns red when any tile would land outside the map. Release the button to commit the move; when the position is out of bounds the element snaps back. Other elements do not react while you drag.
- Map spots can be dragged the same way, but they have no context menu.
Context menu
Right-click an element to open its menu. The menu closes when a drag begins or when you click outside it.
- Move back / Move front - swaps the element paint order with its previous or next neighbour, so its layers render further behind or closer on top. Use it to tweak the 2.5D paint order when the automatic bottom-row order is not what you want.
- Duplicate - enters placing mode: a ghost of the element follows the mouse, and a click on the map places the copy at the current tile. Press Escape, or click the red "Cancel duplication" toolbar button, to abort. The ghost turns red where the copy cannot be placed (out of bounds). The copy is a new version of the same source instance (see Duplicates below).
- Edit tiles layers - opens a modal for that element: pick a layer type (Below player, Collisions, Over player, Collisions + over player, Base, or a Custom suffix), then click a tile to reassign it to that layer. "Save changes" applies the result, "Cancel" discards it.
- Delete - asks for confirmation, then removes every tile of every layer of the element. A layer left with only empty cells is removed from the map, so no empty layers are saved. Dragging is blocked while the confirmation is open.
Toolbar
- Save - saves the changes (see Saving below). The button briefly shows "Saved" or "Save failed" (1.5 seconds); clicking again restarts that timer.
- Backups - shows or hides the backups panel.
- Resize - shows or hides the resize panel.
- Reset - after a confirmation, discards your changes and restores the last loaded state: the state when the editor opened, or the last backup you reloaded.
- Unsaved changes - orange indicator shown after any change; it clears after a successful save or a reset.
- Cancel duplication - only visible while a duplicate is being placed.
- Zoom - the - button, the percentage label and the + button at the right end of the toolbar.
Opening one toolbar panel closes the other one.
Zoom
- The zoom range is 25% to 400%, in 25% steps.
- Ctrl + mouse wheel over the map zooms the map instead of the browser page, clamped to the same range as the toolbar buttons.
- Ctrl + +, Ctrl + = and Ctrl + - also zoom; Ctrl + 0 resets to 100%.
- When the zoomed map is larger than the editor area, scrollbars appear. Clicks are always converted to the right tile at every zoom level.
- The other admin map canvases (map selection, object tile picker, room starting point picker) have no zoom of their own, but Ctrl + wheel over them is blocked too, so the page does not zoom while the pointer is over a map.
Resizing the map
- Click "Resize" in the toolbar.
- Pick an anchor in the 3x3 anchor picker (the default is center). The bands that will be removed are previewed on the map.
- Set "Remove horizontal" and "Remove vertical" to the number of tiles to remove.
- Click "Apply resize". The map is cropped on the server; the editor never re-stamps tiles itself.
- If any element would fall outside the new bounds, the resize is blocked and an error lists the affected element instance IDs, together with a "Force resize" button that crops the map anyway (the tiles outside the new bounds are removed).
A successful resize writes a backup of the new state and, when the editor was opened from a room, republishes the runtime copies of the map.
Saving, publishing and rolling back
Saving
- From the Maps Wizard, Save writes directly. From a room, a confirmation first explains that the map will be overwritten and that a server restart is required to publish the updates.
- Only the element record and the moved spots are sent to the server, never the full map: the server already has the map on disk, and re-uploading a map of several MB would be wasteful and hit the request body limit.
- The server reads the live map (generate-data/generated/{mapName}.json, seeded from the runtime copy for rooms), rebuilds its element layers from the record with the ElementsToLayersBuilder of @reldens/tile-map-generator, and writes the rebuilt map plus the record.
- When saving from a room, the runtime copies in theme/default/assets/maps/ and dist/assets/maps/ are overwritten too. They are not backed up, because they can always be reproduced from the generated folder.
- Only after the live files are written, a timestamped backup pair of the saved state is added to generate-data/generated/backups/, so every save adds one restorable version to the Backups panel.
- The game server must be restarted for players to see the saved map, the same as after a map regeneration.
Backups
- Initial backup: the first time the editor opens a map that has no backups, the server writes one. The oldest backup therefore always holds the state before the first editor save, and works as a roll-back even for a map that was never saved through the editor.
- Backups panel: hidden by default and toggled with the Backups toolbar button. It lists the backups newest first, about three rows at a time with a vertical scroll. Each row has a "Reload" and a "Delete" button.
- Reload copies the chosen backup over the live map and record. No backup of the current state is written first, so unsaved changes are discarded, and the current live state can only be recovered if it matches a listed backup. That is the case right after a save or a resize, because both write their own backup.
- Delete removes the chosen backup pair.
- Published tag: the newest backup created at or before the moment the running server started is tagged "Published", because it matches the map version the game server loaded. A newer backup means the live files changed after the start, and a restart is required to publish them.
- On the room view, the same check shows the "Unpublished - server restart required to publish" badge next to the edit button. It is loaded with the page and kept in sync by the editor.
How elements, duplicates and layer order work
Element naming
The tileset editor emits each element as key-number (for example tree, tree-001, tree-002). Each key-number is a different element style (colour, size, etc.). The instance number identifies the style and the editor never changes it.
Duplicates
- A duplicate keeps the source instance number and fuses a counter into the key: tree-001 -> tree1-001 -> tree2-001 (a keyless tree becomes tree1, then tree2).
- Reusing the instance number (duplicating into a new tree-002) is never done: it would collide with the styles the generator already created and cause version issues.
- The counter belongs to the original family and only increases. Duplicating a duplicate resolves back to the original family and takes its next counter, so numbers never repeat: after duplicating the original into tree1 to tree11, duplicating tree1 gives tree12, not tree11. The editor tracks which instances are duplicates and their original element to pick the next counter.
Layer order (z-order)
- Element layers stay per instance: one set of layers per element and per duplicate, kept in the exact order of the map JSON layers array.
- The order follows the tile row: the element lower on the map (larger row, in front) renders over the one behind it. A duplicate dropped on the same row as its source, or above it, renders behind the source; dropped below, it renders in front. This applies between any two elements, so all elements and all their layers are positioned together.
- On the game client (lib/game/client/scene-dynamic.js) each over-player layer gets the depth array index x map height x tile height, and every below-player layer gets the single configured depth client/map/layersDepth/belowPlayer, so their relative order is resolved by the order they are added. A layer later in the layers array renders on top, which is why element layers are sorted ascending by bottom row.
- After every change the editor re-sorts: the sort key of each element is its bottom row (bounds.row + bounds.height) plus the offset set by Move back / Move front, if any. Only the element layer slots are reordered among themselves; static layers (ground, borders) keep their positions. The element list in the record is sorted the same way, so the order is persisted and the saved map matches the on-screen order after a reload.
Merged and un-merged maps
- Map generation always produces un-merged maps. Editing a map, from the wizard before import or from a room, always loads and works on the un-merged version under generate-data/generated: the editor reads both the map JSON and the element record from /reldens-admin/generated/ and never loads the merged runtime copy.
- Layers are merged only at the publish points: the import to the runtime folders, and a save from a room. The merge is applied by PublishedMapMerger (lib/import/server/published-map-merger.js) following the config server/rooms/maps/autoMergeLayersByKeys.
- The merge preserves the original layer order, with no grouping by type. Element layers are walked in record (Y-sorted) order and each one is merged into the lowest existing merged layer of the same type whose tiles it does not overlap (and that sits above any layer it does overlap). When a tile would land on an occupied cell, a new layer is created on top (merge-{type}-2, merge-{type}-3, ...).
- As a result, non-overlapping layers of the same type collapse into a single merge-{type} layer, overlapping ones spill to the next layer up, the front element always ends on top and no tile is ever overwritten.
- Spot layers merge by their spot group; static (non-element) layers are untouched.
- The config default is every mergeable type plus spots: ['collisions-over-player','collisions','over-player','below-player','path','base','spot']. Set it to an empty value to disable merging.
- Keep keepGeneratedForEditing enabled (its default) so room maps stay editable after import.
Legacy maps
If a room has no element record on disk, the editor asks the server to build one from the map layer names. Two forms are recognised: the fused {elementName}{index}-{layerType} (for example tree0-collisions), which is what the generator writes, and the standalone {elementName}-{index}-{layerType}. Only the elements recognised this way are interactive; anything else is drawn but cannot be moved, duplicated or deleted.
Technical reference
Files and folders
- Source of truth: generate-data/generated/{mapName}.json and generate-data/generated/{mapName}-room-map-elements.json. The element record lives only in the generated folder and is always read from there; it is never copied to the theme or dist runtime folders, which hold game-ready assets only.
- Backups: generate-data/generated/backups/{mapName}-{YYYY-MM-DD-HH-mm-ss}-back.json and {mapName}-{YYYY-MM-DD-HH-mm-ss}-back-room-map-elements.json.
- Runtime: theme/default/assets/maps/{mapName}.json and dist/assets/maps/{mapName}.json. Not backed up, always reproducible.
- Pre-extrusion tileset images (import flow): generate-data/generated/original-map-images/{image}-original.png. Written by MapImageExtruder.copyExtrudedFiles, read back as the extruder input, and removed by MapsImporter.removeImportedMapFromGenerated when keepGeneratedForEditing is off.
Element record file
One record per generated map, saved as generate-data/generated/{mapName}-room-map-elements.json:
{
"schemaVersion": 1,
"mapName": "town-001",
"mapFileName": "town-001.json",
"tilesetSessionId": "2026-05-28-12-34-56-my-town",
"compositeFile": "composite.json",
"generatedAt": "...",
"generatedBy": "elements-composite-loader",
"tileWidth": 32,
"tileHeight": 32,
"mapWidth": 60,
"mapHeight": 40,
"elements": [
{
"instanceId": "tree-001",
"elementKey": "tree",
"index": 1,
"bounds": { "col": 12, "row": 7, "width": 2, "height": 3 },
"layers": [
{ "name": "tree-001-below-player", "type": "below-player", "tiles": [{ "col": 12, "row": 7, "gid": 158 }] },
{ "name": "tree-001-collisions", "type": "collisions", "tiles": [{ "col": 12, "row": 9, "gid": 168 }] }
]
}
]
}
The import flow does not write the record location in the room customData: the record and the tileset session are resolved by name convention. RoomsEntitySubscriber.resolveMapElementsFile still reads a mapElementsFile value from customData if you set one manually, then falls back to {mapName}-room-map-elements.json.
Record creation at generation time
The record file of each generated map is produced by MapElementsRecordsEmitter.emitForRunner() after MapsWizardRunner.run() finishes:
- MapLayersComposer.generateLayersList() (in @reldens/tile-map-generator, called from RandomMapGenerator.generate()) snapshots generator.preMergeLayers before the layers are merged by name. The runtime map keeps the smaller merged layer set; the snapshot preserves the per-element data for the records.
- MapElementsRecordsEmitter.mapJsonForRecords(generatedMap, generator) picks generator.preMergeLayers when present (otherwise generatedMap.layers) and runs them through LayerComponentSplitter.splitMapLayers(layers, mapWidth, mapHeight). The three emit paths (emitMain, emitMultiMaps, emitSubMaps) all go through it.
- MapElementsBuilder.build({mapJson, ...}) parses the split layers into elements with ElementsFromLayersLoader and writes {mapName}-room-map-elements.json.
In practice the record holds one element per spatially distinct instance (for example five separate tree-1 to tree-5 records instead of one grouped tree-001).
Editor load priority
- If a record file is supplied, the editor loads /reldens-admin/generated/{mapElementsFile} and uses it when a record JSON is returned.
- Otherwise it calls the build-elements-from-layers route, which runs ElementsFromLayersLoader.load() of @reldens/tile-map-generator directly on the live map.
- If neither works, the editor refuses to load.
Every load also fetches the draggable spots (build-spots-from-layers), ensures the initial backup and refreshes the backups list.
Admin routes
All routes are prefixed with /reldens-admin/maps-elements-editor/api/:
- POST save-map-edit - body {mapName, sessionId, context, mapElements, mapSpots} (the element record plus the moved spots; the map is not uploaded). Reads the live map from disk (seeded from the runtime copy for context: 'room'), rebuilds its element layers with ElementsToLayersBuilder.apply, writes the rebuilt map and the record, syncs the runtime copies for the room context, and only then writes a backup pair of the saved state.
- GET list-backups?mapName=X - returns {backups: [{timestamp, mapJsonPath, elementsFilePath, sizeBytes}], publishedTimestamp}, newest first. publishedTimestamp is the newest backup whose timestamp is at or before the server start time captured by the subscriber.
- POST restore-backup - body {mapName, backupTimestamp, context}. Copies the chosen pair over the live map and record; no pre-restore backup is written. The room context re-copies to the runtime folders.
- POST delete-backup - body {mapName, backupTimestamp, context}. Removes the chosen pair from the backups folder.
- GET build-elements-from-layers?mapName=X - returns {mapElements: {...}, warnings: [...]} for the legacy map fallback.
- GET build-spots-from-layers?mapName=X - returns {mapSpots: {...}}, the draggable spot entries, read by MapSpotsFromLayersReader.
- POST resize-map - body {mapName, sessionId, context, anchor, removeHorizontal, removeVertical, force}. Handled by MapResizePersister: crops the map on the server, writes a backup pair and republishes for the room context.
- POST ensure-initial-backup - body {mapName, context}. Writes a first backup pair when the map has none. Returns {success, created, timestamp?, existingCount?}.
The save order in MapsElementsEditorSubscriber.handleSaveMapEdit():
if(!FileHandler.writeFile(livePath, sc.toJsonString(mapJson))){
Logger.error('Could not write map JSON.', mapName);
return res.status(500).json({error: 'mapWriteError'});
}
FileHandler.writeFile(this.backupArchive.path('liveElements', mapName), sc.toJsonString(mapElements));
this.publishIfRoomContext(req.body, mapName);
let backupInfo = this.backupArchive.writeBackupPair(mapName);
Server classes
- MapElementsBuilder (lib/admin/server/map-elements-builder.js) - runs ElementsFromLayersLoader.load and stamps the Reldens metadata (schema version, map name and file name, tileset session, composite file, generator, generation date, tile and map sizes). Owns the record file name convention through elementsFileName(mapName). Code that only needs a transform uses the package classes directly.
- MapElementsBackupArchive (lib/admin/server/map-elements-backup-archive.js) - owns the backups folder: writes, lists, restores and deletes backup pairs. writeBackupPair(mapName) copies the current live map and record into a new timestamped pair; restore(mapName, backupTimestamp) only copies the chosen pair over the live files.
- MapElementsRecordsEmitter (lib/admin/server/map-elements-records-emitter.js) - emits the record files at generation time for each main map, multi-map and sub-map.
- MapsElementsEditorSubscriber (lib/admin/server/subscribers/maps-elements-editor-subscriber.js) - hosts the eight admin routes and uses ElementsFromLayersLoader and ElementsToLayersBuilder directly for the load and save transforms.
- MapsWizardSubscriber (lib/admin/server/subscribers/maps-wizard-subscriber.js) - runs the records emitter after MapsWizardRunner.run() succeeds.
- PublishedMapMerger (lib/import/server/published-map-merger.js) - merges the runtime copies on import and on a room save.
- LayerComponentSplitter (@reldens/tile-map-generator, lib/map/layer-component-splitter.js) - groups element layers by source instanceId and finds connected components in the union of all layers of each instance (so tree-001-base and tree-001-collisions line up). Each component emits one renamed layer per source layer, using a counter that is unique per element key, so placements merged into a shared layer come back as one record per instance with no instance ID collisions. Known limitation: instances whose tiles touch (share an edge) are treated as a single component.
- ElementsToLayersBuilder (@reldens/tile-map-generator, lib/map/elements-to-layers-builder.js) - the inverse of ElementsFromLayersLoader. apply(mapJson, mapElements) keeps the static layers in place and rebuilds the element layers from the record tiles in record (Y-sorted) order. Without autoMergeLayersByKeys it emits the layers un-merged (one per instance); with keys it runs a single order-preserving merge pass, and merges spots separately. The save route uses it without keys (the generated folder stays un-merged), PublishedMapMerger uses it with the config keys.
Client classes
The editor lives in theme/admin/js/maps-elements-editor/:
- maps-elements-editor.js - MapsElementsEditor, the top-level controller. load() fetches the map, the element record and the spots; afterMutation() re-sorts the layers, rebuilds the tile index, marks the painter cache dirty and requests a render (render calls are coalesced with requestAnimationFrame). Owns save(), loadElements() and loadSpots(), reports the unpublished state through the onPublishedState option, and delegates to the helpers below.
- editor-confirmations.js - EditorConfirmations, every confirmation dialog: delete element, reload backup, delete backup, and the save confirmation in the room context.
- element-tiles-layer-editor.js and element-tiles-layer-canvas.js - the "Edit tiles layers" modal.
- map-elements-canvas-painter.js - canvas rendering with an offscreen base cache that is repainted only when painter.baseDirty is set; every other frame blits the cache and draws the hover, drag and duplicate overlays. The live canvas is resized only when the map size changes. Tileset column formula: Math.floor((imagewidth - 2 * margin + spacing) / (tilewidth + spacing)).
- element-z-order-sorter.js - ElementZOrderSorter: sort() reorders the element layer slots by bottom row, and moveElement(instanceId, direction) implements Move back (-1) / Move front (+1) by swapping the sort keys through zOrderOffset and re-running the sort.
- element-mover.js - drag logic over the map spots and the elements. Keeps a Map<"col,row" -> instanceId> tile index for constant-time lookups, moves tiles in place (clearing the old positions, then stamping the new ones) so co-located elements are preserved, and checks bounds against the cached element rectangle. Layers are found by name, falling back to the tile position when the record uses per-instance names but the map has a merged layer.
- element-duplicator.js - placing-mode state machine (startPlacing, updatePlacing, confirmPlacing, cancelPlacing), bounds-checked before commit.
- element-deleter.js - removes the tiles across layers and the element from the record; layers left all-zero are removed from mapJson.layers.
- map-layers-normalizer.js - MapLayersNormalizer: explode(mapJson, mapElements) splits merged layers into per-instance layers using the record tile lists, so a merged map becomes editable per instance, and prunes empty layers.
- map-resizer.js - the resize panel: anchor and amounts state, removal preview and the request to resize-map.
- editor-context-menu.js - the right-click menu, styled by the .element-context-menu rules in theme/admin/css/container-maps-elements-editor.css. Closes on an outside click and stays inside the viewport.
- editor-backups-panel.js - lists, reloads and deletes backups, stores the published timestamp and exposes isUnpublished().
- editor-json-fetcher.js - EditorJsonFetcher with fetch(url, options) and post(url, body), an HTTP status guard and lastError.
- editor-ui.js - EditorUi: toolbar, panels, canvas scroll wrapper, buildButton(label, extraClass, handler), dirty indicator, save flash and zoom (zoomMin = 0.25, zoomMax = 4, zoomStep = 0.25).
- editor-reset-controller.js - EditorResetController: keeps the snapshot of the last loaded map, record and spots for Reset, and requests the initial backup.
Outside that folder:
- theme/admin/js/admin-map-elements-editor-launcher.js - AdminMapElementsEditorLauncher, bound on the "Edit Map Elements" buttons. On open it clones the preview canvas to drop the zoom modal click listener, then creates the editor once the tileset image loads; on close it disposes the editor and restores the listener. On the room view it also adds the unpublished badge.
- theme/admin/js/element-name-suffix.js - shared next-suffix helper used by the tileset editor namer and the duplicator.
- AdminClientMaps.bindMapCanvas() (theme/admin/js/reldens-admin-client-maps.js) - blocks Ctrl + wheel page zoom on the other admin map canvases.
Including the editor scripts in a template
The editor script tags live in a single template, theme/admin/templates/maps-elements-editor-scripts.html, registered in lib/admin/server/templates-list.js as mapsElementsEditorScripts. It is not a Mustache partial: its loaded content is passed as a Mustache variable, and each consumer template declares the slot at the end of its markup:
{{&mapsElementsEditorScripts}}
- maps-wizard-maps-selection.html is rendered per request by MapsWizardSubscriber, which sets data.mapsElementsEditorScripts from adminManager.adminFilesContents before rendering.
- sections/view/rooms.html is pre-rendered once at setup, so MapsElementsEditorSubscriber.injectScriptsVariableIntoRoomsSection fills the slot in adminFilesContents.sections.view.rooms before that render. The subscriber is created on reldens.beforeSetupAdminManager, which runs before the entity contents are built.
To include the scripts in another template, add the slot to it and supply the variable wherever that template is rendered (for a pre-rendered entity section, fill the slot in adminFilesContents.sections before setup, as the editor subscriber does for rooms). See Admin Templates Architecture for how the admin templates are loaded.
Logging
- Logger.info on a successful save, restore, delete or initial backup write.
- Logger.warning when the layer name detection fallback is used.
- Logger.error on validation or write failures. Full payloads are never logged.
Related Documentation
- Maps Wizard - generate and import maps from the admin panel.
- Town With Inner Houses - worked example of a town whose doors lead to generated interiors.
- Tile Map Generator - the package that generates the maps and provides the element transforms.
- Tile Map Generator - Internal Flow - from the admin UI to the generated map.
- Tileset to Tilemap - the tileset editor that names the elements.
- Admin Templates Architecture - how admin templates and their variables are loaded.
- Rooms Entity - the map_filename field the editor works on.
- Administration Panel - admin panel overview.
reldens