Tile Map Generator - Internal Flow

How the Maps Wizard turns a tileset editor session into a generated map: the data contract between the tileset-to-tilemap and tile-map-generator packages, the optimizer step and every generator stage.

This page covers what data the tileset analyzer (@reldens/tileset-to-tilemap) produces, what format the map generator (@reldens/tile-map-generator) expects, and how the two are connected through the Maps Wizard. For the tile id spaces those values live in (source local id, composite gid, optimized gid), the tileset-ref parking invariant that keeps annotated tiles alive through the optimizer, and the checklist for adding a new tile option, see Tile Ids and Annotations Pipeline.

Overview

The system has four stages:

  1. Stage 0 - Admin UI: the user configures tilesets and spots in the browser, triggers file generation, then navigates to the Maps Wizard.
  2. Stage 1 - tileset-to-tilemap: a server endpoint receives the UI state and produces composite.json, map-generator-config.json and the element files.
  3. Stage 2 - tile-map-optimizer: strips unused tiles from the composite and produces an optimized PNG and JSON.
  4. Stage 3 - tile-map-generator: reads the config plus the optimized composite and generates the final Tiled map JSON.

Packages and Responsibilities

  • @reldens/tileset-to-tilemap: accepts uploaded tileset PNGs, detects elements via pixel analysis, and lets the user annotate tile roles (ground, path, surroundings, corners, spots, etc.). Generates composite.json, map-generator-config.json, per-element JSON files and session-editor-state.json. User guide: Tileset to Tilemap.
  • @reldens/tile-map-optimizer: packs only the used tiles into a new tilesheet and remaps every id. See Tile Map Optimizer.
  • @reldens/tile-map-generator: reads composite.json and map-generator-config.json, processes them into a map layout, and writes the output Tiled JSON and PNG tileset. See Tile Map Generator.

Stage 0: Admin UI to Generate to Maps Wizard

Tileset editor (TilesetGenerator)

Located at theme/admin/js/tileset-to-tilemap/tileset-generator.js in the Reldens project. TilesetGenerator.generate() serializes the current in-browser tileset state and POSTs it to the server generate endpoint.

serializeTileset(tileset) builds the POST body for one tileset: it copies all tileset fields (name, tileWidth, tileHeight, etc.) and spots: tileset.spots || [], the full array of spot objects configured by the user. Each spot object has: name, spotTile (tileset-local index or null if not set), width, height, quantity, markPercentage, variableTilesPercentage, freeSpaceAround, walkable, isElement, allowPathsInFreeSpace, mapCentered, placeRandomPath, depth, splitBordersInLayers, borderInnerWalls, borderOuterWalls, borderOuterWallsIncreaseLayerSize, surroundingTiles (position dict) and corners (position dict).

runGenerate(tilesets, fullTilesets) POSTs to GenerateRoute on the server. The server writes all output files and returns a list of generated file entries. After a successful generate, the "Maps Wizard" button becomes visible if a session ID is present.

Generate endpoint (GenerateRoute)

Located at lib/routes/generate.js of @reldens/tileset-to-tilemap. GenerateRoute.handle(req, res) reads:

  • req.body.tilesets - serialized tileset array (includes spots[] per tileset)
  • req.body.fullTilesets - full tileset data with image buffers
  • req.body.sessionId - session identifier
  • req.body.mapName, req.body.mapTitle - map naming
  • req.body.globalTileOptions - optional global tile options

It delegates to TilesetFilesBuilder.build(rootDir, sessionId, outputDir, tilesets, fullTilesets, mapName, mapTitle, globalTileOptions), which writes all output files.

Session config API

When the Maps Wizard page opens for a tileset session (?tilesetSessionId=X in the URL), the client fetches GET /reldens-admin/tileset-analyzer/api/session-wizard-config?sessionId=X, registered by TilesetAnalyzerSubscriber.setupRoutes() under the admin root path (/reldens-admin by default). It:

  1. Reads generate-data/tileset-sessions/output/{sessionId}/map-generator-config.json.
  2. Calls MapsWizardConfigBuilder.buildPartialGeneratorData(config), or, when the config holds a savedWizardConfig, merges it into every saved strategy.
  3. Returns { strategy, partialData } (plus savedStrategies when a wizard config was saved) to pre-fill the Maps Wizard form.

partialData contains:

  • compositeElementsFile - filename of the composite JSON
  • automaticallyExtrudeMaps: 1
  • strategy specific fields: mapName for the composite strategy, mapNames for the multiple strategy, mapsInformation and associationsProperties for the multiple with associations strategy
  • the tile options copied flat out of config.tileOptions by applyTileOptions(): groundTile, groundTiles, pathTile, borderTile, randomGroundTiles, mapBorderWallsTiles, surroundingTiles, corners, bordersTiles, borderCornersTiles, borderInnerCornersTiles
  • groundSpots - the full ground spots config object ({ spot_001: { layerName, tilesKey, width, height, spotTile: 0, ... } })
  • generatedFolder when the config sets one

This flattening bridges a gap: map-generator-config.json stores tile options nested under tileOptions, but the Maps Wizard form expects them as top-level properties. The returned values are what the form gets pre-filled with, and buildGeneratorData() then serializes them back as top-level keys in the generatorData submitted to the server.

Maps Wizard client binding

The client code lives in theme/admin/js/maps-wizard/maps-wizard-bindings.js and maps-wizard-utils.js. When the Maps Wizard page loads with a prefill session id:

  1. The strategy radio button is clicked, which fires a change event and runs updateGeneratorDataFromInputs() synchronously. At this point extraProperties = {}, so the textarea gets JSON without groundSpots yet.
  2. The async fetch returns and setExtraProperties(wizardConfig.partialData, wizardConfig.strategy) captures any partialData property that has no matching .config-input[data-property="..."] element into the module-level extraProperties object. Because compositeElementsFile and groundSpots have no form inputs, they are captured there.
  3. fillInputsFromData(wizardConfig.partialData, wizardConfig.strategy) fills the form inputs for properties that DO have matching .config-input elements (e.g. mapSize, blockMapBorder).
  4. updateGeneratorDataFromInputs() calls buildGeneratorData(optionType), which starts with Object.assign({}, extraProperties), reads all .config-input elements for the selected strategy and serializes the result to the #generatorData textarea.
  • setExtraProperties(data, optionType): resets extraProperties to {}, then for each key in data queries for a .config-input[data-option="common"][data-property="{key}"] and a .config-input[data-option="{optionType}"][data-property="{key}"]. If neither exists, it writes extraProperties[key] = data[key].
  • buildGeneratorData(optionType): returns Object.assign({}, extraProperties, ...commonInputs, ...optionInputs). The spread from extraProperties seeds all properties with no matching form input (including groundSpots, compositeElementsFile, generatorType, mapsInformation, tileOptions), then the form inputs overlay their values.
  • updateInputsFromGeneratorData(): called when the user manually edits the #generatorData textarea. It parses the textarea JSON, calls setExtraProperties(jsonData, optionType) to rebuild extraProperties from it, then fills the form inputs, so extraProperties stays consistent with whatever is in the textarea.

Maps Wizard submit and server flow

