Defining Bundle Tiles

Define tile registry entries in Selene bundles, connect them to visuals, and use tile flags for map collision.

Defining Bundle Tiles

Tile definitions describe reusable map tiles: terrain, floors, walls, overlays, items placed as tiles, transition edges, and any other map layer entry that should be synchronized by tile id.

Tiles live in the common tiles registry. The server uses them for map storage and collision. The client uses the same definitions to resolve visuals and render received map chunks.

If you are defining automatic terrain edges or corner overlays, see Defining Bundle Tile Transitions.

Where tiles live

Put tile definitions under common/data/<namespace>/, where <namespace> becomes the namespace part of each tile identifier.

You can define multiple tiles in one registry file:

my-bundle/
  bundle.json
  common/
    data/
      myworld/
        tiles.json

Or split each tile into its own JSON file:

my-bundle/
  common/
    data/
      myworld/
        tiles/
          terrain/
            grass.json

The merged tiles.json form stores entries by local name. This defines myworld:terrain/grass:

{
  "entries": {
    "terrain/grass": {
      "visual": "myworld:terrain/grass"
    }
  }
}

The split-file form uses the file path as the local name. For example, common/data/myworld/tiles/terrain/grass.json also defines myworld:terrain/grass.

Tile identifiers

Tile identifiers use the same namespace:name format as other Selene registries:

myworld:terrain/grass
myworld:terrain/water
myworld:objects/stone_wall
illarion:tile/grass

The namespace comes from the directory below common/data/. The name comes from either the key inside tiles.json or the relative path of a file below tiles/.

Use stable tile names once maps, saves, transitions, scripts, or migration data refer to them. Saved maps store numeric ids, but those ids are populated from tile identifiers. Renaming a tile definition means updating every place that refers to it.

Tile shape

A tile definition has these fields:

{
  "visual": "myworld:terrain/grass",
  "impassable": false,
  "passableAbove": false,
  "metadata": {},
  "tags": []
}

visual is required. The other fields have defaults in the engine and can be omitted when the default is correct:

{
  "visual": "myworld:terrain/grass"
}

Fields:

  • visual is the client visual identifier used to render the tile.
  • impassable marks the tile as blocking movement.
  • passableAbove lets this tile make a coordinate passable when it is placed above a blocking lower tile.
  • metadata stores custom structured data that can be searched through selene.registries.
  • tags are strings used for filtering and classification.

Connecting visuals

Tiles reference visuals by identifier. The visual itself is client data, usually declared under client/data/<namespace>/visuals.json or client/data/<namespace>/visuals/**/*.json.

{
  "entries": {
    "terrain/grass": {
      "visual": "myworld:terrain/grass"
    }
  }
}
{
  "entries": {
    "terrain/grass": {
      "type": "variants",
      "textures": [
        "client/textures/myworld/terrain/grass_0.png",
        "client/textures/myworld/terrain/grass_1.png"
      ]
    }
  }
}

The tile and visual identifiers do not have to match, but matching them keeps data easy to scan. If the client cannot resolve the visual, the tile will have no renderable visual.

See Defining Bundle Visuals for the full visual definition format.

Collision flags

The server checks tile flags when resolving movement.

Use impassable: true for blocking terrain or objects:

{
  "entries": {
    "terrain/water": {
      "visual": "myworld:terrain/water",
      "impassable": true,
      "tags": ["myworld:liquid"]
    },
    "objects/stone_wall": {
      "visual": "myworld:objects/stone_wall",
      "impassable": true
    }
  }
}

Use passableAbove: true for upper-layer tiles that should override a blocking lower tile at the same coordinate. This is useful for bridges, transition overlays, or similar tiles that are placed above another tile:

{
  "entries": {
    "terrain/bridge": {
      "visual": "myworld:terrain/bridge",
      "passableAbove": true
    }
  }
}

Collision is evaluated in layer order:

  • If the base tile is missing or impassable, the coordinate starts as blocked.
  • Additional impassable tiles keep or make the coordinate blocked.
  • An additional tile with passableAbove: true makes the coordinate passable again.
  • Impassable entities at the coordinate can still block movement afterward.

Keep passableAbove for deliberate upper-layer overrides. Do not use it as a general substitute for impassable: false.

Metadata

Use metadata for bundle-specific ids, migration data, editor categories, or values that scripts need to inspect.

{
  "entries": {
    "terrain/grass": {
      "visual": "myworld:terrain/grass",
      "metadata": {
        "legacyTileId": 11,
        "biome": "plains"
      },
      "tags": ["myworld:terrain", "myworld:natural"]
    }
  }
}

The built-in registry implementation indexes metadata so Lua can find entries by a metadata key and value:

local Registries = require("selene.registries")

local grass = Registries.findByMetadata("tiles", "legacyTileId", 11)

Use metadata keys consistently. If you are importing from an older content pipeline, store the old id in metadata instead of baking it into the tile name unless the name is already meaningful.

Item and overlay tiles

Tiles are not limited to ground terrain. A bundle can define item-like or overlay-like map tiles in the same registry:

{
  "entries": {
    "items/apple": {
      "visual": "myworld:items/apple",
      "metadata": {
        "itemId": 15
      },
      "tags": ["myworld:item"]
    },
    "transitions/grass_to_dirt_north": {
      "visual": "myworld:transitions/grass_to_dirt_north",
      "passableAbove": true,
      "metadata": {
        "from": "myworld:terrain/grass",
        "to": "myworld:terrain/dirt"
      },
      "tags": ["myworld:transition"]
    }
  }
}

Separate large generated sets by using the split-file layout when that makes them easier to maintain:

my-bundle/
  common/
    data/
      myworld/
        tiles/
          terrain/
            grass.json
          items/
            apple.json
          transitions/
            grass_to_dirt_north.json

Each split file contains one tile definition, without an outer entries object. The identifier comes from the path below tiles/, so the examples above define myworld:terrain/grass, myworld:items/apple, and myworld:transitions/grass_to_dirt_north.

Using tiles from Lua

Server Lua APIs accept tile definitions from the tiles registry when placing, replacing, swapping, or removing map tiles.

local Registries = require("selene.registries")

local grass = Registries.findByName("tiles", "myworld:terrain/grass")
local wall = Registries.findByName("tiles", "myworld:objects/stone_wall")

local coordinate = { x = 100, y = 64, z = 0 }

mapTree:ReplaceTiles(coordinate, grass)
mapTree:PlaceTile({ x = 101, y = 64, z = 0 }, wall)

Client Lua can inspect tiles that are already present on the local map through map lookup APIs. The returned tile exposes its Definition, which is the same tile registry entry.

Practical conventions

  • Put shared tile definitions in common/data because both the server and client need them.
  • Put tile graphics in client visuals, then reference those visuals from tile definitions.
  • Keep terrain, item, object, and transition names grouped by path prefixes.
  • Use impassable for server gameplay collision, not just for visual height.
  • Use surfaceOffsetY or sorting fields on the visual when the tile only needs rendering adjustments.
  • Store legacy import ids in metadata so generated maps and migration tools can look them up.
  • Use tags for broad groups such as terrain, item, transition, liquid, wall, or harvestable.