Interactable Texture Configuration
Configure textures for placeable, interactable objects like doors, chests, and furnaces.
Interactables are objects the player can place and interact with: doors, chests, furnaces, crafting benches, and any custom interactable added by a mod. Their textures are declared in a YAML config file, one per mod.
File Location
Config file:
mods/<your-mod>/assets/textures/interactables/interactables.yaml
Texture images go in the same folder:
mods/<your-mod>/assets/textures/interactables/<name>.png
The file name must match the name field exactly (case-sensitive), with spaces kept as spaces in the file name.
Minimal Example
textures:
- name: my_bench
sprites:
- name: idle
frame-count: 1
frame-width: 32
frame-height: 16
This registers an interactable named my_bench from my_bench.png. It will be referred to as YourMod:my_bench anywhere an interactable name is expected.
Full Field Reference
textures:
- name: my_door # Required. Registered as YourMod:my_door
override: Creation:door # Optional. Replace another mod's registered interactable (see below)
light-mask: my_door_mask # Optional. Light mask texture (see below)
frame-width: 16 # Required. Width of one frame in pixels
frame-height: 64 # Required. Height of one frame in pixels
sprites: # Required. One or more named animation sequences
- name: door_swing # Required. Animation name
frame-count: 2 # Required. Number of frames in this animation
frame-time-ms: 100 # Optional. Milliseconds per frame (default: 100)
frame-durations-ms: # Optional. Per-frame timing override (see below)
- count: 2
duration-ms: 150
states: # Optional. Named static poses
- name: door_closed # State name used by server scripts
sprite: door_swing # Which sprite this state uses
frame: first # first, last, or a frame number (0-based)
- name: door_open
sprite: door_swing
frame: last
| Field | Required | Description |
|---|---|---|
name | Yes | Identifier for this interactable. Registered as ModName:name. |
override | No | Full name of another mod’s interactable to replace (e.g. Creation:door). See Overrides. |
light-mask | No | Name of a separate PNG used as the light emission mask. See Light Masks. |
frame-width | Yes | Width of a single frame in pixels. |
frame-height | Yes | Height of a single frame in pixels. |
sprites | Yes | List of named animation sequences. At least one required. |
sprites[].name | Yes | Animation name, used by server scripts to trigger playback. |
sprites[].frame-count | Yes | How many frames this animation plays through. |
sprites[].frame-time-ms | No | Milliseconds per frame for uniform timing. Default: 100. |
sprites[].frame-durations-ms | No | Per-frame timing override. See Per-Frame Timing. |
states | No | Named static poses the server can snap the interactable to without animation. |
states[].name | Yes | State name, used by server scripts via entity:set_state(...). |
states[].sprite | Yes | Which sprite this state uses. |
states[].frame | Yes | first, last, or a zero-based frame index. |
Sprite Sheet Format
All sprites for an interactable share one sprite sheet image. The image is a grid:
- Columns — frames, read left to right. Total image width =
frame-width × column count. - Rows — sprites, stacked top to bottom. Total image height =
frame-height × sprite count.
The first sprite declared in the YAML corresponds to the top row; the second to the row below it, and so on.
[ var 0, frame 0 ][ var 0, frame 1 ][ var 0, frame 2 ] ← row 0 (first sprite)
[ var 1, frame 0 ][ var 1, frame 1 ][ var 1, frame 2 ] ← row 1 (second sprite)
All rows must have the same number of columns. If a sprite uses fewer frames than the column count, fill the unused columns with transparent/empty pixels.
Per-Frame Timing
By default all frames display for the same duration (frame-time-ms). To give individual frames different hold times, add frame-durations-ms to the sprite.
Two entry formats are supported and can be mixed freely:
Flat — one value per frame:
frame-durations-ms: [80, 80, 120, 120, 200]
Batch — a duration applied to several consecutive frames:
frame-durations-ms:
- count: 3
duration-ms: 80
- count: 2
duration-ms: 200
Both produce the same result: frames 0–2 last 80 ms each, frames 3–4 last 200 ms each.
Note: The total number of entries (after expanding batches) must equal
frame-countexactly. If the counts do not match,frame-durations-msis ignored andframe-time-msis used for all frames instead.
States
States let the server snap an interactable to a specific visual pose without playing an animation. Use frame: first for the first frame, frame: last for the last frame, or a zero-based number for any specific frame.
states:
- name: door_closed
sprite: door_swing
frame: first
- name: door_open
sprite: door_swing
frame: last
Server scripts set the state with entity:set_state("door_closed") — the string must match the state’s name exactly (state keys are stored verbatim and matched literally).
animate direction, and why it has to agree with your states
entity:animate("sprite_name", reverse) plays a sprite once:
reverse | Plays | Ends on |
|---|---|---|
false | first frame → last | the last frame |
true | last frame → first | the first frame |
The state decides the resting pose; the animation only decides how you get there. When an animation finishes, the object snaps to the frame its current state names — so set_state is what determines how the object is drawn once it settles, and reverse only picks which way the sprite travels on the way. Set both:
-- door_swing runs closed (frame 0) -> open (frame 3)
entity:animate("door_swing", collidable) -- closing = reverse, opening = forward
if collidable then
entity:set_state("door_closed") -- frame: first — the resting pose
else
entity:set_state("door_open") -- frame: last
end
Choose reverse so the sprite travels toward the state’s frame: a forward pass for a state on frame: last, a reverse pass for one on frame: first. Get it backwards and the swing plays the wrong way and then snaps — visibly odd, but the object still comes to rest drawn correctly.
This used to be load-bearing rather than cosmetic. Nothing re-applied the state to the sprite, so the frame an animation stopped on stayed forever, and a mismatch left an object drawn as its own opposite indefinitely — a door standing open but rendered shut and still solid. The state is now authoritative for the resting pose, so a mismatch is a visual glitch rather than a permanent lie.
State names are matched exactly
Three files have to agree on these names, and nothing cross-checks them at load — both the state and sprite lookups are exact string matches:
| Written in | As |
|---|---|
| this texture config | a states[].name and a sprites[].name |
interactable-config.yaml | default-state |
| your Lua | entity:set_state("…") and entity:animate("…") |
A name that matches nothing is not an error — the object simply keeps the sprite it already had. It now logs a warning naming the object and the unresolved name, so check the client log first when an interactable’s art never moves.
Case and separators are load-bearing here, unlike elsewhere in the schema. These are names you invent, not closed-set values, so
DOOR OPEN,door-openanddoor_openare three different names. Nothing normalizes them. The shipped door and chest were broken this way for exactly this reason: their scripts calledset_state("DOOR CLOSED")andanimate("DOOR SWING")againstdoor_closedanddoor_swing, so neither the pose nor the swing animation ever played. Pick one spelling per object and use it in all three files.
A missing
default-statecounts as a mismatch. Omitting it leaves the object on the engine fallbackdefault, which is only special-cased to mean “variation 0, frame 0” — if that isn’t the pose you want, declare a state and name it indefault-state. The shipped chest had this bug too.
Light Masks
A light mask controls how an interactable interacts with the lighting system. It must be a PNG the same size as the full sprite sheet. White pixels (255, 255, 255) emit full light; black pixels (0, 0, 0) block light entirely.
- name: christmas_tree
sprites:
- name: christmas_tree
frame-count: 1
frame-width: 80
frame-height: 112
light-mask: christmas_tree_mask
Place christmas_tree_mask.png in the same folder as the main texture. If no light-mask is specified, a fully opaque white mask is generated automatically (the interactable emits no special light).
Reusing Configuration with YAML Anchors
Interactables that share animation sets, states, or timing (e.g. several doors or chests with the same swing/open cycle) don’t need to duplicate that YAML by hand. Standard YAML anchors (&name) and aliases (*name) let you define a block once and reuse it anywhere in the same file — the parser resolves the alias to a full copy of the anchored block before the interactable config is read, so no special loader support is needed.
Anchor whichever block is actually shared — a single sprite list, a states block, or anything else — and leave the rest of the entry free to differ:
textures:
- name: door
sprites: &door_swing
- name: door_swing
frame-count: 2
frame-durations-ms:
- count: 2
duration-ms: 150
states: &door_states
- name: door_closed
sprite: door_swing
frame: first
- name: door_open
sprite: door_swing
frame: last
frame-width: 16
frame-height: 64
- name: vault_door
sprites: *door_swing # identical swing animation, reused
states: *door_states # identical open/closed states, reused
frame-width: 32 # its own frame size
frame-height: 96
Note: An alias is a full substitution, not a merge —
*door_swingcopies the entire anchored block as-is. You cannot reuse a block and then tweak just one field inside it; anchor only the smallest block that is genuinely identical across entries, and declare anything that varies outside of it.
Overrides
Your mod can replace the texture used for any interactable from another mod without changing that mod’s files. Add override with the full mod-namespaced name of the interactable to replace:
textures:
- name: door
override: Creation:door
sprites:
- name: door_swing
frame-count: 2
frame-durations-ms:
- count: 2
duration-ms: 150
states:
- name: door_closed
sprite: door_swing
frame: first
- name: door_open
sprite: door_swing
frame: last
frame-width: 16
frame-height: 64
- The
namefield is still required (it is used to locate your replacement PNG in your mod’s folder). - Your texture file must match the frame dimensions of the original interactable.
- Your override entry fully replaces the animation configuration of the original, so you must redeclare all sprites, states, and timing you want to keep.
- The states and timing declared in your override must be compatible with server scripts that reference that interactable by name.
Complete Example
A door with two sprites (a single animated swing plus an alternate locked state):
textures:
- name: door
sprites:
- name: door_swing
frame-count: 2
frame-durations-ms:
- count: 2
duration-ms: 150
- name: door_locked
frame-count: 1
frame-time-ms: 100
states:
- name: door_closed
sprite: door_swing
frame: first
- name: door_open
sprite: door_swing
frame: last
- name: door_locked
sprite: door_locked
frame: first
frame-width: 16
frame-height: 64
Sprite sheet layout for this door (16 px wide × 64 px tall frames, 2 columns × 2 rows):
[ CLOSED frame ][ OPEN frame ] ← row 0: DOOR SWING
[ LOCKED frame ][ (empty) ] ← row 1: DOOR LOCKED (second column unused)
Total image size: 32 × 128 px.
Ambient Particle Emission
Note: this section configures behavior, not textures — it belongs in the interactable/tree definition YAML (for interactables: the file referenced by interactable-config in your mod’s manifest, the one declaring each interactable’s name, hitbox, time-to-break-ms, etc.; for trees: tree-config.yaml), not in the interactables.yaml/trees.yaml texture files described above.
Both interactables and trees can ambiently emit particles at random, mirroring the particles config blocks support (e.g. a campfire crackling, a furnace venting smoke, a magic crystal glowing, leaves drifting off a tree). Add an optional particles section to an entry:
interactables:
- name: campfire
hitbox: { ... }
particles:
- emit-random:
- name: smoke # Required. Particle name (namespaced automatically if no ':')
chance: 0.4 # Optional, default 0.1. Roll 0.0–1.0 each time the cooldown elapses
cooldown-ms: 800 # Optional, default 1000. Milliseconds between emission attempts
lifetime-ms: 1500 # Optional, default 1. Particle lifetime in milliseconds
has-lifetime: true # Optional, default true. Whether the lifetime applies (smart particles only)
x-offset-min: -0.4 # Optional, default 0.0. Random x offset range (world units) from the object's location
x-offset-max: 0.4
y-offset-min: 0.0 # Optional, default 0.0. Random y offset range (world units) from the object's location
y-offset-max: 0.6
trees:
- name: cherry_tree
type: cherry_tree
particles: # Tree-level default, applies to every sprite below
- emit-random:
- name: cherry_leaf
chance: 0.1
cooldown-ms: 5000
lifetime-ms: 10000
x-offset-min: -3.0
x-offset-max: 3.0
y-offset-min: 0.0
y-offset-max: 3.0
sprites:
- name: cherry_tree # No 'particles' here — uses the tree-level config above
hitbox: { ... }
- name: cherry_tree_2
hitbox: { ... }
particles: # Sprite-level override — replaces the tree-level config for this sprite only
- emit-random:
- name: cherry_leaf
chance: 0.2
cooldown-ms: 3000
lifetime-ms: 10000
x-offset-min: -3.0
x-offset-max: 3.0
y-offset-min: 0.0
y-offset-max: 3.0
Each emit-random entry tracks its own cooldown independently per instance, with a randomized initial phase so multiple instances of the same tree/interactable don’t emit in lockstep. Particles are positioned at the object’s location plus the rolled x-offset/y-offset. Emission is purely cosmetic and entirely client-driven — no client-update hook is required for particles to emit (trees have none at all).
A particles block on a tree sprite entirely replaces the tree-level one for that sprite (it does not merge). Sprites that omit particles fall back to the tree-level config, if any. Interactables have no sprite concept and only support the top-level particles block.