POST /reldens-admin/maps-wizard with body { mainAction, mapsWizardAction, tilesetSessionId, generatorData } is handled by MapsWizardSubscriber.generateMaps() (lib/admin/server/subscribers/maps-wizard-subscriber.js):

  1. Parses generatorData (the JSON string from the #generatorData textarea) into mapData, which includes groundSpots, compositeElementsFile and all form input values.
  2. Builds rootFolder = tilesetSessionsDir/output/{safeSessionId} when a session id was posted (otherwise themeManager.projectGenerateDataPath) and puts it, plus generatedFolder = themeManager.projectGeneratedDataPath, into handlerParams.
  3. Checks the composite file exists (see Missing Composite Resolution below).
  4. Hands selectedHandler plus handlerParams to MapsWizardRunner.run() (lib/admin/server/subscribers/maps-wizard-runner.js). For the elements-composite-loader strategy it:
    • creates LayerElementsCompositeLoader(handlerParams) and calls loader.load(), which reads rootFolder/composite.json and validates the schema,
    • creates new RandomMapGenerator(),
    • calls generator.fromElementsProvider(loader.mapData),
    • calls generator.generate(), which writes the output to generate-data/generated/.

The mapData passed to the loader does not contain tileset image paths, only the compositeElementsFile filename and the generation parameters. The tileset image is resolved at runtime from rootFolder.

Missing composite resolution at generation time

MapsWizardSubscriber.generateMaps() checks the composite before handing anything to the runner:

let compositeElementsFile = sc.get(mapData, 'compositeElementsFile', '');
if(compositeElementsFile
    && !this.compositeSampleFilesProvider.ensureCompositeFile(rootFolder, compositeElementsFile)
){
    return this.mapsWizardRedirect(res, 'mapsWizardMissingCompositeFileError', safeSessionId);
}

CompositeSampleFilesProvider.ensureCompositeFile() (lib/admin/server/composite-sample-files-provider.js) reads the composite from the session folder when it is already there, and only copies the tileset images it references that are still missing. When the composite itself is missing it resolves the sample from the installed package at node_modules/@reldens/tile-map-generator/examples/layer-elements-composite/ (relative to the project root), then copies BOTH the composite JSON and every tileset image that JSON references into the session folder, so the next run reuses the copied files instead of resolving again. In both cases it then recurses over the composites named by the compositeFileNames, downFloorCompositeFileNames and upperFloorCompositeFileNames layer properties, so the associated maps composites are provided too.

This is why each wizard strategy keeps its own sample payload. Pointing every strategy at the same composite.json destroys the per strategy data. The four strategies and the loader each one routes to:

  • elements-object-loader uses LayerElementsObjectLoader (single map with elements from separate element files)
  • elements-composite-loader uses LayerElementsCompositeLoader and needs compositeElementsFile (single map from a composite element file with embedded quantities)
  • multiple-by-loader needs mapNames (MultipleByLoaderGenerator, multiple maps using the object or composite loader)
  • multiple-with-association-by-loader needs mapsInformation plus associationsProperties (MultipleWithAssociationsByLoaderGenerator, multiple maps with nested associated sub-maps, see Maps Multiple With Associations)

The result code is a client side message key

mapsWizardRedirect puts the code in the result query parameter. AdminClient.bindNotifications() (theme/admin/js/reldens-admin-client.js) renders it with this.errorMessages[result] || result, so any code with no entry in the errorMessages map is shown to the user as the raw identifier. mapsWizardMissingCompositeFileError and its sibling codes (mapsWizardMissingActionError, mapsWizardMissingDataError, mapsWizardWrongJsonDataError, mapsWizardMissingHandlerError, mapsWizardGeneratorError, mapsWizardSelectedHandlerError, mapsWizardMapsNotGeneratedError, mapsWizardMissingElementsFilesError) all have entries.

Stage 0b: Client-Side Tileset State Management

How the in-browser tileset state is created, stored and mutated directly determines what the server receives when the user clicks Generate. The files mentioned below live in theme/admin/js/tileset-to-tilemap/ of the Reldens project.

In-memory state shape

Each tileset in app.state[tilesetIndex] is a plain object. spots is an array of spot objects:

tileset.spots[i] = {
  name: 'spot-001',                  // display name; normalized to 'spot_001' server-side
  type: 'spot',
  approved: false,
  bulkSelected: false,
  spotTile: null,                    // tileset-local index (0-based) set by picking; null = not picked
  spotTileVariations: [],
  surroundingTiles: {},              // position -> tileset-local index
  corners: {},
  bordersTiles: {}, borderCornersTiles: {},
  innerWallsTiles: {}, innerWallsCornerTiles: {},
  outerWallsTiles: {}, outerWallsCornerTiles: {},
  width: 5, height: 5,               // spot dimensions on the map in tiles
  quantity: 1,
  walkable: true,
  markPercentage: 100,
  variableTilesPercentage: 0,
  isElement: false,
  freeSpaceAround: null,             // null means "not set"
  allowPathsInFreeSpace: false,
  mapCentered: 0,
  placeRandomPath: false,
  depth: false,
  splitBordersInLayers: false,
  borderInnerWalls: false,
  borderOuterWalls: false,
  borderOuterWallsIncreaseLayerSize: 4
}

New spot creation

TilesetTileOptionsBinder.addSpot() (tileset-tile-options-binder.js) runs when the user clicks "Add Spot":

  1. SharedUtils.buildDefaultSpot('spot-NNN') (shared-utils.js) returns the shape above.
  2. The new spot is pushed to tileset.spots.
  3. app.selectedSpot = { tilesetIndex, spotIndex } auto-selects the new spot.
  4. app.editor.legendRenderer.renderLegend(tilesetIndex) re-renders the legend panel with the new spot row, then the list scrolls to it.

buildDefaultSpot(name) sets width: 5, height: 5 so a new spot is always placeable, spotTile: null (the user must pick a tile; without it the spot uses groundTile as fill) and freeSpaceAround: null (computed to 1 by buildGroundSpotConfig() when absent).

Session loading

When loading a saved session from disk, TilesetStateBuilder.buildTileset(tilesetData) (state-builder.js) sets spots: tilesetData.spots || []. No normalization or default-filling of spot properties occurs. Sessions saved by older versions, or where the user manually cleared the inputs, may carry width: null, height: null; those values are fixed at render time (see below).

Spot props UI (TilesetSpotEditor)

  • appendSpotRow(list, spot, si, tileset, tilesetIndex, spotTemplate) (spot-editor.js): clones the spot-template HTML fragment, sets the row data-spot-name / data-spot-index and the name input, shows the detail panel when the spot is the app.selectedSpot, applies the lock visual and the bulk / generate checkboxes state, and calls initSpotProps(frag, spot). It adds no event listeners.
  • initSpotProps(frag, spot): fills every [data-prop] element from the spot. Checkboxes get spot[key] || false, number inputs go through initNumberSpotProp(), the depth input goes through initDepthSpotProp(), any other input gets spot[key] when defined. Then toggleIsElementRows() shows or hides the free space rows based on isElement.
  • initNumberSpotProp(domElement, key, spot): when spot[key] is set it is copied into the input; when it is null or undefined and the input has a positive min, both spot[key] and the input value are set to that minimum. Width and height inputs have min="1", so null dimensions from old sessions become 1 when the row is rendered, and null never flows to the generator.

Spot row events (TilesetSpotInteractions)

spot-interactions.js handles the spot rows events through dispatchSpot(tilesetIndex, event, eventType, spotRow), which routes them by type:

  • handleSpotClick(): the lock button toggles spot.approved (toggleSpotLock()), the delete button asks for confirmation and splices the spot from tileset.spots[] (requestDeleteSpot()), the row header toggles the selection and the detail panel (toggleSpotSelection()).
  • handleSpotChange(): the bulk / generate checkboxes set bulkSelected / generateSelected, the other checkboxes write spot[key] = checked (and isElement toggles the free space rows).
  • handleSpotInput(): number inputs write null when empty or the number value, depth writes null, true or the text value, other inputs write the text value.
  • handleSpotFocusOut(): the name input renames the spot and re-sorts the legend.

These handlers only run on user changes, NOT at initialization time, so the null values of old sessions are fixed at render time by initNumberSpotProp().

Spot tile pick

When the user clicks a tile in the tileset canvas while a spot-tile option button is active, TilesetTileOptionsPickHandler.handleSpotTilePick() (tileset-tile-options-pick-handler.js) runs:

spot.spotTile = flatIndex;
// where: flatIndex = row * tileset.tilesetColumns + col  (0-based within the tileset image)

This is the tileset-local index (0-based, within the specific tileset), NOT the composite GID. The composite GID is computed in buildVariationLayers() as firstgid + flatIndex, ensuring the tile appears in the tileset-ref layer and survives optimization.

Spot lock state

When spot.approved = true the spot row shows a lock icon. This flag is purely UI state: it is serialized into the session but has no effect on generation.

Session State: What Gets Saved per Tileset

After the user assigns tile roles in the Map Tiles tab, the session state (session-editor-state.json) stores per tileset:

{
  "tileOptions": {
    "groundTile":        42,
    "groundTiles":       [42, 44, 46],
    "pathTile":          85,
    "borderTile":        100,
    "randomGroundTiles": [43, 44, 45],
    "surroundingTiles":  { "-1,-1": 10, "-1,0": 11, "-1,1": 12 },
    "corners":           { "-1,-1": 20, "-1,1": 21, "1,-1": 22, "1,1": 23 },
    "bordersTiles":      { "top": 30, "right": 31, "bottom": 32, "left": 33 },
    "borderCornersTiles":{ "top-left": 40, "top-right": 41, "bottom-left": 42, "bottom-right": 43 },
    "borderInnerCornersTiles": { "top-left": 44, "top-right": 45, "bottom-left": 46, "bottom-right": 47 },
    "mapBorderWallsTiles": { "-1,-1": 69, "-1,0": 70, "0,0": 118, "1,1": 167 }
  },
  "spots": [
    {
      "name": "mySpot",
      "spotTile":          50,
      "spotTileVariations":[51, 52],
      "surroundingTiles":  { "-1,-1": 60, "-1,0": 61 },
      "corners":           { "-1,-1": 70 },
      "innerWallsTiles":   { "-1,-1": 80, "-1,0": 81 },
      "innerWallsCornerTiles": { "top-left": 90 },
      "outerWallsTiles":   {},
      "outerWallsCornerTiles": {},
      "width": 5,
      "height": 5,
      "quantity": 3,
      "freeSpaceAround": 1,
      "isElement": false,
      "allowPathsInFreeSpace": false,
      "walkable": true,
      "depth": null
    }
  ]
}
  • The session spot has no layerName: the generator config layerName is derived server side from the spot name by TilesetCompositeConfigBuilder.buildGroundSpotConfig().
  • Positional keys are the data-pos values of the editor grids in theme/admin/templates/tileset-to-tilemap.html. surroundingTiles, corners, mapBorderWallsTiles and the spot innerWallsTiles / outerWallsTiles are keyed by row,col coordinates (-1,-1 NW through 1,1 SE), bordersTiles by side name, and borderCornersTiles, borderInnerCornersTiles and the spot innerWallsCornerTiles / outerWallsCornerTiles by corner name. CompositeTileAnnotationBuilder maps the coordinate keys to position names through TilesetConst.SPOT_SURROUNDING_POSITION_TO_NAME and TilesetConst.SPOT_CORNER_POSITION_TO_NAME when it writes the annotations (see Spot Wangsets and Annotations).
  • The spot row has no borders grids: it shows the spot tile, variations, surrounding and corner grids plus the inner and outer walls grids, and the walls become the {spotKey}-inner-walls and {spotKey}-outer-walls wangsets (CompositeWangsetBuilder.buildSpotWangsets()). A spot bordersTiles / borderCornersTiles object stays empty in new spots and is only annotated when an older session carries it.
  • All tile values are flat indices: flatIndex = row * tilesetColumns + col (0-based).
  • globalTileOptions (stored at session root level, not per tileset) uses the same structure but each entry is { tilesetKey, flatIndex } to identify tiles across multiple tilesets, where tilesetKey is the source tileset filename. Legacy entries using the positional { tilesetIndex, flatIndex } are still resolved as a fallback (Helpers.resolveEntryTilesetIndex).

Stage 1: UI to Composite JSON and Config JSON

Entry point: TilesetFilesBuilder.build() in lib/tileset-files-builder.js of @reldens/tileset-to-tilemap.

TilesetFilesBuilder.build()
  // copies PNG, builds per-element JSON files, groups tilesets by tile size
  -> buildTilesetFilesEntries()
  // produces composite.json + map-generator-config.json
  -> buildCompositeEntries()
      // composite.json
      -> CompositeBuilder.buildCompositeJSON()
      // map-generator-config.json
      -> TilesetCompositeConfigBuilder.buildConfigData()

buildElementFiles() skips the entries with type === 'spot', since spots use the config path, not the element JSON path.

CompositeBuilder.buildCompositeJSON()

Builds the Tiled map JSON that represents the "elements composite": a single map containing all elements as separate layers, with the tilesets listed and the annotations and wangsets embedded. Steps:

  1. CompositeAnnotationResolver.resolve() merges per-tileset tileOptions and globalTileOptions into effectivePerTileset[].
  2. preprocessTilesets() builds tilesetEntries[], collects all elements into a flat elements[] and tracks tilesetFirstgids[].
  3. annotationResolver.resolvePathTileCompositeId() finds the path tile composite GID (firstgid + opts.pathTile).
  4. packElements() bin-packs the elements onto a canvas and returns placements[] plus the canvas size.
  5. buildLayers() builds one layer per element layer for each placement; path layers get all tiles replaced with pathTileCompositeId (replacePathLayerTiles); the first layer gets the quantity / freeSpaceAround / allowPathsInFreeSpace properties.
  6. buildVariationLayers() appends the ground-variations layer, the tileset-ref layer and the spot variation layers.
  7. Returns the full Tiled map object.

What the composite carries

CompositeBuilder.createTilesetEntry() produces a full tileset entry including:

  • a tiles array built by CompositeTileAnnotationBuilder.buildTileAnnotations(), with the key and groundSpots properties of every assigned tile role, merged by tile id with the tile animations (see Tile Animations below),
  • a wangsets array built by CompositeWangsetBuilder.buildSpotWangsets() for every spot ring and its inner and outer walls, plus the map border inner walls wangset. Every wangset carries one color named after the terrain and uses type: 'mixed', because its wangids fill both edge and corner slots, which is what makes it usable as a terrain in the Tiled map editor.

buildVariationLayers() additionally emits:

  • a ground-variations layer when any tileset has randomGroundTiles (random ground tile ids listed sequentially),
  • a tileset-ref layer listing all annotated tile ids (firstgid + annotatedId for every annotated tile, spot tiles included), so they survive the optimizer,
  • a spot-layer-ground-variations-{spotName} layer per spot that has spotTileVariations.

Map border inner walls wangset

The map border inner walls travel through the same wangset mechanism: CompositeWangsetBuilder.buildMapBorderWallsWangset() emits the wangset named map-border-inner-walls (TilesetConst.MAP_BORDER_WALLS_WANGSET_NAME) out of the mapBorderWallsTiles grid, and TilesShortcuts.fromPropertiesMappersList picks it up by name, so the generator needs no border specific mapper. The grid is fed twice through remapWallsPositions(): once against TilesetConst.MAP_BORDER_WALLS_SURROUNDING_POSITIONS for the surrounding wangids, and once against TilesetConst.MAP_BORDER_WALLS_CORNER_POSITIONS, which turns the 0,-1 and 0,1 cells into the top-right and top-left corner names the wall run ends need (TilesetConst.SPOT_CORNER_WANGIDS is keyed by grid key as well as by corner name, so both spellings resolve).

The wall slot names belong to the generator, not to the tileset. WallsGenerator.determineWallTiles() writes sMC on the row directly below the top border and sTC on the row under it, and InnerWalls.sequences() caps each horizontal run with sMR on its left end and sML on its right end, plus cTR and cTL on the second row. So the wall block's own top row must reach the generator as the middle-* slots, its second row as the top-center slot and the top-left and top-right corners, and the columns are mirrored. That whole shift is expressed once, in CompositeWangsetBuilder.remapWallsPositions(); the mapBorderWallsTiles grid in the admin keeps plain data-pos values (-1,-1 NW through 1,1 SE) so the tiles are picked in their natural reading order. In the @reldens/tile-map-generator package tests, tests/test-data/reldens-dungeon-composite.json shows the same convention on the working cave-inner-walls wangset, and tests/test-data/house-composite.json with tests/test-data/map-border-walls-expected.json prove it end to end for the border.

Per-instance output layer naming (ElementLayerName)

The composite convention {name}-{index}-{layerType} describes the INPUT element layers. When the generator PLACES elements, each placed copy gets its own layer so overlapping copies of the same element never overwrite each other's tiles. ElementLayerName (lib/map/element-layer-name.js of the generator) fuses the instance number to the element key, with no extra - segment since - is the structural delimiter:

  • ElementLayerName.build(elementType, instanceNumber, sourceLayerName): element tree, source layer tree-collisions, instance 0 gives tree0-collisions. If the source layer name does not start with the element key it is appended whole (collisions gives tree0-collisions), so the output is always element-scoped and convention-agnostic.
  • ElementLayerName.instanceIndex(elementType, layerName): inverse parse; strips the element key and reads the leading digits before the next -. Returns the instance index string or null.

Used by RandomMapGenerator.updateLayerData() (builds the per-instance target layer, find-or-create in additionalLayers), PatternMatcher.countElementInstancesInMap() (counts an element's instances by distinct instance indices) and ElementPositionAnalyzer.findElementPositionsInMap() (groups an element's layers by instance index, one position per instance).

TilesetCompositeConfigBuilder.buildConfigData()

Produces map-generator-config.json. Key fields:

  • generatorType - from the first tileset, or defaults to the composite strategy
  • compositeElementsFile - filename of the composite JSON
  • mapsInformation[] - {mapName, mapTitle} per tileset
  • tileOptions - merged by TileOptionsMerger.merge() using the firstgids
  • groundSpots - keyed by normalized spot name (- replaced by _), built by buildGroundSpotConfig()

buildGroundSpotConfig() outputs:

{
    layerName: normalizedKey,
    tilesKey: normalizedKey,
    width: (null !== rawWidth && 0 < rawWidth) ? rawWidth : 5,
    height: (null !== rawHeight && 0 < rawHeight) ? rawHeight : 5,
    quantity, freeSpaceAround, walkable,
    // server-side default: true (visible positioned layer)
    isElement: sc.get(spot, 'isElement', true),
    allowPathsInFreeSpace, variableTilesPercentage, markPercentage,
    applyCornersTiles: hasSurroundingTiles || hasCorners || borderOuterWalls || borderInnerWalls,
    splitBordersInLayers: splitBordersInLayers || borderOuterWalls || borderInnerWalls,
    placeRandomPath,
    // server-side default: true (depth-ordered placement)
    depth: sc.get(spot, 'depth', true),
    mapCentered,
    borderOuterWalls,
    borderInnerWalls: borderInnerWalls || borderOuterWalls,
    borderOuterWallsIncreaseLayerSize
    // spotTile: 0, present only when the user picked a spot tile; omitted when null
}
  • spotTile is only written when the user picked a tile for the spot, and its value is 0: the real optimized tile id is resolved later through the spot middle-center annotation (the sMC to p mechanism, see Stage 3a). When absent, sc.get(groundSpotConfig, 'spotTile', this.groundTile) in the generator returns the ground tile, so the spot is filled with the same tile as the ground and visually blends into the terrain. Always pick a spot tile when the spot needs to be visually distinct from the ground.
  • width and height are null-safe: if the value is null or <= 0, the fallback of 5 is used, matching the default from buildDefaultSpot(). The null check must be explicit (see Critical Utility Behaviors).
  • isElement and depth both default to true on the server side when the property is absent from the spot data, so spots configured without explicit values are treated as visible positioned layers placed above ground. The Depth text input allows an empty value, true, or a layer name string: "" and "false" are converted to null, "true" to boolean true, and any other string is kept as-is.
  • applyCornersTiles is true when the spot has surrounding tiles, corner tiles, or either walls flag. It is the master switch for the spot ring and walls (see Stage 3b). Without it the spot is filled uniformly with the spot tile.
  • When borderOuterWalls or borderInnerWalls is true, splitBordersInLayers is forced true (required for the wall layers to be included in the generator output), and outer walls imply inner walls.

composite.json: The Full Contract

composite.json is a standard Tiled-format map JSON. The map generator reads tile roles exclusively from two places inside it: the tileset entry's tiles array and special layer names.

Tileset entry

Each tileset in composite.json must include a tiles array where each entry annotates a tile id with its semantic role:

"tilesets": [
  {
    "columns": 16,
    "firstgid": 1,
    "image": "my-tileset.png",
    "imageheight": 256,
    "imagewidth": 512,
    "margin": 0,
    "name": "my-tileset",
    "spacing": 0,
    "tilecount": 128,
    "tileheight": 16,
    "tilewidth": 16,
    "tiles": [
      { "id": 42, "properties": [{ "name": "key", "type": "string", "value": "groundTile" }] },
      { "id": 85, "properties": [{ "name": "key", "type": "string", "value": "pathTile" }] },
      { "id": 10, "properties": [{ "name": "key", "type": "string", "value": "top-left" }] },
      { "id": 11, "properties": [{ "name": "key", "type": "string", "value": "top-center" }] },
      { "id": 20, "properties": [{ "name": "key", "type": "string", "value": "corner-top-left" }] },
      { "id": 30, "properties": [{ "name": "key", "type": "string", "value": "border-top" }] },
      { "id": 50, "properties": [
          { "name": "groundSpots", "type": "string", "value": "mySpot" },
          { "name": "key",         "type": "string", "value": "mySpot-middle-center" }
      ]},
      { "id": 60, "properties": [{ "name": "key", "type": "string", "value": "mySpot-top-left" }] },
      { "id": 70, "properties": [{ "name": "key", "type": "string", "value": "mySpot-corner-top-left" }] }
    ]
  }
]

The id field is a 0-based tile id within the tileset: it equals the flat index directly (id = flatIndex). The global tile id for any tile is firstgid + flatIndex.

Key property values

  • "groundTile" - base ground tile. Source: tileOptions.groundTile and every entry of tileOptions.groundTiles.
  • "pathTile" - walkable path tile. Source: tileOptions.pathTile. IMPORTANT: a tile marked as a path layer type keeps only its POSITION; its own gid is discarded and replaced by this single configured pathTile gid when the map is populated. In the composite build, replacePathLayerTiles (@reldens/tileset-to-tilemap lib/composite-builder.js) overwrites every non-zero cell of any path layer with pathTileCompositeId; in random generation, lib/generator/main-path-generator.js of the generator writes pathTile into each path cell. To keep a tile's actual image in an element, add that tile to a non-path layer type as well: each layer type is emitted as its own map layer, so the same tile index can exist on both a path layer (replaced by pathTile) and another layer (keeps its gid).
  • "top-left" through "bottom-right" (9 positions) - surrounding tiles. Source: tileOptions.surroundingTiles[pos], the row,col key mapped through SPOT_SURROUNDING_POSITION_TO_NAME.
  • "corner-top-left" through "corner-bottom-right" - corner transition tiles. Source: tileOptions.corners[pos] mapped through SPOT_CORNER_POSITION_TO_NAME, prefixed with "corner-".
  • "border-top" through "border-left" plus "border-top-left" through "border-bottom-right" - border / edge tiles. Source: tileOptions.bordersTiles[side] (or tileOptions.borderTile on the four sides when bordersTiles is empty) merged with tileOptions.borderCornersTiles[corner], prefixed with "border-".
  • "border-inner-corner-top-left" through "border-inner-corner-bottom-right" - border opening end tiles. Source: tileOptions.borderInnerCornersTiles[corner], prefixed with "border-inner-corner-".
  • "{spotName}-{pos}" - spot surrounding tiles. Source: spot.surroundingTiles[pos] mapped to the position name, prefixed with spotName + "-".
  • "{spotName}-corner-{pos}" - spot corner tiles. Source: spot.corners[pos] mapped to the corner name, prefixed with spotName + "-corner-".

{spotName} is the normalized spot key: normalizeSpotKey() replaces every - in the spot name with _.

The groundSpots property name (not key) marks a tile as the ground tile for a named spot, and CompositeTileAnnotationBuilder.addSpotAnnotations() gives the same spot tile the {spotName}-middle-center key (plus synthetic {spotName}-corner-* keys when the spot has no corners). Entries are merged by tile id (mergeDuplicateTileAnnotations()), so a single tile can carry several roles, for example groundSpots and key: "groundTile" when the spot tile is also the map ground tile.

How ElementsProvider.fetchPathTiles() detects the role

fetchPathTiles() reads the OPTIMIZED tileset tiles[] annotations (already remapped to new ids, newTileId = tileset.firstgid + tile.id):

  • property.name === "groundSpots": sets this.groundSpots[spotName] = tileId (comma-separated spot names supported).
  • value === "groundTile": the first one sets this.groundTile = tileId, every other tile id is pushed to this.groundTiles; when groundTiles ends up not empty the first tile is appended to it too and groundTile is reset to 0, so the generator picks one ground tile at random per map.
  • value === "pathTile": sets this.pathTile = tileId.
  • value starts with "border-inner-corner-": sets this.borderInnerCornersTiles[...]; this branch is tested BEFORE the generic border- one.
  • value contains "border-": sets this.bordersTiles[value.replace("border-", "")] = tileId.
  • Otherwise the spot key is resolved by GroundSpotsMapper.matchGroundSpotKey(value), which strips the trailing position name (and a trailing -corner) instead of counting dash-separated parts, so spot names containing hyphens still match. An empty result means the value belongs to the map itself.
  • Value that does NOT contain "corner-" is a surrounding tile: groundSpotsPropertiesMappers[spotKey].mapSurroundingByKey(value, tileId) when a spot key was resolved (the mapper is created on first use with new PropertiesMapper(spotKey)), otherwise this.propertiesMapper.mapSurroundingByKey(value, tileId).
  • Value that contains "corner-" is a corner tile: groundSpotsPropertiesMappers[spotKey].mapCornersByKey(cleanKey, tileId) when a spot key was resolved, and this.propertiesMapper.mapCornersByKey(cleanKey, tileId) in every case.

After the loop: this.surroundingTiles = this.propertiesMapper.surroundingTiles and this.corners = this.propertiesMapper.corners (the global path tiles mapper).

Special layer names

ElementsProvider.splitByLayerName() recognizes these reserved layer names (this.specialLayers in lib/generator/elements-provider.js of the generator):

this.specialLayers = ['ground', 'path', 'ground-variations', 'borders', 'tileset-ref'];
  • "ground" - skipped as a special layer; the ground tile is identified from the tileset tiles property instead.
  • "path" and "borders" - skipped as special layers, they are never grouped as elements.
  • "tileset-ref" - skipped; it is the configuration only layer that parks the annotated tiles so the optimizer keeps them. It is never used as an element for placement.
  • "ground-variations" - all non-zero tile ids in data[] become this.randomGroundTiles.
  • Layer name containing both "spot-layer-" and "ground-variations-" - after stripping both substrings the remainder is the tilesKey; non-zero tile ids become this.elementsVariations[tilesKey].

Recommended layer name format for spot variations: "spot-layer-ground-variations-{spotName}". After removing "spot-layer-" and "ground-variations-" the result is "{spotName}", which must match the tilesKey in the groundSpots config.

Any other layer name is treated as an element layer. The name must have at least 3 dash-separated parts: "{elementName}-{index}-{layerType}" (names with fewer parts are skipped with an error). This is the step that cuts the composite canvas back into individual elements: every layer is assigned to a GROUP, resolved by ElementsProvider.fetchElementLayerGroup():

  1. Names starting with stairs-up- or stairs-down- are pinned to the keys stairs-up / stairs-down (hardcoded stair keys used by prePlaceStairs and the associated maps floor logic).
  2. Otherwise the key is ElementLayerName.parse(layerName).instanceId: the known layer-type suffix (collisions-over-player, collisions, over-player, below-player, path, base) is stripped and the remainder is {elementName}-{index}, so multi-segment names keep per-instance groups (house-clean-005-collisions gives house-clean-005).
  3. Names not ending in a known layer type fall back to MapNaming.fuseGroupName, the first two dash-separated parts joined (tree-001-custom-suffix gives tree-001).

Every group becomes exactly ONE placeable element: splitElements() clones the map per group, keeps only that group's layers, crops them to the union bounding box of their tiles (cropMapToMinimumArea) and stores the result in croppedElements[groupKey]. That cropped stamp is placed as one unit. The group's quantity, freeSpaceAround, allowPathsInFreeSpace and mapCentered are read from the layer.properties of the group's property-carrying layer.

map-generator-config.json: The mapData Contract

map-generator-config.json feeds the mapData object passed to the map generator. The file is read by LayerElementsCompositeLoader when mapData is not provided directly (in the Maps Wizard, mapData is pre-parsed from the form's generatorData field).

{
  "compositeElementsFile": "composite.json",
  "generatorType": "elements-composite-loader",
  "mapsInformation": [
    { "mapName": "my-map", "mapTitle": "My Map" }
  ],
  "tileOptions": { },
  "groundSpots": {
    "mySpot": {
      "layerName": "mySpot",
      "tilesKey": "mySpot",
      "width": 5,
      "height": 5,
      "quantity": 3,
      "freeSpaceAround": 1,
      "walkable": true,
      "isElement": false,
      "allowPathsInFreeSpace": false,
      "variableTilesPercentage": 0,
      "depth": false
    }
  },
  "factor": 1,
  "mainPathSize": 3,
  "blockMapBorder": true,
  "freeSpaceTilesQuantity": 2,
  "freeTilesMultiplier": 2,
  "variableTilesPercentage": 15,
  "collisionLayersForPaths": ["collisions"]
}

Key fields the map generator reads from mapData:

  • compositeElementsFile - filename of composite.json relative to rootFolder.
  • groundSpots - object keyed by spot name; each entry configures a generated spot area. tilesKey must match the spot name used in the composite's tile property annotations and the variation layer name.
  • factor - image resize factor for the optimized tileset (1 = no resize).
  • Map dimension and generation options: mainPathSize, blockMapBorder, freeSpaceTilesQuantity, freeTilesMultiplier, variableTilesPercentage, collisionLayersForPaths, minimumDistanceFromBorders, splitBordersInLayers, etc.
  • placeRejectResolver - how a rejected element placement is resolved: "moveElements" relocates already placed movable elements to open space, "autoGrow" (default) grows the map bottom to fit the element. Exposed in the Maps Wizard as the "Elements place rejection resolve method" select (placeRejectResolver-common). Placement candidates are validated by a strict feasibility safeguard so every still-pending element keeps a free window; elements are never silently dropped (see Stage 3c2).

Key fields in each groundSpots entry:

  • walkable - when false, the generator appends -collisions to the spot layer name before the per instance suffix (e.g. lake_001-collisions-s0). The Reldens game engine reads any layer containing collisions as a non-walkable collision zone. Set it to false for any spot the player should not be able to walk through.
  • depth - controls where the spot layer is inserted in the final layer stack:
    • false (boolean) and isElement: false: the spot goes into the invisible-spots group, placed before the ground layer and hidden under it.
    • false (boolean) and isElement: true: the spot is placed as an element at default order; element layers are appended after all the static layers, path included (MapLayersComposer.generateLayersList() combines staticLayers first and additionalLayers after them).
    • true (boolean): insert at position 1 (just below the ground layer).
    • string (layer name, e.g. "ground-variations"): insert immediately after the named layer; the spot tiles appear above it. Combined with isElement: true this makes the spot visually prominent on top of the named layer. A name matching no layer falls back to position 1 (MapLayersComposer.calculateTargetIndex).
  • isElement - when true, the spot participates in the element placement pipeline and respects the depth reordering. When false, the spot is stamped by SpotLayersBuilder.generateInvisibleSpots() at a random free position before the ground layer; generateInvisibleSpots does NOT filter by depth, so a non-element spot with a truthy depth is still placed and then reordered by reorderLayersBasedOnSpots.

Note: the nested tileOptions object in this config is NOT read by the map generator. MapsWizardConfigBuilder.applyTileOptions() copies its keys flat into the wizard generatorData, and MapDataMapper.fromProvider() (lib/map/data-mapper.js of the generator) then applies the values read from the composite annotations last, so the annotations win. A posted groundTile / groundTiles only survives as a fallback when the annotation is missing (removeEmptyGroundTiles() drops the empty provider keys), and the keys the provider never exposes (borderTile, borderCornersTiles) are used as posted. The tile role assignments (ground, path, surrounding, etc.) must therefore be encoded in the composite.json tiles array as described above.

roomData: Setting Room Fields on Import

roomData is an optional object read by the maps importer in MapsImporter.import() (lib/import/server/maps-importer.js):

this.roomImportData = new RoomImportData(sc.get(data, 'roomData', sc.get(this.handlerParams, 'roomData', {})));

It is applied to every room row the importer creates, right after the importer defaults and before the insert, in MapsImporter.createRoomByMapTitle(), and it is handled by RoomImportData (lib/import/server/room-import-data.js):

this.roomImportData.applyTo(roomCreateData, roomCustomData, mapName, mapTitle);

Shape:

{
  "roomData": {
    "allRooms": {
      "customData": {"enabled": true}
    },
    "rooms": {
      "reldens-new-age-town": {
        "server_url": "https://some-server-url",
        "room_class_key": "custom-room",
        "customData": {"allowGuest": true}
      }
    }
  }
}
  • allRooms properties are applied to every imported room.
  • rooms properties are applied per map, matched by map name first and by map title as fallback, and they override allRooms.
  • Keys must be the REAL rooms fields: name, title, map_filename, scene_images, room_class_key, server_url (assigned directly, primitives only: string, number, boolean or date) and customData.
  • customData keys are set one by one through RoomCustomData, so the column stays a valid JSON string and only the provided keys change. This is how {"customData": {"enabled": true}} overrides the importer default of enabled: false for imported rooms, making the room usable on the next restart.
  • Any other key, or a non-primitive value for a direct field, is ignored with a warning; it is NOT folded into customData.

Where to put it:

  • Maps Wizard: add roomData to the generatorData JSON in the wizard textarea. The raw JSON is carried to the maps selection step as handlerParams (set to generatorData in MapsWizardSubscriber.generateMaps(), rendered as the hidden handlerParams input of theme/admin/templates/maps-wizard-maps-selection.html), parsed back by SelectedMapsImportRunner.mapGeneratedMapsDataForImport() (handlerParams: sc.toJson(data.handlerParams)) and read from handlerParams.roomData by the importer.
  • Outside the wizard (CLI bin/import.js maps import): add roomData at the top level of the import JSON, which is passed straight to MapsImporter.import(data). A top level roomData wins over handlerParams.roomData.

Stage 2: Tile Map Optimization

Entry: TileMapOptimizer.optimize() in lib/tile-map-optimizer.js of @reldens/tile-map-optimizer, run by ElementsProvider.optimizeMap() with TileMapOptimizer({ originalJSON: tileMapJSON, rootFolder }).

What it does

  1. parseJSON() scans ALL layer data arrays and collects every unique non-zero tile id into mappedOldToNewTiles[] (sorted, zero removed). It also force-adds the gids of every animated tile and its frames, and stores each tileset's wangsets for remapping (wangset tiles are NOT collected as used tiles). It reads the tileset.image filename into tileSet.tmp_image.
  2. createThumbsFromLayersData() calls findImageFile(tileSet), extracts each collected tile image from the source tileset PNG and places it sequentially in a new packed PNG, updating newImagesPositions[oldGID] = newPosition (1-based).
  3. createNewJSON() remaps all layer data values via newImagesPositions, remaps the tile annotation id fields to newImagesPosition - 1, copies tile.properties as-is (no remapping), and remaps wangset tileid values via newImagesPositions[tileset.first + wangsetTile.tileid] - 1.

The optimized tileset PNG and JSON are written to generatedFolder/optimized/ (ElementsProvider.optimizedFolder, GeneratedFoldersConstants.OPTIMIZED_SUB_FOLDER) and the call returns { newImage, newMap, newJSON, newJSONResized }. The intermediate optimized-* files are deleted right after generation (removeOptimizedMapFilesAfterGeneration defaults to true in RandomMapGenerator), and FileOperations.cleanAutoGeneratedProcessMapFiles also removes the generated/optimized/ folder itself once it is empty.

What survives optimization

A tile only survives if it appears in at least one layer's data array (or is an animation tile). Tiles that exist only in annotations (tiles[]) or wangsets but are never placed in any layer data are dropped: their id remapping produces a CRITICAL log and the annotation entry is removed from the optimized composite.

The spot tile must therefore appear in the tileset-ref layer to survive optimization and receive a valid new position. buildVariationLayers() explicitly adds a layer containing firstgid + annotatedId for every annotated tile, spot tiles included, to ensure this. See Tile Ids and Annotations Pipeline.

Output

optimizedMap = output.newJSONResized || output.newJSON. The newJSONResized is used when factor > 1 (pixel-doubled tileset). The optimized tileset always has firstgid = 1.

rootFolder and image resolution

rootFolder = tilesetSessionsDir/output/{sessionId}. TileMapOptimizer.findImageFile() resolves tileset images as rootFolder / tileSet.tmp_image, where tmp_image is extracted from the tileset image field in composite.json (last path component after /). The tileset PNG must therefore exist at output/{sessionId}/{tileset.filename}, where it is placed by TilesetFilesBuilder.buildTilesetFilesEntries() during the generate step.

In-place mutation (critical behavior)

TileMapOptimizer receives originalJSON: this.tileMapJSON and immediately sets this.newJSON = this.originalJSON: the same reference, NOT a clone. createNewJSON() then modifies this.newJSON.layers[i].data[j] in place, so after optimizeMap() returns, elementsProvider.tileMapJSON.layers[i].data contains optimized GIDs and the original values are gone.

async splitElements()
{
    // tileMapJSON.layers have optimized GIDs after this call
    await this.optimizeMap();
    // reads optimized GIDs
    this.elementsLayers = this.splitByLayerName();
    // ...
}

So when splitByLayerName() collects the non-zero tiles for elementsVariations["spot_001"], those values are already the optimized GIDs of the spot variation tiles; no re-mapping is needed. Similarly croppedElements contain optimized GIDs, so the tile ids placed in the final map always come from the single combined optimized tileset.

Stage 3: Map Generation

Processing pipeline: composite.json to generated map

  1. LayerElementsCompositeLoader.load() reads composite.json from rootFolder/compositeElementsFile, sets mapData.rootFolder = rootFolder and mapData.tileMapJSON to the parsed composite content.
  2. RandomMapGenerator.fromElementsProvider(mapData) creates ElementsProvider(mapData) and calls splitElements(), which runs optimizeMap() first and splitByLayerName() after it.
  3. ElementsProvider.optimizeMap() runs the optimizer (Stage 2).
  4. ElementsProvider.fetchPathTiles(), called at the end of optimizeMap(), reads optimizedMap.tilesets[0].tiles[] and populates groundTile, groundTiles, pathTile, surroundingTiles, corners, bordersTiles, borderInnerCornersTiles, groundSpots and groundSpotsPropertiesMappers.
  5. ElementsProvider.splitByLayerName() groups the composite layers by element group, sets randomGroundTiles from the ground-variations layer and elementsVariations from the spot-layer-* layers.
  6. MapDataMapper.fromProvider(props, mapName, elementsProvider) merges the mapData props with all ElementsProvider outputs: groundTile, groundTiles, pathTile, randomGroundTiles, surroundingTiles, corners, bordersTiles, groundSpotsPropertiesMappers, layerElements, elementsQuantity, elementsFreeSpaceAround, and others.
  7. RandomMapGenerator.resetInstance(mergedOptions) only configures the instance: it applies the merged options (setOptions() plus validate()) and rebuilds the sub-instances.
  8. RandomMapGenerator.generate() generates the spots and the map grid, places the elements, draws the paths, composes the layers and writes the map JSON.
fromElementsProvider(props)
  -> new ElementsProvider(props)
  -> elementsProvider.splitElements()
      // runs TileMapOptimizer, calls fetchPathTiles()
      -> optimizeMap()
      // splits composite layers into croppedElements groups
      -> splitByLayerName()
  -> MapDataMapper.fromProvider(props, mapName, elementsProvider)
  // initializes all generator state
  -> resetInstance(mappedMapDataFromProvider)

MapDataMapper.fromProvider()

Merges provider data into props with Object.assign(sc.deepJsonClone(props), ...), provider values last. Key fields passed through:

  • groundSpots - comes from the original props (the config JSON), NOT from elementsProvider.groundSpots.
  • groundSpotsPropertiesMappers - from elementsProvider.groundSpotsPropertiesMappers.
  • optimizedMapFirstTileset - optimizedMap.tilesets[0] (with wangsets, tiles and firstgid = 1); result.tiles = optimizedTileset.tiles, which is how the tile animations reach the final map.
  • layerElements - elementsProvider.croppedElements.
  • groundTile, pathTile, randomGroundTiles, surroundingTiles, corners, bordersTiles - from the provider. removeEmptyGroundTiles() first drops the provider groundTile / groundTiles when empty, so a posted value survives only as a fallback.

RandomMapGenerator.resetInstance()

Sets all options via setOptions(). Critical initialization:

this.propertiesMapper.map(this.surroundingTiles, this.corners);
this.tilesShortcuts = this.mapTilesShortcuts('path', this.pathTile, this.propertiesMapper);

Validation: why the groundTile annotation matters

OptionsValidator (lib/validator/options-validator.js) requires a truthy groundTile among other keys (tileSize, tileSheetPath, tileSheetName, imageHeight, imageWidth, tileCount, columns, layerElements) and a non-empty elementsQuantity; when validation fails, generate() returns false without writing any map file.

  1. The composite tileset annotates, for example, tile id 41 (GID 42) with key: "groundTile".
  2. fetchPathTiles() reads the OPTIMIZED tileset tiles[]: for this tile newTileId = 1 + (newImagesPositions[42] - 1) = newImagesPositions[42], a non-zero position in the optimized tileset.
  3. MapDataMapper.fromProvider() overrides the posted groundTile with this value, so validation passes with an id in the right space.

If the composite had NO key: "groundTile" annotation, the provider value would be empty and dropped, and the groundTile posted from tileOptions (a composite gid, not an optimized one) would survive: validation passes but the ground points at the wrong art. Treat the groundTile annotation as mandatory.

Spot Tiles Resolution - A Wangset Is NOT Required

A ground spot resolves its border, corner and wall tiles from three supported sources. SpotGenerator.fetchSpotPropertiesMapper() tries them in this order:

  1. Tiles properties mappers: groundSpotsPropertiesMappers[tilesKey], built by ElementsProvider from the tileset tiles key annotations prefixed with {tilesKey}- (for example house-room-top-center). Also accepted directly as the groundSpotsPropertiesMappers generator option.
  2. A wangset named exactly tilesKey: resolved by TilesShortcuts.fetchWangsetByName() against optimizedMapFirstTileset.wangsets. This is the channel the tileset editor composites use, and the walls variants are the wangsets named {tilesKey}-inner-walls and {tilesKey}-outer-walls.
  3. The configured surroundingTiles and corners: read from the spot config first, falling back to the map level ones. A PropertiesMapper(tilesKey) is built from them and cached into groundSpotsPropertiesMappers[tilesKey].

Source 3 exists so a spot declared purely through groundSpots works with no annotations and no wangset at all. Requiring a wangset would be wrong: the position keys used here are the grid keys documented in MapDataSchema (-1,0, 0,-1, ...), converted to position names by PropertiesMapper.map().

Source 2 is checked before source 3 on purpose. When a spot has both a wangset and configured tiles, the wangset wins, so composites generated by the tileset editor keep their resolved tiles.

Consequences when nothing resolves

If none of the three sources yields tiles, every TilesShortcuts slot (sTC, cTL, cTR, ...) is undefined. WallsGenerator.isTopBorderTile() returns false for any falsy tile (otherwise undefined === shortcuts.cTL would match), and fetchResolvedWallTiles() returns null (with a debug log) when either wall tile is unresolved, so placeWallTiles() never stamps undefined tiles into the walls layer. A spot with no resolvable tiles produces empty layers. Note TileCountingUtility.countNonZeroTiles() counts undefined as a tile, so any test asserting only "non zero tiles exist" can pass on corrupted output: assert against the expected tile ids instead.

Outer walls without inner walls

WallsGenerator.placeOuterWallTile() skips a position when wallsLayer[tileIdx] is already taken. When borderInnerWalls is false, wallsLayer is false, so the check is wallsLayer && 0 !== wallsLayer[tileIdx]: without it false[tileIdx] is undefined, 0 !== undefined is always true and every outer wall tile would be skipped.

Stage 3a: Spot Generation

Entry: SpotGenerator.generateSpots() (lib/generator/spot-generator.js). It runs first in RandomMapGenerator.generate(), before the map grid is created, because spots can become elements that affect map sizing.

for each spotKey in this.groundSpots:
  // from the config JSON (groundSpots from sc.deepJsonClone(props), NOT from elementsProvider.groundSpots)
  groundSpotConfig = this.groundSpots[spotKey]
  // usually same as spotKey, allows multiple spots to share tile annotations
  tilesKey = sc.get(groundSpotConfig, 'tilesKey', spotKey)
  // 0 when the user picked a tile (the sMC to p mechanism resolves the real id below)
  // this.groundTile when the spotTile key is absent from the config (sc.get fallback)
  spotTile = sc.get(groundSpotConfig, 'spotTile', this.groundTile)

  if groundSpotsPropertiesMappers[tilesKey] exists:
    // populates surroundingTilesPosition + cornersPosition from raw tile positions
    groundSpotsPropertiesMappers[tilesKey].map()

  spotTilesShortcuts = mapTilesShortcuts(tilesKey, spotTile, propertiesMapper)
  // -> TilesShortcuts.fromPropertiesMappersList()

  if spotTilesShortcuts.p is truthy AND spotTile !== spotTilesShortcuts.p:
    // REAL tile id from the optimized composite (via the sMC to p mechanism)
    spotTile = spotTilesShortcuts.p

  // spotTile is now the actual GID to fill the spot with
  // createSpotLayerData(width, height, markPercentage, spotTile, applyCornersTiles)

Spot types and layer placement

  • Functional invisible layers (isElement: false): the spot layers are stored in groundSpotConfig.spotLayers. SpotLayersBuilder.generateInvisibleSpots() places them at a random position on the full map grid and pushes them into staticLayers before the ground layer is added, so they sit below all visible terrain. These spots serve as functional zones (respawn areas, event triggers, zone markers) where only the tile data matters for gameplay logic. The tiles used may be transparent or visually indistinct from the ground. A truthy depth does not exclude them: they are placed and then reordered by reorderLayersBasedOnSpots.
  • Visible positioned layers (isElement: true): saveLayerElements() stores them in layerElements, and the element placement system positions them (respecting freeSpaceAround, quantity, allowPathsInFreeSpace, etc.). These spots become additionalLayers, which are merged after all staticLayers, placing them on top of the terrain. When depth: true, reorderLayersBasedOnSpots additionally moves the spot layer to index 1 in the final stack.

TilesShortcuts.fromPropertiesMappersList()

mapTilesShortcuts() in the generator calls TilesShortcuts.fromPropertiesMappersList() (lib/map/tiles-shortcuts.js):

// If suffix is provided (e.g. '-inner-walls'):
if(suffix){
    // e.g. 'spot_001-inner-walls'
    tilesKey = tilesKey + suffix;
    propertiesMapper = groundSpotsPropertiesMappers[tilesKey];
}
propertiesMapperShortCut = 'path' === tilesKey || !propertiesMapper ? '' : tilesKey+'-';
mappedData = {
    surroundingTilesPosition: propertiesMapper?.surroundingTilesPosition,
    cornersPosition: propertiesMapper?.cornersPosition
};
// Fall back to the wangset if the mapper is missing OR either position dict is empty:
if(
    !propertiesMapper
    || 0 === Object.keys(propertiesMapper.surroundingTilesPosition).length
    || 0 === Object.keys(propertiesMapper.cornersPosition).length
){
    // WangsetMapper(fetchWangsetByName(tilesKey, optimizedMapFirstTileset))
    mappedData = TilesShortcuts.mapWangsetData(tilesKey, optimizedMapFirstTileset);
}
instance = new TilesShortcuts(mappedData.mainTile || mainTile, mappedData.surroundingTilesPosition, ...);

The wangset fallback fires if EITHER surroundingTilesPosition OR cornersPosition is empty: both must be non-empty for the PropertiesMapper path to be used. When the fallback fires and no wangset named spot_001 exists, both come back empty, sMC is undefined and the spot tile cannot be resolved from the annotations.

The sMC to p mechanism

In the TilesShortcuts constructor:

this.sMC = surroundingTilesPosition[prefix+'middle-center'];
// ...all 9 surrounding + 4 corner shortcuts...
// When spotTile = 0 AND sMC is set, p = sMC (the real center tile id)
this.p = 0 === pathTile && 0 !== this.sMC ? this.sMC : pathTile;

This is how spotTile: 0 from the config becomes the real optimized tile id. For a spot named spot_001 the prefix is 'spot_001-' and sMC resolves to surroundingTilesPosition['spot_001-middle-center']. When spotTile is absent from the config, pathTile = this.groundTile and p = this.groundTile.

PropertiesMapper

Created with new PropertiesMapper(spotKey) (lib/generator/properties-mapper.js); the prefix becomes '{spotKey}-'.

  • mapSurroundingByKey(key, value): maps a full key (e.g. 'spot_001-middle-center') into surroundingTiles['0,0'] etc.
  • mapCornersByKey(cleanCornerKey, value): maps 'top-left' into corners['-1,-1'] etc.
  • map(): converts surroundingTiles into surroundingTilesPosition and corners into cornersPosition, with prefixed named keys ('spot_001-top-left', 'spot_001-middle-center', 'spot_001-bottom-right', etc.).

Since propertiesMapperShortCut = tilesKey+'-', the shortcuts read this.sMC = surroundingTilesPosition['spot_001-middle-center'] and this.cTL = cornersPosition['spot_001-top-left'].

Stage 3b: Inner and Outer Walls

When groundSpotConfig.borderInnerWalls is truthy, SpotGenerator.generateSpots() runs:

wallsLayer = this.createLayerInnerWalls(bordersLayer, tilesKey, spotTilesShortcuts, width, height);

WallsGenerator.createLayerInnerWalls()

innerWallsTilesShortcuts = this.mapTilesShortcuts(tilesKey, spotTilesShortcuts.p, null, '-inner-walls');
// tilesKey + '-inner-walls', e.g. 'spot_001-inner-walls'
// groundSpotsPropertiesMappers['spot_001-inner-walls'] is NOT found (no such mapper)
// falls back to WangsetMapper(fetchWangsetByName('spot_001-inner-walls', optimizedMapFirstTileset))

Then determineWallTiles(innerWallsTilesShortcuts, spotTilesShortcuts, currentTile):

if(currentTile === spotTilesShortcuts.cTL) // [innerWallsTilesShortcuts.sML, innerWallsTilesShortcuts.cTL]
if(currentTile === spotTilesShortcuts.sTC) // [innerWallsTilesShortcuts.sMC, innerWallsTilesShortcuts.sTC]
if(currentTile === spotTilesShortcuts.cTR) // [innerWallsTilesShortcuts.sMR, innerWallsTilesShortcuts.cTR]

innerWallsTilesShortcuts.cTL requires the wangset for 'spot_001-inner-walls' to have a tile with corner wangid [0,1,0,1,0,1,0,0].

Wangset naming convention

Wangsets are created by CompositeWangsetBuilder.buildSpotWangsets() (@reldens/tileset-to-tilemap):

  • '{normalizedSpotKey}' - the spot ground ring, from spot.surroundingTiles plus spot.corners, with spot.spotTile used as the 0,0 middle center when the spot has no explicit one
  • '{normalizedSpotKey}-inner-walls' - from spot.innerWallsTiles plus spot.innerWallsCornerTiles
  • '{normalizedSpotKey}-outer-walls' - from spot.outerWallsTiles plus spot.outerWallsCornerTiles

These wangset names are what TilesShortcuts.fetchWangsetByName(tilesKey, ...) searches for. The bare '{normalizedSpotKey}' wangset is required: without it fetchWangsetByName returns false, every spotTilesShortcuts slot is undefined, and isTopBorderTile() can never match, so the inner walls layer comes out empty even when borderInnerWalls is true. Details in Spot Wangsets and Annotations.

applyCornersTiles is the master switch for the spot ring

TilesetCompositeConfigBuilder.buildGroundSpotConfig() derives applyCornersTiles and emits it into the groundSpots config. It is true when the spot has surrounding tiles, or corner tiles, or borderInnerWalls, or borderOuterWalls. When it is false:

  • SpotBordersAndCorners.applySplitBordersAndCorners() returns the main layer untouched, so no border tiles exist and the emitted -borders layer is only a copy of the spot layer,
  • the whole borders and walls block in SpotGenerator.generateSpots() is skipped, so no inner or outer walls layer is created at all.

Spot wall and border layer suffixes

SpotLayersBuilder appends a configurable suffix to each emitted layer name:

  • wallsLayerSuffix - appended to '{layerKey}-inner-walls'
  • borderLayerSuffix - appended to '{layerKey}-borders'
  • outerWallsLayerSuffix - appended to '{layerKey}-outer-walls'

All three default to an empty string. The Reldens game engine treats a layer as a collision zone only when its name ends in -collisions, so with the default empty suffix these layers render but do not block the player. The hand written dungeon example sets them to -collisions and -collisions-over-player.

Paths inner and outer walls

Independent of spots, the path network can grow its own walls:

  • applyPathsInnerWalls (default false) and pathsInnerWallsTilesKey (default 'path')
  • applyPathsOuterWalls (default false) and pathsOuterWallsTilesKey (default 'path')

PathConnector passes the path borders layer into PathTilesFinisher, which calls the same WallsGenerator.createLayerInnerWalls() / createLayerOuterWalls() used by spots. The emitted layers are 'path-borders-inner-walls' and 'path-borders-outer-walls', each with splitBordersLayerSuffix appended. The tiles key names the wangset the wall tiles are read from, so pathsInnerWallsTilesKey: 'cave' resolves the 'cave-inner-walls' wangset.

Ground spot fill options

  • markPercentage - when 100 or more the spot area is filled completely, otherwise round(totalTiles * markPercentage / 100) tiles are filled at random positions inside the area.
  • placeRandomPath (default false) - carves a random path through the spot area.

Entry position values

MapBorderGenerator.createEntryPosition() opens a gap in the map border and emits a return-to-main-map-change-points layer. It does nothing unless entryPosition is a non empty string AND entryPositionSize is greater than zero, both of which default to empty and 0.

The value must be exactly two dash separated parts, direction-position:

  • direction: top or down, nothing else resolves
  • position: left, middle or right

So the six accepted values are top-left, top-middle, top-right, down-left, down-middle and down-right. Anything else logs a critical and leaves the border sealed.

createEntryPosition() runs after placeElements(), never inside populateCollisionsMapBorder(), so it is always cut against the final map size (see Map size and why the map can grow). Do not move this call earlier: PlacementRejectResolver.growMapBottom() rebuilds the whole border ring through redrawBorderForGrownMap(), so a gap cut before placement is drawn over, and its change points are left on the pre grow row while the border moves to the new bottom.

Entry position opening ends

MapBorderGenerator.stampEntryPositionEnds() puts a tile on each side of the gap through fetchOpeningEndTile(isTopBorder, side), which resolves two different vocabularies:

  • borderInnerCornersTiles is rotated 180 degrees against the border for a mid run end. A bottom opening takes the top-* pair and a top opening takes the bottom-* pair, and both flip left with right, so the left end of a bottom opening takes top-right. This is the same rotation the wall band uses (see Map border inner walls wangset above).
  • An end landing on a map corner column keeps the border own family instead, side flipped only, because there the line closes into the map corner rather than turning inwards: the right end of a bottom right opening takes bottom-left. Rotating that one leaves the closing tile shaded against the line and the border reads as broken at the turn.
  • The fallback, used when no inner corners are configured, reads the bordersTiles outer corners and flips only the side, because those are the map real corners: the left end of a bottom opening takes bottom-right, the tile the map already draws at its own bottom right corner.

Both ends are stamped even when the opening sits against a map corner, so a left or right entry position closes the line on the corner column itself instead of leaving the corner tile with nothing joining it.

Main path links

RandomMapGenerator.createMainPathLinks() runs right after createEntryPosition() and only when the map has a previousMapName (with a previousMainPath) or a nextMapName (set by chainMainPaths). Each opening is the main path border row or column cells (the path cells themselves when isBorderWalkable is true). MainPathLinksWriter.writeLinks() clears those cells from the collisions map border, marks them walkable, opens the border inner walls below a top opening through MapBorderWallsDrawer.openWallsBelowTopBorder(), and returns the main-path-links-change-points layer data with change-point-for-{target map} on every cell and one return-point-for-{target map} one tile inside the opening middle, facing into the map. The main path edges use MainPathEdgesConstants (TOP: 0, RIGHT: 1, BOTTOM: 2, LEFT: 3), the same numbering the random main path uses.

Map border inner walls

MapBorderWallsDrawer (lib/generator/map-border-walls-drawer.js) owns the walls that hang below the top border. It reads the wangset named map-border-inner-walls, emitted by the tileset editor through CompositeWangsetBuilder.buildMapBorderWallsWangset(), so the border walls travel the same wangset channel the spot walls use and the generator needs no border specific mapper. The resulting layer is emitted as map-border-inner-walls plus mapBorderWallsLayerSuffix.

A top border opening would be sealed by the wall drawn directly below it. openWallsForEntryPosition() therefore clears both wall rows over the opening columns, marks those grid positions walkable, and applies the inner walls patterns a second time so the two new run ends receive their end tiles. It runs after createEntryPosition() and again after a grown map redraw.

WangsetMapper

WangsetMapper (lib/map/wangset-mapper.js) reads wangset tiles and maps wangids to position names.

Surrounding wangids (9 tiles):

top-left:      [0,0,0,1,0,0,0,0]
top-center:    [0,0,0,1,0,1,0,0]
top-right:     [0,0,0,0,0,1,0,0]
middle-left:   [0,1,0,1,0,0,0,0]
middle-center: [0,1,0,1,0,1,0,1]
middle-right:  [0,0,0,0,0,1,0,1]
bottom-left:   [0,1,0,0,0,0,0,0]
bottom-center: [0,1,0,0,0,0,0,1]
bottom-right:  [0,0,0,0,0,0,0,1]

Corner wangids (4 tiles, different bit pattern):

top-left:     [0,1,0,1,0,1,0,0]
top-right:    [0,0,0,1,0,1,0,1]
bottom-left:  [0,1,0,1,0,0,0,1]
bottom-right: [0,1,0,0,0,1,0,1]

These populate WangsetMapper.surroundingTilesPosition and WangsetMapper.cornersPosition. Since WangsetMapper has no prefix, these keys are bare ('top-left', 'middle-center', etc.), and TilesShortcuts is constructed with propertiesMapperShortCut = '', so the shortcuts read this.cTL = cornersPosition['top-left'] and this.sMC = surroundingTilesPosition['middle-center'].

WallsGenerator.createLayerOuterWalls()

Similar: calls mapTilesShortcuts(tilesKey, spotTiles.p, null, '-outer-walls') and finds the wangset 'spot_001-outer-walls'. It uses WallsMapper to determine opposite tile placements, then applies multiple pattern sequences (OuterWalls, OuterWallsMerge, Corners).

Stage 3c: Map Grid Generation and Order

RandomMapGenerator.generate() runs the steps in this order:

// spots first (can become elements)
generateSpots()
// creates mapGrid and groundLayerData; the map size is computed here
generateEmptyMap(this.mapSize, ...)
// draws and blocks the border only
populateCollisionsMapBorder()
initializeMainPath()
// generateAdditionalLayers() and place all layerElements; the last step that can change mapHeight
await placeElements()
// cut against the final size
createEntryPosition()
// chained main path openings (change points and return points)
createMainPathLinks()
// runs PathFinder, applies borders / corners / walls to paths
executePathsConnection()
// applies randomGroundTiles to the ground-variations layer
applyVariations()
// assembles all layers, reorders by depth, filters empty, merges
generateLayersList()
// wraps in the Tiled map JSON and saves to generated/{mapName}.json
createTiledMapObject(layers)

createEntryPosition() used to run inside populateCollisionsMapBorder(), before placement. A grown map then redrew the border over the gap and the recorded change points stayed on the pre grow row, so the interior had no visible door while its return trigger sat on open floor mid room. It emits its layer with unshift so the layer keeps its original position in additionalLayers regardless of when it runs.

Stage 3c2: Element Placement and Order

ElementsPlacer.placeElements() (lib/generator/elements-placer.js):

  1. generateAdditionalLayers() creates one empty map-sized layer per distinct element source layer name.
  2. prePlaceStairs() places stairs-up / stairs-down at fixed positions from previousFloorData (associated floors) and removes them from elementsQuantity.
  3. feasibility.buildPending() (lib/generator/placement-feasibility.js) builds the pending footprints queue: one entry per element instance, sized width/height + freeSpaceAround * 2. Spots registered as elements are included because they live in elementsQuantity.
  4. CenteredElementsPlacer.placeCenteredElements() runs FIRST: elements with a non-zero mapCentered order are sorted ascending (stable sort, ties keep file order) and placed deterministically, order 1 at the exact map center and the rest in offset rings around it (placementOffsets, ring distance grows per failed round). Their quantities are zeroed so the main loop skips them.
  5. Main loop: iterates the elementsQuantity key order, or by descending area when orderElementsBySize is true. The position search per instance is PositionFinder.findPosition: inOrder scans top-left to bottom-right, random tries random positions.

Every successful placement is recorded in generator.placedElementsJournal as {elementType, elementNumber, position, width, height, freeSpaceAround, allowPathsInFreeSpace, movable, layerNames}.

Strict feasibility safeguard

Every candidate position must keep ALL still-pending elements placeable. PlacementFeasibility.buildValidator():

  • Returns null when nothing is pending (no constraint, behavior identical to a build without the safeguard).
  • Returns false (global fail) when some pending footprint has no free window on the CURRENT grid; no candidate can fix that, so the position search is skipped entirely and the resolver runs.
  • Otherwise returns a validator called per candidate: a cheap free-area pre-check first, then, using a blocked-cells integral table built once per placement (GeometryCalculator.buildBlockedIntegral / hasFreeWindow), every distinct pending footprint (largest area first) must still have a free window that does not overlap the candidate rect.

The validator threads through PositionFinder.canPlaceElement and CenteredElementsPlacer.canPlaceElementCentered. It never changes ordering, only candidate validity.

placeRejectResolver

When no candidate passes (or on global fail) the element is NEVER silently dropped: PlacementRejectResolver.resolve() (lib/generator/placement-reject-resolver.js) runs, controlled by the placeRejectResolver option (moveElements or autoGrow, default autoGrow).

Contract: resolve(elementType, elementNumber) PLACES the rejected element itself (through the normal placeElementOnMap path, so the journal and pending queue stay in sync) and returns a boolean; the caller must NOT place again on success. A re-entrant resolve call (a placement triggered while already resolving) goes straight to autoGrow so resolution always terminates.

  • moveElements: removes the most recent movable placed element (journal tracked; elements with change-points or return-point layers and stairs are never moved), clears its tiles from its per-instance layers, requeues it, rebuilds the map grid from the journal (MapGridBuilder.rebuildGridFromJournal, replaying free-space marking through ElementLayerWriter.markFreeSpaceAroundElementAsNotAvailable), then retries the rejected element. Removed elements are re-placed by processPendingElements() at the end of placeElements(). Falls back to autoGrow when moving cannot open a window.
  • autoGrow: grows the map BOTTOM only (bottom growth appends flat indexes, so every stored main path index and change / return point record keeps pointing at the same tile; growing right would change the row stride and corrupt them), by the rejected footprint height plus free space, border and minimum distance. The grid, ground layer, path layer, all element layers and the border are grown or redrawn consistently (MapBorderGenerator.redrawBorderForGrownMap).

Index stability is not the same as geometric validity. Growth moves the bottom border to a new row, so anything anchored to the old bottom edge is left behind even though its index still resolves. That is why the entry position is cut AFTER placeElements(). growMapBottom() still logs a critical asking for an explicit mapSize when an entry position (or chained main paths) is set; for the entry position itself that advice is now stale and it remains only as a warning that the map did not fit its estimate.

Map size and why the map can grow

MapGridBuilder.setMapSize() takes the configured mapSize when BOTH dimensions are greater than zero, otherwise it calls calculateMapSizeWithFreeSpace() (lib/generator/map-grid-builder.js). Per element type, with freeSpaceAround from ElementsPlacer.determineElementFreeSpaceAround():

freeSpaceCalculated = (freeSpaceTilesQuantity * freeTilesMultiplier + freeSpaceAround * freeSpaceMultiplier)
    * mapSizeFreeSpaceSidesMultiplier
totalArea += (element.width + freeSpaceCalculated) * (element.height + freeSpaceCalculated) * quantity

Then, per ground spot that is not isElement, totalArea += spotWidth * spotHeight * quantity. Finally:

baseSize = max(ceil(sqrt(totalArea)), maxWidth, maxHeight)
mapWidth = mapHeight = baseSize + minimumDistanceFromBorders * 2
// plus 1 on each axis when blockMapBorder

How the estimate balances out

ceil(sqrt(totalArea)) makes the map a square whose area equals the sum of the computed areas. Two effects pull in opposite directions and neither has been measured:

  • the square root assumes perfect packing, which is optimistic, since placement is random position with rejection retries,
  • each element contributes a FULL free space margin on every side (freeSpaceUpDownLeftRight); two adjacent elements share one gap, but the sum counted it twice, so the total is over counted, which is slack.

Do not assume the estimate is too small. There is no evidence of that: with the exhaustive scan fallback in PositionFinder.findPosition() in place, the whole package test suite grows exactly one map, the one deliberately undersized by the test that checks the map grows on the bottom only. Before adding a packing efficiency factor, measure whether real configurations actually reject, otherwise the maps only get bigger for nothing.

What is structurally true regardless: an area based estimate cannot GUARANTEE a packing, so placeRejectResolver is a genuine safety net rather than dead code, and with the default autoGrow the size is computed once but is not final. An explicit mapSize is the only way to fix it, at the cost of a hard placement failure instead of a grow.

Ground spots are counted raw (spotWidth * spotHeight * quantity, with no free space) while elements get (w + freeSpace) * (h + freeSpace). That asymmetry is CORRECT: SpotPlacement.findFreeSpotPlacement() places a spot using only its width and height, and rectOverlapsAny() compares raw rectangles, so a spot never claims a free space margin and none should be counted for it. Spots flagged isElement are skipped there because they are registered as elements and counted in the element loop instead.

Invisible spot placement, and its unchecked fallback

SpotLayersBuilder.generateInvisibleSpots() (lib/generator/spot-layers-builder.js) handles only the spots NOT flagged isElement; an isElement spot is skipped and placed through the element path instead, with the free space, safeguard and reject resolver rules that path carries.

It keeps a single occupiedRects list, pushes every placed spot rect into it and passes it to SpotPlacement.findFreeSpotPlacement(), so every invisible spot avoids every other invisible spot. There is no per spot or per type flag anywhere in the ground spot config that permits overlap on this path.

findFreeSpotPlacement() tries 30 random positions and, when none is free, returns a random position WITHOUT checking it. So on a crowded map the final placement is unvalidated and may overlap. Whether overlapping invisible spots is acceptable depends on the intended design, which is not expressed anywhere in the code; what the code does guarantee is only that the first 30 attempts try to avoid it. Spots do not go through PlacementRejectResolver, so there is no grow or retry behind them.

A rejected placement does not mean the map is full

PositionFinder.findPosition() (lib/generator/position-finder.js) picks a strategy from placeElementsOrder, and the two differ in whether a failure is trustworthy:

  • inOrder calls findNextAvailablePosition(), an EXHAUSTIVE first fit scan over every row and column that returns the first fitting spot. A null means the element genuinely fits nowhere, so a grow is justified.
  • random, the DEFAULT, calls findRandomPositionOnAnywhere(), which is tryRandomPositions(200, ...): 200 random draws and then it gives up. On a crowded map the odds of drawing a valid cell inside 200 tries collapse long before the map is actually full.

So with the default settings a grow is frequently triggered by SAMPLING failure, not by a full map, and the map ends up larger than it needed to be. This is separate from the size estimate above: even a correctly sized map will grow if the sampler misses. placeElementsCloserToBorders takes a different path, findRandomPositionCloserToBorders(), which tries edge and distributed border positions first and uses mapWidth * mapHeight as its try budget instead of 200.

Anything reasoning about why a map grew has to separate the three causes: the estimate being the theoretical minimum, the 200 draw cap in random mode, and the element genuinely not fitting. Because of that, anything anchored to the map edges must be produced after placement, which is the order shown in Stage 3c.

Stage 3d: Assembling the Final Layer Stack

RandomMapGenerator.generate() calls MapLayersComposer.generateLayersList() (lib/generator/map-layers-composer.js) after all element and path generation is complete. It assembles staticLayers, then filters and merges them into the final layer array:

generateLayersList()
  // places each non-element spot onto the full map grid at a free random position
  -> spotLayersBuilder.generateInvisibleSpots(staticLayers, generatedSpots, mapWidth, mapHeight, ...)
  // adds ground, ground-variations, collisions-map-border, map border inner walls, path, inner / outer wall layers
  -> staticLayers.push(groundLayer, ...)
  // merges staticLayers with additionalLayers (element layers from placeElements())
  -> staticLayers + additionalLayers
  // re-orders layers based on the spots depth
  -> reorderLayersBasedOnSpots(layers)
  // replaces null tiles with 0; logs an error for each null
  -> null-tile sanitization loop
  // removes fully-empty layers to reduce file size
  -> layers.filter(layer => layer.data.some(tile => tile !== 0))
  // merges layers sharing a name sub-key (autoMergeLayersByKeys)
  -> mergeLayersByNameSubstring(layers, matchKey)
  // assigns sequential ids
  -> applyLayersIds(layers)

Requirements for an invisible spot (isElement: false) to appear:

  • groundSpotConfig.spotLayers must exist and be non-empty (populated by generateSpots()); otherwise a warning is logged and the spot is skipped.
  • groundSpotConfig.width and height must be greater than 0; with 0 or null the inner copy loop never executes.
  • The spot layer data must have at least one non-zero entry; an all-zero layer passes placement but is removed by the empty layer filter.
layers = layers.filter(layer => {
    let keepLayer = layer.data.some(tile => tile !== 0);
    if(!keepLayer){
        Logger.debug('Empty layer will be removed: '+layer.name);
    }
    return keepLayer;
});

A spot layer with all-zero data (for example because width / height was 0 or null at createSpotLayerData time) is removed here: the layer was created but immediately filtered as empty.

Spots as Terrain Sets in the Generated Map

The generated map tileset can carry the placed spots as Tiled terrain sets, so every spot can be painted as a terrain when the map is edited. It is controlled by includeSpotsAsTerrains (default true), exposed in the Maps Wizard common options as "Include Spots As Terrains". Only the spots actually placed in that map become terrains, so a spot with quantity: 0 or one that failed placement is never written, and tiles outside the map tileset are dropped.

  1. SpotGenerator.appendSpotTerrains() runs once per spot key, after the spot instances were created, and only when the spot produced layers (groundSpotConfig.spotLayers non-empty).
  2. The terrain positions come from buildSpotTerrainPositions(): the surroundingTilesPosition and cornersPosition already resolved in TilesShortcuts.originalMappedData (from the PropertiesMapper or from the wangset fallback), plus the resolved spotTile as middle-center when the mapped data has none, which is the case for a spot filled with a single tile.
  3. When the spot config has borderInnerWalls or borderOuterWalls, the same is stored for tilesKey+'-inner-walls' and tilesKey+'-outer-walls' using their own mapTilesShortcuts() resolution.
  4. generateSpots() returns the collected spotsTerrains, assigned onto the generator with the rest of the spots result.
  5. createTiledTilesetObject() calls SpotTerrainsBuilder.build(spotsTerrains, tileset.tilecount, tileset.firstgid) and attaches the result as tilesets[0].wangsets.

SpotTerrainsBuilder (lib/map/spot-terrains-builder.js) writes what WangsetMapper reads, using the same position / wangid table (lib/map/wangset-positions.js): each terrain becomes {name, type: 'mixed', tile, colors: [one color named after the terrain], wangtiles: [{tileid, wangid}]}, where tileid = gid - firstgid. Tiles outside the map tileset (tileid negative or beyond tilecount) are dropped and a terrain left without tiles is not written, so a terrain never points at tiles the map does not have. Duplicated tile ids inside one terrain are kept once. A generated map can therefore be fed back as a composite and its spots are recognized again. The optimizer already preserves and remaps terrain sets, so an optimized map keeps them with corrected tile ids.

TerrainsValidator (lib/validator/terrains-validator.js) verifies the terrains of a generated map tile by tile, reusing WangsetMapper to read back the emitted wangtiles as positions:

  • validateTerrainsMatchResolvedTiles(map, spotsTerrains) - every emitted terrain tile must be the tile resolved for that position when the spot was generated, and every emitted terrain must belong to a generated spot.
  • validateTerrainsMatchSourceTerrains(map, optimizedMapFirstTileset) - the emitted terrains must match the source composite terrains the tiles were taken from, after the optimizer remapped the tile ids.
  • validateTerrainsTilesArePresentInMap(map) - every terrain must have a representative tile and at least one of its tiles painted in the generated map layers.

Covered by tests/test-dungeon-generation-expected-maps.js (the real dungeon composite: cave, cave-inner-walls and cave-outer-walls against the committed tests/test-data/dungeon-walls-terrains-expected.json golden file), tests/functionality/test-spots-generation.js (single tile spots, terrains disabled) and the unit tests in tests/test-spot-terrains-builder.js and tests/test-spot-generator.js.

Annotation Rules for Spots

CompositeTileAnnotationBuilder.buildTileAnnotations() adds tiles[] to each tileset entry in the composite. The annotations come from the effectiveTileOptions (merged global and per-tileset options: groundTile, pathTile, borderTile, surroundingTiles, corners, bordersTiles, etc.) and from tileset.spots[]: every spot with a spotTile adds multiple annotations on that tile id:

{ id: spot.spotTile, properties: [{ name: 'groundSpots', value: normalizedKey }] }
{ id: spot.spotTile, properties: [{ name: 'key', value: normalizedKey+'-middle-center' }] }
{ id: spot.spotTile, properties: [{ name: 'key', value: normalizedKey+'-corner-top-left' }] }
  • The 'groundSpots' annotation makes fetchPathTiles() register groundSpots[spotKey] = newTileId.
  • The '{spotKey}-middle-center' annotation creates groundSpotsPropertiesMappers[spotKey] and sets surroundingTilesPosition['spot_001-middle-center'], which drives the sMC to p mechanism.
  • The '{spotKey}-corner-*' annotations set cornersPosition['spot_001-top-left'] etc.

Synthetic corner annotations

When a spot has a spotTile set but spot.corners is empty (the user picked a center tile but no corner tiles), addSpotAnnotations() emits synthetic corner annotations on the spot tile to keep cornersPosition non-empty and prevent the wangset fallback:

let hasCorners = spot.corners && 0 < Object.keys(spot.corners).length;
if(!hasCorners){
    this.addSyntheticCornerAnnotations(tiles, spot.spotTile, normalizedKey);
}

addSyntheticCornerAnnotations(tiles, spotTile, normalizedKey)
{
    let cornerNames = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
    for(let cornerName of cornerNames){
        tiles.push({ id: spotTile, properties: [
            { name: 'key', type: 'string', value: normalizedKey+'-corner-'+cornerName }
        ]});
    }
}

All 4 corners are emitted because 1 corner is enough to make cornersPosition non-empty, but having only top-left leaves cTR, cBL and cBR undefined in TilesShortcuts, so with applyCornersTiles = true the other 3 corner cells would render as tile 0. All 4 synthetic corners ensure a visually uniform fill when no real corner tiles are configured.

The !hasCorners condition prevents ambiguity when real corner tiles are defined: real corners are added through addPositionalAnnotations(spot.corners, ...) on different tile ids. If both synthetic and real corners were present, fetchPathTiles() would process both, and the iteration order (determined by the optimizer) would decide which tile id wins.

Required composite annotations for spots to work

For a spot named spot_001 with spot.spotTile = N to generate correctly, the optimized tileset MUST have the following annotations on tile N:

  • groundSpots: "spot_001" - registers groundSpots['spot_001'] = N in elementsProvider.
  • key: "spot_001-middle-center" - sets surroundingTiles['0,0'] = N, so sMC = N, p = N and spotTile = N.
  • key: "spot_001-corner-top-left" - sets corners['-1,-1'] = N, making cornersPosition non-empty and preventing the wangset fallback.
  • key: "spot_001-corner-top-right" - sets corners['-1,1'] = N, filling the TR corner with the spot tile.
  • key: "spot_001-corner-bottom-left" - sets corners['1,-1'] = N, filling the BL corner with the spot tile.
  • key: "spot_001-corner-bottom-right" - sets corners['1,1'] = N, filling the BR corner with the spot tile.

The four corner-* annotations are only required when spot.corners = {}. When real corner tiles are configured, they replace the synthetic values through their own key: spot_001-corner-* annotations on their respective tile ids.

Data Flow Summary (Tile Id Through the Pipeline)

UI: user sets spot.spotTile = 5 (tileset-local index, 0-based)
    |
    v
composite.json: tileset firstgid=1, spot tile annotation id=5
  tile GID in composite = firstgid + 5 = 6
    |
    v
TileMapOptimizer: tile 6 placed in the tileset-ref layer, survives, newImagesPositions[6] = N
  annotation id remapped: old_id=5 becomes new_id = N-1
    |
    v
optimized composite: tile annotation id = N-1, layer data values = N, tileset firstgid = 1
    |
    v
fetchPathTiles(): newTileId = tileset.firstgid + tile.id = 1 + (N-1) = N
  groundSpotsPropertiesMappers['spot_001'].mapSurroundingByKey('spot_001-middle-center', N)
    |
    v
PropertiesMapper.map():
  surroundingTilesPosition['spot_001-middle-center'] = N
    |
    v
TilesShortcuts.fromPropertiesMappersList():
  mappedData.surroundingTilesPosition = {'spot_001-middle-center': N, ...}
  new TilesShortcuts(mainTile=0, surroundingTilesPosition, cornersPosition, prefix='spot_001-')
  this.sMC = surroundingTilesPosition['spot_001-middle-center'] = N
  this.p = 0 === pathTile(0) && 0 !== N ? N : 0 = N
    |
    v
SpotGenerator: spotTile = spotTilesShortcuts.p = N  (correct optimized tile id)

Example Files vs UI-Generated Flow

The example configs in examples/layer-elements-composite/ of the generator package are hand-crafted and bypass the tileset-to-tilemap UI tool entirely. Key differences:

  • spotTile - examples: absent (uses the groundTile fallback); UI-generated: absent when null, 0 when set.
  • tilesKey - examples: explicit string, e.g. 'cave', shared across multiple spots; UI-generated: normalized spot name, e.g. 'spot_001', unique per spot.
  • applyCornersTiles - examples: explicit true / false; UI-generated: derived from the surrounding tiles, corners and walls flags.
  • borderInnerWalls - examples: wangset name string, e.g. 'cave-inner-walls'; UI-generated: boolean.
  • borderOuterWalls - examples: wangset name string, e.g. 'cave-outer-walls'; UI-generated: boolean.
  • wallsLayerSuffix - examples: present, e.g. '-collisions'; UI-generated: absent (the generator defaults to '').
  • borderLayerSuffix - examples: present, e.g. '-collisions-over-player'; UI-generated: absent (the generator defaults to '').
  • outerWallsLayerSuffix - examples: present; UI-generated: absent (the generator defaults to '').

borderInnerWalls as boolean vs string: the spot generator only does a truthy check, so both true and 'cave-inner-walls' enable the walls. The wangset actually looked up is always tilesKey + '-inner-walls', never the borderInnerWalls value itself.

Multiple spots sharing a tilesKey: in the dungeon example, caveRooms, caveSingle and cavesBig all use tilesKey: 'cave', so they share the same tile annotations (one PropertiesMapper for all three spots). In the UI flow each spot gets its own tilesKey, the normalized spot name.

Critical Utility Behaviors

sc.hasOwn and sc.get with null values

From @reldens/utils:

hasOwn(obj, prop) {
    return obj && {}.hasOwnProperty.call(obj, prop) && 'undefined' !== typeof obj[prop];
}
get(obj, prop, defaultReturn) {
    return this.hasOwn(obj, prop) ? obj[prop] : defaultReturn;
}

The critical trap: typeof null === 'object', NOT 'undefined'. Therefore sc.hasOwn(spot, 'width') returns true when spot.width = null, and sc.get(spot, 'width', 5) returns null (not 5). The fallback is only used when the key is absent or set to undefined. To guard against null values, check explicitly:

let raw = sc.get(spot, 'width', null);
let width = (null !== raw && 0 < raw) ? raw : 5;

elementsProvider.groundSpots vs config groundSpots

These are two different things with the same name:

  • elementsProvider.groundSpots - { spotKey: newTileId }, tile ids only, populated by fetchPathTiles() from the groundSpots tile annotation.
  • config groundSpots - { spotKey: { layerName, tilesKey, width, height, quantity, ... } }, the full placement config, read from map-generator-config.json.

MapDataMapper.fromProvider() uses Object.assign(sc.deepJsonClone(props), ...) and the groundSpots key is NOT in the override object, so it comes from the original props. When SpotGenerator looks up tile ids for a spot, they come from elementsProvider.groundSpotsPropertiesMappers, not from elementsProvider.groundSpots.

Tile Animations: Editor to Generated Map

Animated tiles are configured per tileset in the editor Animations panel and stored in the session state next to tileOptions and spots:

{
  "animationsDefaultDuration": 200,
  "skipTileAnimations": false,
  "tileAnimations": [
    {
      "name": "water-flow",
      "baseTile": 242,
      "defaultDuration": null,
      "frames": [
        { "tile": 242, "duration": null },
        { "tile": 244, "duration": 300 }
      ]
    }
  ]
}

baseTile and frames[].tile are flat indices (tileset local, 0-based), the same values used by every other tile option, and they must be stored as numbers (they are validated with sc.isInt, anything else is dropped). Duration precedence per emitted frame: frame duration, then the animation defaultDuration, then the tileset animationsDefaultDuration, then TilesetConst.ANIMATIONS_DEFAULT_DURATION (200). The resolution is falsy driven, so null, 0 and empty values fall through, and every emitted frame ends with an explicit numeric duration.

Frame reorder

Frame order is user controlled by drag and drop, implemented in theme/admin/js/tileset-to-tilemap/tileset-animation-frames-reorder.js (TilesetAnimationFramesReorder, instantiated in the TilesetAnimationsBinder constructor and wired in bindTileset() through bindList()). It delegates dragstart, dragover, drop and dragend on .tileset-animations-list, the same delegation the click and input handlers use. The frame cells carry draggable="true" from the .tileset-animation-frame-template while the duration input carries draggable="false", so dragging inside the number field does not start a frame drag. A drop is only accepted when the target frame belongs to the same animation (it compares data-animation-index), so frames cannot be moved across animations.

The move splices the frame object out and back in at the target index, which carries its per frame duration along, then refreshPanel() re-renders the list so every data-frame-index is re-derived and the remove buttons and duration inputs stay aligned. Nothing else is needed for persistence: the order lives in animation.frames, which tileset-serializer.js already writes.

After every move the reorder sets animation.baseTile = animation.frames[0].tile, so the first frame is always the main frame. Dropping a frame into the first position makes it the base tile, and dragging the current base out of the first position promotes whatever lands there, which matches what resolveBaseTile() already does when the base tile frame is removed with right click. This also means buildFrames() never has to prepend the base tile after a reorder, so the emitted frame count stays the same as the list shown in the editor. Changing the base tile changes which map cells play the animation, since the base tile is the tile actually painted on the map.

Skip tile animations

skipTileAnimations is the "Skip tile animations" checkbox of the Animations panel (.tileset-animations-skip, bound in tileset-animations-binder.js, rendered by tileset-animations.js, persisted by tileset-serializer.js and loaded by state-builder.js). Checked, the generate request strips that tileset's animations client side before the POST: TilesetGenerator.runGenerate() sends TilesetAnimationsNormalizer.stripSkippedAnimations(tilesets) (tileset-animations-normalizer.js), which replaces tileAnimations with an empty array, so TileAnimationsBuilder.build() receives no animations, the composite carries no animation key and the optimizer does not force the frame tiles into the packed sheet. The session state is written from the unstripped fullTilesets, so the animations data survives in the session, the switch is reversible and works as an A/B test for anything suspected to come from the animated tiles. It does not change the merge: merging preserves the animations and the resulting merged tileset starts unchecked.

Emission, merge and downstream

  • TileAnimationsBuilder.build() emits an animation ONLY when its baseTile belongs to the tiles the tileset actually uses (the annotated flat ids, every tile of every element layer, and the variation tiles). Frames are not filtered: a used base tile pulls its frames into the optimized sheet even when they are not painted anywhere.
  • The output goes into the composite tileset entry tiles array, merged by tile id with the role annotations by mergeDuplicateTileAnnotations(), so a tile can carry properties and animation at once; an animated tile with no role is emitted with only the animation key, for example { "id": 242, "animation": [{ "duration": 200, "tileid": 242 }, { "duration": 300, "tileid": 244 }] }. When the first configured frame is not the base tile, the base tile is prepended as frame 1.
  • Merged tilesets: TilesetsMerge.run() remaps the animations through TileAnimationsBuilder.remapForMerge(), baking every frame duration and resetting defaultDuration to null, so a merged tileset behaves like an uploaded one.
  • TileMapOptimizer.parseJSON() force-adds the animated base tile gid and every frame gid to the used tiles, and createNewJSON() remaps the entry id and each frame.tileid to their new packed positions, copying the durations verbatim. MapDataMapper assigns result.tiles = optimizedTileset.tiles and RandomMapGenerator emits that array in the tileset entry of the final map JSON, so the animations arrive in the generated map with optimized tileset local ids.

Full details: Tileset to Tilemap - Tile Animations.

Maps Wizard Cards and the Elements Editor

Card layout and the aspect ratio

The wizard options list is a flex row defined by .wizard-options-container in theme/admin/css/container-maps-wizard.css. Each generated map is one .wizard-map-option-container card, and the card clamps its preview canvas so several maps fit side by side.

That clamp is the reason the maps elements editor used to distort the map: the editor mounts a much larger canvas into a card sized for a thumbnail. The fix is the .is-editing state on the card, which sets flex: 0 0 100% and order: -1 so the editing card takes the full row and jumps to the front, and lifts the clamp with max-width: none on the canvas.

Important detail for anyone changing this: the clamp selector is nested four classes deep (.maps-wizard .wizard-options-container .wizard-map-option-container .map-canvas-container canvas), so its specificity is 0,4,1. An override written in container-maps-elements-editor.css at 0,2,1 is inert no matter the source order. The override has to live inside the same nested block in container-maps-wizard.css.

EditorUi.dispose() (theme/admin/js/maps-elements-editor/editor-ui.js) also clears the inline width and height it set in EditorUi.applyZoom() before returning the canvas to its original parent. Without that, the zoomed inline sizes stay on the element and the thumbnail stays broken after the editor closes:

if(this.originalCanvasParent && this.editor.canvas){
    this.editor.canvas.style.width = '';
    this.editor.canvas.style.height = '';
    this.originalCanvasParent.appendChild(this.editor.canvas);
}

Why the preview modal died after closing the editor

Opening the editor replaces the card canvas, and the replacement is a clone. Cloning a node copies attributes but NOT event listeners, so the cloned canvas lost both its click listener and its data-toggle="modal" attribute, and clicking the preview after closing the editor did nothing. AdminMapElementsEditorLauncher handles this explicitly:

  • openEditor() stores the canvas on the button as button.editorCanvas and calls toggleEditingCard(button, true).
  • closeEditor() calls reattachExternalListeners(button.editorCanvas), which re-sets data-toggle and re-binds the click handler that calls adminFunctions.openElementModal(canvas), then nulls the reference and calls toggleEditingCard(button, false).

Tileset editor hover readout

TilesetCanvasTileReadout (theme/admin/js/tileset-to-tilemap/canvas-tile-readout.js) shows the tile under the cursor as column, row and flat index while hovering the tileset canvas, which is what makes picking spot tile indexes possible without counting tiles by hand.

It does not compute the tile itself. It reuses app.interaction.tileEditor.getTileFromEvent(event, canvas, tileset), the same resolution the click handler uses, so the readout can never disagree with what a click would select. It is wired from tileset-row-binder.js on mousemove and mouseleave, and its container sits above .canvas-scroll-area with a fixed height in component-canvas-panel.css so showing and clearing the text never shifts the layout.

Key File Reference

@reldens/tileset-to-tilemap

  • lib/routes/generate.js - GenerateRoute.handle(), the server endpoint called by the UI generate button.
  • lib/tileset-files-builder.js - build(), buildCompositeEntries(), buildTilesetFilesEntries(), buildElementFiles(): the main file-generation orchestrator.
  • lib/composite-builder.js - buildCompositeJSON(), createTilesetEntry(), buildVariationLayers(), buildElementLayers().
  • lib/composite-annotation-resolver.js - resolve(), resolvePathTileCompositeId(), applyMergedToEffective(), applyGlobalAsDefault().
  • lib/composite-wangset-builder.js - buildSpotWangsets(), appendSpotWangsets(), buildWangset(), buildWangtiles(), buildMapBorderWallsWangset(); the wangid tables live in TilesetConst.SPOT_SURROUNDING_WANGIDS and TilesetConst.SPOT_CORNER_WANGIDS.
  • lib/composite-tile-annotation-builder.js - buildTileAnnotations(), addSpotAnnotations(), collectAnnotatedFlatIds().
  • lib/tile-animations-builder.js - build(), remapForMerge().
  • lib/tileset-composite-config-builder.js - buildConfigData(), buildGroundSpotConfig(), buildTilesetData().
  • lib/maps-wizard-config-builder.js - buildPartialGeneratorData(), converts map-generator-config.json into {strategy, partialData} for the Maps Wizard API.

@reldens/tile-map-optimizer

  • lib/tile-map-optimizer.js - optimize(), parseJSON(), createThumbsFromLayersData(), createNewJSON(), fetchNewImagePositionForTile().

@reldens/tile-map-generator

  • lib/random-map-generator.js - fromElementsProvider(), generate(), mapTilesShortcuts(), setOptions().
  • lib/generator/elements-provider.js - splitElements(), optimizeMap(), fetchPathTiles(), splitByLayerName(), cropMapToMinimumArea().
  • lib/generator/ground-spots-mapper.js - matchGroundSpotKey(), appendSpotNames().
  • lib/map/data-mapper.js - fromProvider(): merges the deep clone of props (keeping the original groundSpots config) with the provider data.
  • lib/map/tiles-shortcuts.js - fromPropertiesMappersList(), mapWangsetData(), fetchWangsetByName(), constructor.
  • lib/map/wangset-mapper.js - mapPositionsFromWangset(), fetchTopCenterTile(), constructor.
  • lib/generator/properties-mapper.js - map(), mapSurroundingByKey(), mapCornersByKey(), mapSurroundingByPosition(), mapCornersByPosition().
  • lib/generator/spot-generator.js - generateSpots(), createSpotLayerData(), saveLayerElements(), createVariationsLayer(), appendSpotTerrains().
  • lib/generator/spot-layers-builder.js - generateInvisibleSpots() and the spot layer suffixes.
  • lib/generator/walls-generator.js - createLayerInnerWalls(), createLayerOuterWalls(), determineWallTiles(), placeWallTiles().
  • lib/generator/map-layers-composer.js - generateLayersList(), reorderLayersBasedOnSpots().
  • lib/generator/path-connector.js - connectPaths(), placeMainPath().
  • lib/loader/layer-elements-composite-loader.js - load(): loads mapData from props.mapData (form submission data), loads the composite JSON from compositeElementsFile and sets mapData.tileMapJSON.
  • lib/loader/layer-elements-object-loader.js - load(): alternative loader for the elements-object-loader strategy; loads mapData plus layerElements from files; does NOT use ElementsProvider and does NOT build groundSpotsPropertiesMappers.

Reldens admin

  • lib/admin/server/subscribers/maps-wizard-subscriber.js - handles POST /maps-wizard; reads req.body.generatorData into mapData.
  • lib/admin/server/subscribers/maps-wizard-runner.js - MapsWizardRunner.run(), routes to LayerElementsCompositeLoader plus generator.fromElementsProvider() for the elements-composite-loader strategy.
  • lib/admin/server/composite-sample-files-provider.js - ensureCompositeFile().
  • theme/admin/js/tileset-to-tilemap/tileset-generator.js - generate(), serializeTileset(), runGenerate().
  • theme/admin/js/tileset-to-tilemap/state-builder.js - TilesetStateBuilder.buildTileset(), loads spots from session data with no normalization.
  • theme/admin/js/tileset-to-tilemap/spot-editor.js - appendSpotRow(), initSpotProps(), initNumberSpotProp().
  • theme/admin/js/tileset-to-tilemap/tileset-tile-options-binder.js - addSpot(), removeSpot().
  • theme/admin/js/tileset-to-tilemap/shared-utils.js - SharedUtils.buildDefaultSpot().
  • theme/admin/js/tileset-to-tilemap/tileset-tile-options-pick-handler.js - handleSpotTilePick().
  • theme/admin/js/maps-wizard/maps-wizard-utils.js - buildGeneratorData(), fillInputsFromData(), updateGeneratorDataFromInputs(), updateInputsFromGeneratorData(), setExtraProperties().
  • theme/admin/js/maps-wizard/maps-wizard-bindings.js - Maps Wizard event bindings and session auto-load; calls setExtraProperties() before fillInputsFromData() when loading the tileset session config.

Related Documentation

Go Up