Town With Inner Houses
Generate a town map where each house door leads to its own explorable interior map, from the admin Tileset Analyzer and Maps Wizard or from the tile-map-generator example scripts.
The MultipleWithAssociationsByLoaderGenerator strategy of the Tile Map Generator generates a town surface map and, for every door element placed on it, a separate interior sub-map that the player can explore. Each door is a pair of change-points and return-point layers: entering the door teleports the player into the generated interior, and leaving it puts the player back outside. Several towns can be generated in sequence and chained at opposite edges by a continuous main path, producing a connected overworld whose houses are individually enterable.
This page has two parts: a worked example in the admin panel that turns a tent into an enterable door, and the generator side of the same flow (example scripts, configuration and authoring rules).
Admin walkthrough: turn a tent into an enterable door
Scenario used in every step:
- The surface is a Tileset Analyzer town session saved with the name reldens-town: 27x45 tiles at 32x32, with the element tent-001 plus trees and ground spots, and no door wiring yet.
- The interior is the shipped example house-composite.json: 40x26 tiles at 16x16.
- A session ID is the save timestamp followed by the session name (YYYY-MM-DD-HH-mm-ss-reldens-town) and is written {sessionId} below.
- tent-001 becomes a door by hand-adding the tent-001-change-points and tent-001-return-point layers, modeled on the reference reldens-town-composite-with-associations.json shipped in node_modules/@reldens/tile-map-generator/examples/layer-elements-composite/. The trees stay decorative.
Step A - Load the town session
- Open the Tileset Analyzer at /reldens-admin/tileset-analyzer/ (sidebar, "Wizards" group).
- The saved sessions list loads automatically when the page opens; the "Generated Files" button only shows or hides that list, it does not fetch anything.
- In the town session row, click "Load" and confirm. The session name field is filled with the part of the ID after the timestamp: reldens-town.
- Optional, only if you changed elements: regenerate composite.json and map-generator-config.json in place. Keep "Override session" checked and the session name reldens-town, then click "Generate All". With override checked the session timestamp is kept, so output/{sessionId}/composite.json is rewritten. Do not use the per-tileset "Generate" button for this: it always creates a new timestamped session.
- Check the loaded composite: the top-level width and height of the session composite.json are 27 and 45, its tilewidth and tileheight are 32, and every tileset declares 32x32. tent-001 emits the layers tent-001-collisions, tent-001-over-player, tent-001-collisions-over-player and tent-001-path, and the group carries a quantity property, so it is placed and the door layers will ride along.
Step B - Bring in the interior files
The interior composite and both its tileset images must be in the same session output folder (the wizard rootFolder). The wizard upload field writes elsewhere, so this is a manual file copy, from the project root:
cp "node_modules/@reldens/tile-map-generator/examples/layer-elements-composite/house-composite.json"
"node_modules/@reldens/tile-map-generator/examples/layer-elements-composite/inside.png"
"node_modules/@reldens/tile-map-generator/examples/layer-elements-composite/outside.png"
"generate-data/tileset-sessions/output/{sessionId}/"
- Both PNGs are required: house-composite.json declares the inside tileset (firstgid 1, inside.png, 16x16) and the outside tileset (firstgid 1681, outside.png, 16x16). Without outside.png the outside tileset image is missing.
- The generate step also provides these files when they are missing: MapsWizardSubscriber.generateMaps() calls CompositeSampleFilesProvider.ensureCompositeFile() (lib/admin/server/composite-sample-files-provider.js), which follows the composites named by the compositeFileNames layer properties and copies each missing one, plus the tileset images it references, from the package examples into the session folder. The manual copy is only needed to use a modified interior.
- The interior ships its own floor connectors (stairs-up-return-point, stairs-up-change-points, stairs-down-return-point, stairs-down-change-points), so you do not author interior stairs.
Why this folder: AssociatedMaps.loadTileMapJSON() resolves the interior as FileHandler.joinPaths(rootFolder, compositeFileName + '.json'), and for a session the Maps Wizard (MapsWizardSubscriber.generateMaps()) sets rootFolder to the session output folder:
rootFolder = FileHandler.joinPaths(this.tilesetSessionsDir, 'output', safeSessionId);
TilesetImagePersister.ensureOutputImages(rootFolder, safeSessionId, this.tilesetSessionsDir);
handlerParams.rootFolder = rootFolder;
So the compositeFileNames value house-composite loads house-composite.json from that folder.
Step C - Wire the tent-001 door in composite.json
The analyzer UI cannot author a door: its layer type options are below-player, collisions, over-player, collisions-over-player, base, path and a free-text custom suffix, with no UI for the door properties. Add the two door layers by hand to the layers array of the session composite.json. In the reference file the house-01-return-point layer is listed before house-01-change-points, so add them in that order.
The change-points layer
- The name must contain change-points and start with the element ID tent-001, so it joins the tent-001 group. The layer is recognised by the substring (ElementLayerWriter.updateLayerChangePointsData() checks layer.name.indexOf('change-points')). Since change-points is not one of the element layer types, the group falls back to the first two dash segments of the name: tent-001.
- data must hold exactly 27 x 45 = 1215 integers, all 0 except a single non-zero gid inside the tent footprint (zero cells are skipped). The index of that cell is doorRow * 27 + doorCol. The gid value is cosmetic, only non-zero matters.
- compositeFileNames must be house-composite to bind the door to the interior.
- The properties mirror the reference house-01-change-points block. No reference change-points layer has a subMapName property, so do not add one: without it the sub-map name is the generated change-point key (see Step D).
{
"name": "tent-001-change-points",
"type": "tilelayer",
"visible": true,
"opacity": 1,
"x": 0,
"y": 0,
"width": 27,
"height": 45,
"id": 9001,
"data": [ /* 1215 entries (27*45); all 0 except one non-zero gid at the tent door cell, index = doorRow*27 + doorCol */ ],
"properties": [
{ "name": "blockMapBorder", "type": "bool", "value": true },
{ "name": "compositeFileNames", "type": "string", "value": "house-composite" },
{ "name": "elementTitle", "type": "string", "value": "Tent 1" },
{ "name": "entryPosition", "type": "string", "value": "down-left" },
{ "name": "entryPositionSize", "type": "int", "value": 2 },
{ "name": "upperFloors", "type": "int", "value": 1 }
]
}
The return-point layer
- Add a paired tent-001-return-point layer (the name must contain return-point) with a single position property and one non-zero cell where the player lands when leaving the interior.
- In the reference, the house-01-return-point cell sits one row south of the house-01-change-points cell, matching position=down. Here the index is (doorRow + 1) * 27 + doorCol.
- The marker must not be at the local index 0 of the element (the top-left cell of its cropped bounding box), or it is skipped.
- position is read from the layer properties and defaults to down:
let returnPointPosition = sc.get(
sc.fetchByProperty(layer?.properties, 'name', 'position'),
'value',
'down'
);
{
"name": "tent-001-return-point",
"type": "tilelayer",
"visible": true,
"opacity": 1,
"x": 0,
"y": 0,
"width": 27,
"height": 45,
"id": 9002,
"data": [ /* 1215 entries (27*45); all 0 except one non-zero gid one row below the door cell, index = (doorRow+1)*27 + doorCol */ ],
"properties": [
{ "name": "position", "type": "string", "value": "down" }
]
}
Optional - multiple floors
Add an upperFloors and/or downFloors int property to the change-points layer; each value greater than 0 generates that many extra floors, all reusing compositeFileNames (house-composite). The block above sets upperFloors=1, like the reference house-01; house-02 uses upperFloors=2. The reference house-03 layers also carry downFloorCompositeFileNames / upperFloorCompositeFileNames, but the generator does not read those names (in Reldens only CompositeSampleFilesProvider reads them, to copy the referenced composites), so floors always reuse compositeFileNames and only the upperFloors / downFloors counts have an effect.
Step D - Generate in the Maps Wizard
- Open the wizard for this session without regenerating: in the Tileset Analyzer sessions list, use the per-row "Maps Wizard" button. It only appears when the session contains a map-generator-config*.json file, and it navigates to /reldens-admin/maps-wizard?tilesetSessionId={sessionId} without generating. Never use "All to Maps Wizard" or "Selected to Maps Wizard" here: they run the generate step before navigating and overwrite your hand-edited composite.json.
- Select the associations strategy. The strategy saved in the session (elements-composite-loader in this example) pre-selects option 2, so click option 4 manually: "Generate MULTIPLE random maps with Layer Elements Composite Loader (with associations)" (value multiple-with-association-by-loader). If the session holds a saved wizard configuration, its saved strategies are preloaded and option 4 fills "Generator Data" with the saved association configuration; otherwise "Generator Data" is built from the option 4 inputs and their template defaults.
- Click "Configuration Options" for option 4 and point it at the session's own composite, not the shipped example: "Composite Elements File" must read composite.json (without a saved configuration it keeps the template default reldens-town-composite-with-associations.json). Replace "Maps Information (JSON)" with a single town entry (the default holds town-001 to town-004) and set "Associations Properties (JSON)" to the block below.
- Optional: click "Save configuration" (only visible when the page was opened with a tilesetSessionId) to store this configuration in the session map-generator-config.json. This overwrites the saved current strategy of the session, from elements-composite-loader to multiple-with-association-by-loader.
- Click "Generate". The wizard checks whether rooms with these map names already exist, shows a confirmation, and submits; the server runs MultipleWithAssociationsByLoaderGenerator through MapsWizardRunner.run().
Configuration Options values:
Composite Elements File = composite.json
[ { "mapName": "reldens-town", "mapTitle": "Reldens Town" } ]
{
"generateElementsPath": false,
"blockMapBorder": true,
"freeSpaceTilesQuantity": 0,
"variableTilesPercentage": 0,
"placeElementsOrder": "inOrder",
"orderElementsBySize": true,
"randomizeQuantities": false,
"applySurroundingPathTiles": false,
"automaticallyExtrudeMaps": true
}
- Edits in the modal only reach "Generator Data" through the input change event (or when the modal is closed); without it the textarea keeps the saved value.
- Both JSON textareas are parsed as JSON. Invalid JSON is kept as a raw string and is not applied as an object.
"Generator Data" holds the full option 4 object, rebuilt from the extra properties, every common input and every option 4 input. You only edit compositeElementsFile and mapsInformation. This excerpt shows the keys that matter, not the literal submitted payload:
{
"factor": 1,
"blockMapBorder": true,
"freeSpaceTilesQuantity": 2,
"variableTilesPercentage": 15,
"collisionLayersForPaths": ["change-points", "collisions", "tree-base"],
"compositeElementsFile": "composite.json",
"mapsInformation": [ { "mapName": "reldens-town", "mapTitle": "Reldens Town" } ],
"associationsProperties": {
"generateElementsPath": false,
"blockMapBorder": true,
"freeSpaceTilesQuantity": 0,
"variableTilesPercentage": 0,
"placeElementsOrder": "inOrder",
"orderElementsBySize": true,
"randomizeQuantities": false,
"applySurroundingPathTiles": false,
"automaticallyExtrudeMaps": true
}
}
- The surface is optimized at factor 1 (the common factor input).
- The option 4 inputs have no tileSize key (only the object loader strategy has one): the surface tile size 32 follows from the 32x32 tilesets of composite.json, because the tile size is taken from the optimized map tilewidth and written as the generated map tilewidth / tileheight.
- Each generated map is an independent Tiled map with its own optimized tileset: every interior runs a new RandomMapGenerator, so the 16x16 interior is optimized separately and stays 16x16.
- collisionLayersForPaths must include change-points so paths route around the door. Matching is by substring, so it matches the renamed surface layer tent-0010-change-points. The default change-points,collisions,tree-base comes from the common input.
Verify the generated output
The run writes the town surface map, one interior sub-map per wired door instance, and one file per extra floor:
- generated/reldens-town.json and reldens-town.png - the surface.
- generated/reldens-town-tent-001-n0.json - the tent interior.
- generated/reldens-town-tent-001-n0-upperFloor-n1.json - the floor added by upperFloors=1.
With no subMapName property on the change-points layer, the sub-map name is the generated change-point key, built by ElementLayerWriter.provideElementKey():
let elementKey = mapPrefix.toString();
let elementNameClean = elementData.name.replace('-change-points', '').replace('-return-point', '');
let isStairsElement = -1 !== elementData.name.indexOf('stairs');
if(!isStairsElement){
return elementKey + '-' + elementNameClean + '-n' + elementNumber;
}
- elementData.name is the original composite layer name (tent-001-change-points), so the cleaned element name is tent-001. Only the layer written on the generated map is renamed to the fused tent-0010-change-points.
- elementNumber is the placement index (0 for the first tent while "Randomize Quantities" stays at its default "No"), so the key is reldens-town-tent-001-n0. Sub-maps are always named <map>-<element name>-n<N> (for example town-001-house-01-n0), and floor suffixes are -upperFloor-n<N> / -downFloor-n<N>.
- AssociatedMaps.generate() finds the door layer by the recorded fused layer name, reads compositeFileNames, loads house-composite.json from the session folder and generates the interior.
- If generated/ only shows reldens-town.* and no *-tent-001-n0.json, the door tile is missing from the change-points layer footprint, or house-composite.json / inside.png are not in the session folder.
Before importing, you can adjust any generated map, the surface or an interior, with the "Edit Map Elements" button of the Maps Elements Editor.
Step E - Import
- On the maps selection page shown after generation, tick the town map (reldens-town). Its interiors are listed under "Associated maps generated" with no checkbox of their own: they are imported automatically with the parent map.
- Click "Import Selected Maps" and confirm. An overlay is shown while the import runs.
- Restart the game server: new maps are not hot-plugged. Refreshing the selection page instead generates a new random set.
What the import does:
- Creates one rooms row per map (name, title, map_filename as <name>.json, scene_images, customData) in MapsImporter.createRoomByMapTitle().
- Creates the roomsChangePoints rows (room_id, tile_index, next_room_id) and the roomsReturnPoints rows (room_id, direction, x, y, is_default, from_room_id) from the change-point-for- / return-point-for- layer properties, through RoomsAssociationsCreator (lib/import/server/rooms-associations-creator.js).
- Copies the map JSON files and tilesets from generate-data/generated to the project theme assets/maps/ folder and to dist/assets/maps/.
- Imports the interiors recursively. The hidden "import associations" fields of the selection page render as 0 for this strategy, but the import recomputes both as true from the handler in SelectedMapsImportRunner.mapGeneratedMapsDataForImport():
let handlerWithAssociations = 'multiple-with-association-by-loader' === data.generatedMapsHandler;
let importAssociations = handlerWithAssociations
|| 1 === Number(sc.get(data, 'importAssociationsForChangePoints', 0));
If a room with the same name already exists, delete or rename it first: MapsImporter.loadValidMaps() stops the import with the error code mapExists, even though the check before generating only warns.
Generator side: the example scripts
The @reldens/tile-map-generator package ships the same flow as Node.js example scripts. All the files live in examples/layer-elements-composite/.
Example files
- generate-with-loader-multiples-with-associations.js - the canonical "town with explorable inner houses" script: creates a MultipleWithAssociationsByLoaderGenerator and calls generate().
- map-composite-data-with-associations.json - the data config: generation options, the mapsInformation list of towns (town-001 to town-004), the associationsProperties block and compositeElementsFile.
- reldens-town-composite-with-associations.json - the town surface composite: one Tiled JSON with the ground / path layers plus the house element layer groups and their per-house change-points / return-point layers.
- house-composite.json - the house interior composite: room, bed, table, walls and stairs element groups, with its own stairs-up / stairs-down change-points and return-point layers so an interior can chain to further floors.
- Surface tileset images: outside.png, terrain.png, house.png, doors.png, water.png. Interior tileset image: inside.png (embedded in house-composite.json and also pre-embedded in the town composite).
- generate-multiples-with-associations.js - a manual (no loader) equivalent: for each town it runs RandomMapGenerator directly, then creates AssociatedMaps and calls associatedMaps.generate(...). It hard-codes mapsInformation and the town options inline.
The manual script and the loader data config differ in four town options, so their output is not identical, only the pipeline is. The associationsProperties block is identical in both:
- expandElementsSize: 1 in the manual script, unset (default 0) in the loader config.
- collisionLayersForPaths: ['change-points', 'collisions'] manual, ['change-points', 'collisions', 'tree-base'] loader.
- freeSpaceTilesQuantity: 1 manual, 2 loader.
- freeTilesMultiplier: 4 manual, 2 loader.
Related examples and how they differ
- examples/layer-elements-object/ feeds separate element files (house-001.json, house-002.json, tree.json) plus a flat tilesheet.png into a single RandomMapGenerator. generate.js builds the options inline; generate-with-loader.js moves the same config into map-data.json and loads it with LayerElementsObjectLoader. Both produce one map and no interiors.
- examples/layer-elements-composite/ takes a pre-laid-out composite Tiled map and splits it back into placeable elements through RandomMapGenerator.fromElementsProvider:
- Single map, inline composite: generate.js (requires reldens-town-composite.json directly).
- Single map, loader driven: generate-with-loader.js + map-composite-data.json.
- Multiple named maps, manual loop: generate-multiples-with-names.js.
- Multiple named maps, loader driven: generate-with-loader-multiples-with-names.js + map-composite-data-with-names.json (uses MultipleByLoaderGenerator and reads mapNames).
- Multiple maps with interiors (associations): generate-with-loader-multiples-with-associations.js (canonical) and generate-multiples-with-associations.js (manual).
- Dungeon: generate-with-loader-dungeon.js + map-composite-data-dungeon.json (cave and wall generation).
The association flow is the only one that produces interior sub-maps, because it is the only one that runs AssociatedMaps.generate(). The dungeon example does not produce interiors even though reldens-dungeon-composite.json contains house-*-change-points layers, because it uses a single RandomMapGenerator and never calls AssociatedMaps.
Requirements
There is no build step. Four runtime dependencies are needed:
- @reldens/tile-map-optimizer - its TileMapOptimizer merges the source tilesets into one optimized tilesheet PNG plus a re-indexed map JSON. Without it the multi-tileset composite cannot be reduced and placed. See Tile Map Optimizer.
- @reldens/utils - Logger, the sc shortcuts, and the SchemaValidator that validates the composite data against MapCompositeDataSchema.
- @reldens/server-utils - FileHandler, which reads the JSON inputs, creates the generated / optimized folders, copies the tilesheet and writes the output maps.
- pathfinding - Grid and AStarFinder, used for path routing and connectivity validation between placed elements.
All the inputs must sit in examples/layer-elements-composite/, because that folder is the rootFolder (__dirname) and the optimizer and the loader resolve every input relative to it: the data config, the town composite (named by compositeElementsFile), the interior composite (named by the compositeFileNames property of each door layer, loaded as rootFolder + name + '.json'), and the six tileset PNGs. A missing or misplaced PNG breaks the optimization.
How to run
Each example calls execute() when run directly, so it is run with Node from the package root (for example a clone of the tile-map-generator repository):
npm install
node examples/layer-elements-composite/generate-with-loader-multiples-with-associations.js
The manual equivalent:
node examples/layer-elements-composite/generate-multiples-with-associations.js
The same scripts can be run through the package command, which also prints the path of every generated map (see Tile Map Generator):
npm run reldens-generate-map -- --composite --multiple-with-associations --with-loader
The working directory does not affect asset resolution because the examples set rootFolder = __dirname; the output is created in examples/layer-elements-composite/generated/.
Generation procedure step by step
- The script creates MultipleWithAssociationsByLoaderGenerator({loaderData: {rootFolder: __dirname, mapDataFile: 'map-composite-data-with-associations.json'} }) and calls generate().
- generate() creates a LayerElementsCompositeLoader(loaderData) and awaits loader.load(). The loader reads the data config (factor, mainPathSize, blockMapBorder, collisionLayersForPaths, the four towns in mapsInformation, associationsProperties and compositeElementsFile).
- LayerElementsCompositeLoader.loadPayload() only reads the compositeElementsFile into tileMapJSON, when it was not already provided. The schema validation against MapCompositeDataSchema, the rootFolder assignment and the tileMapJSON attachment happen in the base LayerElementsLoader.load() flow.
- The generator reads loader.mapData.mapsInformation and starts the loop over the towns.
- For each town it computes previousMainPath (the prior town generatedMainPathIndexes, only when that town hasAssociatedMap is false, otherwise []), creates a fresh RandomMapGenerator, deep-clones loader.mapData (each generator mutates its incoming tileMapJSON), sets mapName and previousMainPath, adds the mapTitle map property and awaits generator.fromElementsProvider(mapData).
- Surface preparation: ElementsProvider.splitElements() runs optimizeMap() (the TileMapOptimizer on the town composite), then fetchPathTiles() scans the optimized tileset tile properties to derive the path tile, the ground tiles, the border tiles, the ground spots and the surrounding / corner tiles.
- ElementsProvider.splitByLayerName() groups the layers by element name (for example house-01, house-02, house-03, trees), skips the special layers, reads the per-element quantity, freeSpaceAround, allowPathsInFreeSpace and mapCentered, and crops each group to its minimum bounding box.
- MapDataMapper.fromProvider(...) assembles the full generation options (map and file name, tilesheet geometry, cropped layer elements, quantities, tile keys), and RandomMapGenerator.resetInstance(...) parses them with setOptions, validates them with OptionsValidator and wires every sub-instance.
- Surface generation: RandomMapGenerator.generate() generates the spots first (they may inject elements that change the map size), builds the empty grid, blocks the map border when blockMapBorder is true, and initializes the main path. The first town gets a random main path; a town that consumes a previous path gets an opposite main path built by MainPathMirror, which also sets hasAssociatedMap = true. The mirror resolves the previous path edge against the previous map size, moves it to the opposite edge on the first walkable row or column of the current map (the border itself when isBorderWalkable is true), keeps its position along the edge inside the current map, and returns the border cells as generatedMainPathIndexesBorder so the opening is painted up to the map edge.
- Element placement: elementsPlacer.placeElements() creates one empty template layer per element sub-layer under its authoring name (for example house-01-collisions, house-01-change-points, house-01-return-point, house-01-over-player). The authoring house-01-path layer is not among them: any element layer whose name contains path is renamed to path and merged into the shared path layer before placement. Each instance is placed via PositionFinder, and ElementLayerWriter.updateLayerData writes the tiles into per-instance layers whose name fuses the instance number into the element key: instance 0 of house-01 writes into house-010-collisions, house-010-change-points, house-010-return-point and house-010-over-player. The empty templates are filtered out at the end.
- Door recording: while writing each element, every layer whose name contains change-points records an entry in generator.generatedChangePoints, keyed for example town-001-house-01-n0 (map prefix, the authoring element name, -n and the 0-based placement index). The entry stores {elementData, targetLayerName, tileIndex, mapIndex, elementNumber, x, y}, where targetLayerName is the fused written layer name (house-010-change-points). The matching *-return-point layers are recorded with their position. The door properties (compositeFileNames, entryPosition, upperFloors, etc.) are not stored in this record: the interior generation re-reads them from the change-points layer of the generated surface map.
- Surface finish: executePathsConnection() routes the A* paths (honoring collisionLayersForPaths, which includes change-points), applyVariations() scatters the ground variations, mapLayersComposer.generateLayersList() composes and merges the final layers, the Tiled JSON is built, the optimized tilesheet PNG is copied next to the output, and the surface map is written to generated/<mapName>.json.
- Interior generation: after the surface map is written, the generator creates AssociatedMaps and calls associatedMaps.generate(generatedMap, mapName, rootFolder, associationsProperties, generator). It validates its inputs and returns false without generating anything when associationsProperties carries dryRun.
- For each recorded change point, AssociatedMaps.generate() finds the written change-points layer of the surface map by targetLayerName (falling back to elementData.name), reads all its properties, and skips the entry when there is no compositeFileNames property.
- loadTileMapJSON reads the interior composite named by compositeFileNames (a comma-separated list picks one at random) as rootFolder + name + '.json'. The sub-map name is the change point key when the door layer has no subMapName property (for example town-001-house-01-n0); with a subMapName property it is mapName + '-' + subMapName + '-n' + elementNumber.
- The interior options merge the town resolved options, the associationsProperties block, the loaded interior tileMapJSON, the sub-map name and rootFolder, all the door layer properties, and a forced reset of {generatedChangePoints: {}, previousMainPath: [], previousMapSize: {}, previousMapName: '', nextMapName: '', mainPathSize: 0}. generator.fromAssociation(...) then re-runs the whole optimize, split and map pipeline on the interior composite: the interior is an independent generation, not a slice of the surface. The stairs-up / stairs-down quantities are set to 1 whenever upperFloors / downFloors are greater than 0. entryPosition and entryPositionSize come from the door layer, and entryPositionFrom is set to the source town mapName.
- The interior is generated by the same RandomMapGenerator.generate() pipeline and written to generated/<sub-map name>.json (for example generated/town-001-house-01-n0.json). Its own change-points / return-point layers (the stairs and the exit back to town) are recorded the same way.
- Multiple floors: when upperFloors / downFloors are greater than 0, AssociatedMaps.generateFloors recurses, creating one more RandomMapGenerator per floor with a suffixed name (for example town-001-house-01-n0-upperFloor-n1 or town-001-house-03-n0-downFloor-n1), toggling the stairs quantities and passing previousFloorData so ElementsPlacer.prePlaceStairs aligns each floor stairs onto the previous floor stairs. Each floor writes its own JSON.
- The loop continues with the remaining doors (more interiors), then the town loop advances.
- Town chaining (default pair mode): the next town receives the previous map size and, as previousMainPath, this town generatedMainPathIndexes, only when this town hasAssociatedMap is false. Because consuming a previous path sets hasAssociatedMap = true, the chaining alternates random and opposite paths, so town-001 / town-002 and town-003 / town-004 share an edge opening. Pair mode writes no change points for these openings.
- Level chaining (chainMainPaths: true in the loader map data): every map receives previousMapName, previousMapSize, the previous map generatedExitMainPathIndexes as previousMainPath, and nextMapName (empty for the last map). A map with a previous map mirrors its entry from the previous exit and, when it has a next map, places its own exit main path on one of the other three edges (MainPathGenerator.placeExitMainPath); the first map main path is its exit. PathConnector.connectExitMainPath routes the exit start to the entry start. MainPathLinksWriter then cuts each opening out of the collisions border (and out of the border inner walls on the top edge), marks it walkable and writes the main-path-links-change-points layer, with change-point-for-{target map} on every opening border cell and one return-point-for-{target map} one tile inside the middle of the opening. The entry return point (or the exit one on the first map) carries return-point-isDefault-{target map}, and chained maps do not write the return-point-for-default-{map} main path return point. The link change points are kept in generatedMainPathLinksChangePoints, never in generatedChangePoints, so AssociatedMaps does not treat them as doors. Auto grow only grows the map bottom, so give a chained map an explicit mapSize: a bottom edge opening placed before a grow ends up above the new bottom border and a critical error is logged.
Town map options
Options reach the generator through fromElementsProvider(props), MapDataMapper.fromProvider, resetInstance and setOptions, where each value is read with sc.get(options, 'name', default). A few options (factor, freeTilesMultiplier, expandElementsSize, minimumDistanceFromBorders) are also read independently by ElementsProvider.
- factor - tile density / optimization scale factor passed to the optimizer; controls element splitting and optimization scaling and the post-generation cleanup. Default 1 (example 2).
- mainPathSize - width of the generated main path; 0 disables the random main path. Default 0 (example 3).
- blockMapBorder - blocks and encloses the outer map border (adds 1 to width and height and marks the border non-walkable). Default false (example true).
- freeSpaceTilesQuantity - free buffer tiles reserved around elements when computing the map size, combined with freeTilesMultiplier. Default 0 (example 2).
- freeTilesMultiplier - multiplier applied to freeSpaceTilesQuantity when sizing the map. Default 1 (example 2).
- minimumElementsFreeSpaceAround - minimum guaranteed empty tiles around each placed element. Default 0 (example 1).
- minimumDistanceFromBorders - minimum tiles between placed elements and the map borders; also adds twice the value to the map width and height. Default 1.
- variableTilesPercentage - percentage of ground / path tiles that receive random ground variation tiles. Default 0 (example 15).
- collisionLayersForPaths - layer names treated as obstacles when building the pathfinding grid, so paths route around them. Default [] (example ['change-points', 'collisions', 'tree-base']).
- previousMainPath - the previous map main path indexes, used to generate an opposite / aligned main path so adjacent towns connect. In pair mode it is set only when the previous map has no associated map; with chainMainPaths it is the previous map exit main path. Default [].
- previousMapSize - {mapWidth, mapHeight} of the previous map, used to resolve the edge of previousMainPath; when empty the current map size is used. Default {}.
- chainMainPaths - loader map data option: links every map to the previous and the next one through an entry and an exit main path with change and return points, and sets previousMapName / nextMapName on every map. Default false.
- previousMapName / nextMapName - the linked neighbour map names used in the change-point-for-* and return-point-* link properties; a non-empty nextMapName makes the map place an exit main path. Default ''.
- expandElementsSize - tiles to pad each cropped element layer outward. Read only by ElementsProvider, not by setOptions; used only by the manual script. Default 0 (manual script 1).
- mapName - name of the map being generated, also used for the generated JSON file name; set per map from mapsInformation.
- mapTitle - human-readable title, added as a custom Tiled map property and later used to build the sub-map and floor titles; taken from each mapsInformation entry.
- mapsInformation - required array of {mapName, mapTitle}, one town per entry (validated non-empty). The example has town-001 to town-004.
- compositeElementsFile - file name of the town composite JSON, loaded into tileMapJSON. Default false (example reldens-town-composite-with-associations.json).
- tileMapJSON - in-memory composite map object, an alternative to compositeElementsFile (the manual script passes a deep copy). Only ElementsProvider (default null) and the composite loader (default false) read it.
- rootFolder - base folder for reading the composites and tilesheets and writing the output; generatedFolder defaults to rootFolder/generated. In the loader it comes from loaderData.rootFolder.
- mapDataFile (inside loaderData) - required file name of the data config itself; the load fails without it. Example map-composite-data-with-associations.json.
associationsProperties (interior maps)
These keys are merged into the options of every interior and floor generator, read by the same setters, and only affect the interior maps. AssociatedMaps always forces mainPathSize: 0, previousMainPath: [], previousMapSize: {}, empty previousMapName / nextMapName and generatedChangePoints: {} for every interior.
- generateElementsPath - generate connecting paths between the interior elements. Default true (example false).
- blockMapBorder - blocks and encloses the interior border. Default false (example true).
- freeSpaceTilesQuantity - free buffer tiles around elements for the interior size. Default 0 (example 1).
- minimumElementsFreeSpaceAround - minimum empty tiles around interior elements. Default 0 (example 0).
- minimumDistanceFromBorders - minimum tiles between interior elements and borders, also adds twice the value to the interior size; 0 keeps interiors tightly sized. Default 1 (example 0).
- variableTilesPercentage - percentage of ground variation tiles on the interior. Default 0 (example 0).
- placeElementsOrder - inOrder places each element in the first available position, random scatters them. Default random (example inOrder).
- orderElementsBySize - places the elements sorted by size, largest first. Default true (example false).
- randomizeQuantities - shuffles the flattened element list before placement; meant to be false when orderElementsBySize is true. Default false (example true).
- applySurroundingPathTiles - applies the surrounding / transition path tiles around generated paths. Default true (example false).
Authoring a town composite in Tiled
A town with inner houses needs one outside (town) composite map plus one or more inside composite maps (for example house-composite.json). The generator splits the town map back into placeable elements, so the layer names and the tile custom properties are the authoring contract. See Create a Room / Map for the general Tiled conventions.
Element layer naming
- ElementsProvider.splitByLayerName() splits each layer name on -. A non-special element layer must have at least three parts, otherwise it is rejected with Invalid layer name ... Expected: [elementName]-[index]-[layerName].
- The element group key is always the first two parts (MapNaming.fuseGroupName), and the rest is the role suffix: house-01-change-points groups as house-01, tree-02-base as tree-02. Plan names so the first two segments are the intended group: bed-side-01-... groups as bed-side, not bed-side-01.
- Role suffixes in the town file include base, collisions, collisions-over-player, over-player, shadow, path, change-points and return-point. Interiors also use background, background-collisions and a variation-NN segment after the index (for example bed-side-01-variation-03-collisions, still grouping as bed-side).
- The layers written at generation time fuse the placement instance number into the element key (ElementLayerName.build): element tree plus tree-collisions plus instance 0 becomes tree0-collisions, and house-01 instance 0 becomes house-010-change-points. The plain authoring names are only the template names.
- Any element layer whose name contains path anywhere is renamed to path and merged into the shared path layer. Avoid path in unrelated role names, or that layer collapses into the path.
- change-points, return-point and collisions are not special layer names: they are ordinary role suffixes and still need the full {name}-{index}-{role} form.
- A layer whose name contains both spot-layer- and ground-variations- is parsed as element variation tiles (the tiles key is the name with both substrings removed).
- The group properties quantity (how many are scattered), freeSpaceAround, allowPathsInFreeSpace and mapCentered are read from any one layer of the element. Put quantity on a single layer (typically -base); duplicating it on several layers just overwrites. This rule does not apply to the door properties (see below).
Reserved layer names
The special layers are skipped from element grouping and must not contain element tiles:
- ground - base ground fill.
- path - the main path network.
- ground-variations - its non-zero tiles become the random ground variation tiles.
- borders - the map border tiles.
- tileset-ref - reserved reference layer.
Separately, ElementsFromLayersLoader.skipLayerNames is ['ground', 'ground-variations', 'borders', 'change-points'], and with collisionLayersForPaths set to layers like change-points, collisions and tree-base, the tiles of *-collisions and *-change-points layers block the A* pathfinding.
Tileset tile custom properties
Tile properties are read from the single optimized tileset, but because the optimizer first merges all the source tilesets, the key and groundSpots properties can be authored on tiles of any source tileset. Supported key values:
- pathTile - the path tile.
- groundTile - the base ground tile; additional groundTile tiles accumulate into a ground variation pool.
- border-<suffix> - hard border tiles (for example border-top, border-bottom-right, border-left).
- corner-<side> - corner tiles (corner-top-left, corner-top-right, corner-bottom-left, corner-bottom-right).
- The nine surrounding values top-left, top-center, top-right, middle-left, middle-center, middle-right, bottom-left, bottom-center, bottom-right - the surrounding tiles for path and spot variations.
- Spot-prefixed surrounding / corner keys - when the value splits into three parts (surrounding) or four parts (corner), the first part is a spot key with its own mapper (for example respawnPunchTrees-top-left or respawnPunchTrees-corner-top-left).
The groundSpots property is a comma-separated list of spot keys; each maps that tile as the spot anchor.
For house interiors, the inside tileset also defines wall-* keys, each paired with a variation int (1 or 2): wall-top-left, wall-top, wall-top-right, wall-middle-left, wall-center, wall-middle-right, wall-bottom-left, wall-bottom-center, wall-down-right. They drive the interior wall placement and are distinct from the path borders.
Wangsets are standard Tiled corner-type wangsets authored over the path / ground transition tiles and consumed by the wangset and walls mappers. terrain, outside, house, doors, water and inside are tileset names (image file basenames), not Tiled terrains. reldens-town-composite-with-associations.json and house-composite.json contain no wangsets and no groundSpots; those features are shown in the sibling reldens-town-composite.json (a corner-type wangset named path, and a tile carrying both groundSpots: "respawnPunchTrees" and key: "groundTile").
Linking a house to its interior
- An element instance becomes a door to an inside map only if it has a {elementName}-{index}-change-points layer whose properties include a compositeFileNames string. Change points without it are skipped.
- compositeFileNames is the inside composite file name without the .json extension; a comma-separated list is allowed and one entry is picked at random, then resolved as rootFolder + name + '.json'.
- Pair the door layer with a one-tile {elementName}-{index}-return-point layer carrying a position string property (for example down or up) that marks where the player re-emerges outside; position defaults to down.
- The door properties are read only from the written change-points layer matched by the recorded targetLayerName (the fused per-instance layer, which carries the authoring layer properties). In reldens-town-composite-with-associations.json the same door properties are also duplicated on other layers of the same house (for example house-03-shadow, with elementTitle: "House"), but those copies have no effect.
- elementTitle builds the sub-map title (mapTitle + ' - ' + elementTitle + '-' + elementNumber), entryPosition / entryPositionSize define where the player enters the interior, and blockMapBorder encloses the interior.
- A house can also include a {name}-{index}-path layer to carve an approach path; it merges into the shared path layer.
Door property sets in the town composite:
- house-01-change-points: blockMapBorder=true, compositeFileNames="house-composite", elementTitle="House 1", entryPosition="down-left", entryPositionSize=2, upperFloors=1.
- house-03-change-points: blockMapBorder, compositeFileNames="house-composite", downFloorCompositeFileNames="house-composite", downFloors=1, elementTitle="House 3", entryPosition="down-right", entryPositionSize=2, upperFloorCompositeFileNames="house-composite", upperFloors=1.
- house-01-return-point: a single non-zero tile with position="down".
Multiple floors: add upperFloors / downFloors (int) on the outside door layer; they drive how many floors are generated. The inside composite must supply the stairs elements with their own change-points and return-point layers (for example stairs-up-base, stairs-up-collisions, stairs-up-change-points, stairs-up-return-point, plus stairs-down-* and side-stairs-down-*). In the shipped house-composite.json both stairs-up-return-point and stairs-down-return-point carry position: "down". The inside composite also needs a top-level map property position (defaults to down). The upperFloorCompositeFileNames / downFloorCompositeFileNames properties are not read by the generator: every floor reuses compositeFileNames.
Output files
- The output root is generatedFolder = rootFolder/generated.
- Surface maps: generated/<mapName>.json (for example generated/town-001.json to generated/town-004.json), with the optimized tilesheet PNG copied next to each JSON.
- Interior maps: generated/<mapName>-<element name>-n<N>.json (for example generated/town-001-house-01-n0.json), with -upperFloor-n<N> / -downFloor-n<N> suffixes for the floors.
- Intermediate optimized files are written to generated/optimized. By default removeOptimizedMapFilesAfterGeneration is true, so the optimized-*-elements files are deleted and the folder is removed when empty. Pass removeOptimizedMapFilesAfterGeneration: false to keep them for inspection.
- Single-map examples that do not pass a mapName write random-map-<timestamp>.json plus its PNG, so their output name changes on every run. The associations example uses the mapsInformation names, so its file names are stable.
- Debug files (test-*.json) are only written when debugPathsGrid or shouldDebugAdjacentSpots are enabled; the town examples leave both off.
Related Documentation
- Maps Wizard - generate and import maps from the admin panel.
- Maps Elements Editor - adjust the generated surface and interiors before or after import.
- Tileset to Tilemap - build the town session in the Tileset Analyzer.
- Tile Map Generator - package overview and the generate map command.
- Generate Multiple Maps with Associations - strategy reference.
- Tile Map Generator - Internal Flow - from the admin UI to the generated map.
- Tile Map Optimizer - the tileset optimization step.
- Create a Room / Map - Tiled conventions for rooms and maps.
- Rooms Entity - the rooms, change points and return points created by the import.
reldens