Defining Bundle Visuals

Learn how to define client visual entries in Selene bundles and reference them from tiles, entities, and Lua code.

Defining Bundle Visuals

Visual definitions describe how Selene turns bundle assets into renderable client visuals. They live in client-side bundle data, usually next to the textures they reference, and are loaded into the visuals registry.

Use this guide when you are creating a new bundle that provides graphics, adding visuals for tiles or entities, or overriding existing visuals in a resource pack.

Where visuals live

Visuals are client data. Put them under client/data/<namespace>/, where <namespace> becomes the namespace part of each visual identifier.

You can define multiple visuals in one registry file:

my-bundle/
  bundle.json
  client/
    data/
      myworld/
        visuals.json
    textures/
      myworld/
        terrain/
          grass.png

Or split each visual into its own JSON file:

my-bundle/
  client/
    data/
      myworld/
        visuals/
          terrain/
            grass.json

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

{
  "entries": {
    "terrain/grass": {
      "type": "simple",
      "texture": "client/textures/myworld/terrain/grass.png"
    }
  }
}

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

Visual identifiers

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

myworld:terrain/grass
myworld:characters/guard
illarion:tile_cursor

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

Use stable, descriptive names. Tiles, entities, and scripts refer to these identifiers, so renaming a visual means updating every reference to it.

Texture paths

Texture paths are bundle-relative paths, such as:

client/textures/myworld/terrain/grass.png

The path is resolved from the loaded bundle files. If you are overriding an existing texture in a resource pack, keep the same internal path as the original bundle. If you are defining a new visual, choose a path that matches your bundle's own texture layout.

Simple visuals

Use simple for one static texture.

{
  "type": "simple",
  "texture": "client/textures/myworld/terrain/grass.png",
  "offsetX": 0,
  "offsetY": 0,
  "surfaceOffsetY": 0,
  "sortLayerOffset": 0
}

Common fields:

  • texture is the PNG or other supported texture asset to draw.
  • offsetX and offsetY move the texture relative to its render position.
  • surfaceOffsetY raises the visual surface for isometric stacking.
  • sortLayerOffset adjusts render order when the default sort is not enough.
  • flipX and flipY mirror the texture.
  • metadata stores custom data that systems or Lua code can inspect.

Most numeric and boolean fields have defaults in the engine, so you can omit them when the default value is correct:

{
  "type": "simple",
  "texture": "client/textures/myworld/terrain/grass.png"
}

Variant visuals

Use variants when several textures are equivalent alternatives. Selene picks a variant from the render context, which is useful for repeating terrain where identical tiles should not all look the same.

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

Variant visuals support the same offset, sorting, flipping, and metadata fields as simple visuals.

Animated visuals

Use animated for a fixed sequence of frames.

{
  "type": "animated",
  "textures": [
    "client/textures/myworld/effects/sparkle_0.png",
    "client/textures/myworld/effects/sparkle_1.png",
    "client/textures/myworld/effects/sparkle_2.png"
  ],
  "duration": 0.1,
  "instanced": true
}

duration is the frame duration in seconds. If instanced is false or omitted, Selene may share the animation state between visuals with the same identifier. Set instanced to true when each visual should animate independently.

Animated visuals support the same offset, sorting, flipping, and metadata fields as simple visuals.

Animator visuals

Use animator when a visual has named animations controlled by an animator component, such as idle, walk, attack, or death.

{
  "type": "animator",
  "animator": "character",
  "offsetX": 0,
  "offsetY": -24,
  "animations": {
    "idle": {
      "textures": [
        "client/textures/myworld/characters/guard_idle_0.png",
        "client/textures/myworld/characters/guard_idle_1.png"
      ],
      "duration": 0.2
    },
    "walk_south": {
      "textures": [
        "client/textures/myworld/characters/guard_walk_south_0.png",
        "client/textures/myworld/characters/guard_walk_south_1.png"
      ],
      "duration": 0.08
    }
  }
}

Each animation entry has:

  • textures for the animation frames.
  • duration for frame duration in seconds. If omitted, Selene uses a fast default.
  • offsetX and offsetY to override the base visual offsets for that animation.
  • flipX and flipY to mirror that animation's frames.

Animator visuals require a render context with an animator controller. They are usually attached through entity rendering components rather than created as standalone decorative visuals.

Text visuals

Use text for a visual backed by rendered text instead of a texture.

{
  "type": "text",
  "text": "Hello",
  "align": "CENTER"
}

align can be LEFT, CENTER, or RIGHT. Entity visual components can override the text value through their visual creation context.

Referencing visuals from data

Bundle data refers to visuals by identifier. For example, a tile definition can point at a visual:

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

Entity definitions and components follow the same pattern when they need a client visual. Keep the visual identifier in client-safe data when possible, and keep server-only gameplay data separate from client assets.

Creating visuals from Lua

Client Lua can create a visual by identifier through selene.visuals:

local visuals = require("selene.visuals")

local cursor = visuals.Create("illarion:tile_cursor")

This is useful for UI cursors, overlays, and custom client effects. For tiles and entities, prefer data-driven visual references so reloads and resource-pack overrides remain straightforward.

Resource pack overrides

Resource packs can override visuals in two ways:

  • Replace the texture file at the same internal path.
  • Replace the visual definition JSON and point it to a different texture or change offsets, animation frames, or sorting.

For example, to override myworld:terrain/grass from a merged registry file, mirror the original path:

global_client_bundles/
  my-resource-pack/
    client/
      data/
        myworld/
          visuals.json

If the original visual was split into client/data/myworld/visuals/terrain/grass.json, mirror that file path instead.

See Making Resource Packs for the broader override workflow.

Checklist

  • Put visual definitions under client/data/<namespace>/visuals.json or client/data/<namespace>/visuals/.
  • Put referenced texture assets under client/textures/.
  • Use simple for static textures, variants for interchangeable texture choices, animated for fixed frame loops, animator for named controlled animations, and text for rendered text.
  • Keep visual identifiers stable once other data references them.
  • Tune offsetX, offsetY, surfaceOffsetY, and sortLayerOffset in game, because these fields depend on the asset's pixel dimensions and the intended isometric footprint.
  • Test reload behavior with F5 or hotReload=true when iterating on bundle assets.