BitLCD theming guide
This guide walks through creating marquee themes for BitLCD, from a minimal JSON-only bezel to Lua-scripted procedural animation. You do not need to use every feature: start with the quick start, stop when your theme does what you want, and return to the later sections when you are ready to add polish or custom behavior.
Choose your path
- New or casual theme author: Start with Quick start, then read Scenes and layers, Bindings, Text layers, Transitions, and the Cookbook. You can build useful themes without Lua.
- Intermediate author: Add Events, Timelines, Video playback, Sequences, Idle and attract mode, and Inheritance as needed.
- Advanced author: Continue into Lua scripting, Procedural animators, Animation limits, and Thread model.
How to use this guide: The examples are copyable starting points. The tables and notes document exact behavior, defaults, limits, and fallback rules, so the advanced reference is here when you need it.
At a glance
| If you want to… | Start here |
|---|---|
| Make a basic bezel around game media | Quick start |
| Position images, video, and text | Scenes and layers |
| Insert game, title, or media data | Bindings |
| Animate without Lua | Timelines |
| Create ambient effects such as snow or embers | Particles |
| Control video looping, clips, or multiple decoders | Video playback |
| Chain screens or wait for video completion | Sequences |
| Customize individual games | Quick start — per-game manifests |
| Reuse and modify another theme | Inheritance |
| Add logic, timers, or state | Lua scripting |
| Add physics-style procedural motion | Procedural animators |
| Understand performance and threading behavior | Animation limits and Thread model |
Quick terminology
- Theme — A package under
bitlcd/themes/that defines scenes, events, and optionally Lua behavior. - Scene — A background plus an ordered set of visual layers.
- Layer — One drawable item such as a color, image, media item, video, text, or particle effect.
- Binding — A string such as
$titleor$media.videothat is replaced with current presentation data. - Manifest — Per-game JSON that can select a theme and explicitly define media slots.
- Sequence — An ordered chain of scenes whose steps advance by time or events.
- Timeline — Declarative keyframe animation evaluated in C++.
- Procedural animator — Lua-driven animation updated at a declared simulation rate.
Quick start
Recommended for everyone. This section gets a working theme on-screen first, then explains per-game manifests and theme-selection fallback behavior. You can copy the smallest example, change the theme ID, and iterate from there.
A theme is a folder on your USB drive:
<drive>/bitlcd/themes/my-theme/
theme.json
frame.png
background.png
The simplest working theme:
{
"format": 2,
"id": "my-theme",
"scenes": {
"selection": {
"background": "#000000",
"layers": [
{
"id": "media",
"type": "media",
"source": "$media",
"rect": [0, 0, 1, 1],
"fit": "contain"
}
]
}
},
"events": {
"selection": "selection"
}
}
Activate it in <drive>/bitlcd/config.json:
{
"version": 2,
"theme": "my-theme",
"media_index_max_kib": 1024
}
That is enough to get started: create the folders, copy in theme.json, and replace my-theme with the ID you chose in config.json. Add frame.png or background.png only when a layer actually refers to them. As you experiment, change one layer at a time; the examples below show the small pieces you can add to this working theme.
A useful mental model:
theme.jsondescribes what can be displayed,eventschooses what to display, and bindings such as$titleand$mediafill in the currently selected game’s data. Lua is optional and adds decisions or state when JSON is not enough.
For complete per-game control, put a manifest in a searchable content directory and keep its media in an underscore-prefixed directory:
<drive>/bitlcd/bitlcd/arcade/pacman.json
<drive>/bitlcd/bitlcd/arcade/_pacman/
pacman.jpg
pacman.mp4
logo.png
fanart-1.jpg
fanart-2.jpg
move-list.png
{
"format": 1,
"theme": "maze-theme",
"media": {
"image": "_pacman/pacman.jpg",
"video": "_pacman/pacman.mp4",
"logo": "_pacman/logo.png",
"fanart": [
"_pacman/fanart-1.jpg",
"_pacman/fanart-2.jpg"
],
"move_list": "_pacman/move-list.png"
}
}
Manifests are discovered recursively in bitlcd/, ext/, and thirdparty/. They may exist without a same-stem JPG or MP4 in the searchable directory. Media paths are relative to the manifest, may enter _ directories, and must remain inside the drive’s bitlcd/ tree. A string declares one item; an array declares an ordered collection. Slot names use lowercase letters, digits, dots, underscores, and hyphens.
image and video are the reserved primary slots. Other names such as logo, fanart, and move_list are author-defined. If either primary slot is omitted, legacy same-stem discovery supplies that type when possible. A theme-only manifest beside legacy media therefore continues to work.
To share a theme, point several manifests at the same theme ID. These are references to one theme package, not copies or inherited themes.
Games without a manifest theme use the global theme. If a selected theme is missing, the global theme is tried before the normal default, classic, and built-in fallbacks. The older central game_themes object is still accepted, but a per-game file takes precedence.
Any directory whose name starts with _ is excluded from recursive manifest and legacy-media discovery. Its files are reachable only through explicit manifest paths. The underscore rule applies at every nesting level.
Background media index
Marqueed incrementally indexes legacy same-stem PNG, JPG, and MP4 files after two seconds with no protocol activity. It scans 64 directory entries at a time and immediately yields when activity resumes, so a newly selected game is never delayed by indexing. media_index_max_kib configures its total RAM admission budget, including both the catalog and an LRU of exact foreground resolutions. It defaults to 1024 KiB and accepts 64–8192 KiB. The 1 MiB default is deliberately small on the BitLCD’s 256 MiB device, while leaving enough room to cache many frequently selected titles.
If a full catalog exceeds the configured budget, its partial entries are discarded and the LRU continues to retain recently resolved titles. Thus large collections still speed up repeated selections without allowing the index to grow past its budget. Manifest-based titles are cached only after their normal resolver path completes, preserving explicit media-slot semantics.
Theme IDs use lowercase letters, digits, dots, underscores, and hyphens. The first character must be a letter or digit.
Theme selection order
At startup and on each game selection, marqueed resolves themes from mounted drives in this order:
- The game’s JSON manifest, when present.
- The global theme named in
config.json. - A theme named
default. - A theme named
classic. - The built-in classic presentation.
Scenes and layers
Core concept. A scene is what BitLCD presents; layers are drawn in order to build that scene. Most visual changes begin here.
A scene has a background color and an ordered list of layers. Later layers draw on top of earlier ones.
{
"scenes": {
"selection": {
"background": "#05010a",
"layers": [
{
"id": "bg",
"type": "image",
"source": "background.png",
"rect": [0, 0, 1, 1],
"fit": "cover"
},
{
"id": "media",
"type": "media",
"source": "$media",
"rect": [0.04, 0.08, 0.92, 0.84],
"fit": "contain"
},
{
"id": "frame",
"type": "image",
"source": "frame.png",
"rect": [0, 0, 1, 1]
},
{
"id": "title",
"type": "text",
"value": "$title",
"font": "font.ttf",
"size": 0.09,
"color": "#ffd85a",
"anchor": "bottom-center"
}
],
"transition": {"type": "fade", "ms": 250}
}
}
}
Layer types
| Type | Purpose |
|---|---|
color | Filled rectangle |
image | Static image from the theme folder |
media | Title media resolved by marqueed (image or video) |
video | Explicit video with playback control |
text | Bound or literal UTF-8 text |
particles | Many small moving sprites: embers, snow, sparkles (see Particles) |
Every layer has an id, rect, and opacity (0 to 1, default 1). Image, media, video, and text layers also support transform and timeline. Particle layers support transform.translate and timelines that animate opacity and translate.
Geometry
rect is [x, y, width, height] in normalized coordinates. [0, 0, 1, 1] covers the full panel.
The optional rect_units property selects the coordinate system:
| Unit | Description |
|---|---|
panel | Default. x/width use panel width, y/height use panel height |
vh | All values use panel height (useful for square regions) |
px | Physical pixels |
Scalar sizes like text.size are fractions of the panel height: 0.09 is about 32 pixels on a 360-pixel panel.
Fitting
Image and video layers have a fit property:
contain— scale to fit inside the rect, preserving aspect ratio (default).cover— scale to fill the rect, cropping the overflow.stretch— distort to fill the rect exactly.
Layers clip to their rectangle by default. Set clip: false to allow overflow.
Colors
Colors are #RRGGBB or #RRGGBBAA. The final pair is alpha (FF = opaque). A layer’s opacity multiplies its color or texture alpha.
Bindings
Core concept. Bindings connect a reusable theme to the currently selected game’s title, media, and other presentation data.
String values starting with $ are replaced with data from the current presentation:
| Binding | Content |
|---|---|
$title | Game title |
$title2 | Secondary title |
$romname | ROM filename |
$leaderboard | Leaderboard text |
$payload_type | Numeric payload type |
$media | Preferred media (image if both exist, otherwise whatever is available) |
$media.image | Title image path |
$media.video | Title video path |
$media.<slot> | First available item in a named manifest media slot |
Bindings are complete-value replacements — "$title" works, but "Game: $title" is treated as a literal string. Use $$ to escape a dollar sign: "$$5.00" renders as $5.00.
For an array-valued slot, declarative JSON uses the first available item. Lua receives every ordered item under ctx.media.slots.<name> and can select among them dynamically. Layers using optional named media should set "required": false.
{
"id": "game-logo",
"type": "image",
"source": "$media.logo",
"required": false,
"rect": [0.7, 0.05, 0.25, 0.3],
"fit": "contain"
}
Text layers
Common customization. Use this section for fonts, wrapping, alignment, overflow, and multilingual fallback behavior.
{
"id": "title",
"type": "text",
"value": "$title",
"rect": [0.04, 0.1, 0.92, 0.8],
"fonts": ["custom.ttf"],
"size": 0.09,
"min_size": 0.045,
"color": "#FFD700",
"anchor": "bottom-center",
"wrap": "word",
"max_lines": 2,
"overflow": "shrink"
}
font is shorthand for a single-element fonts array. Fonts are tried in order for each glyph; unreadable files are skipped. Relative paths resolve from the theme that declared the list, including inherited fonts. The default font and MARQUEED_FALLBACK_FONTS are appended to the chain. Supply a font covering CJK to display CJK text; classic bundles one for its boot and missing-media cards. Lua inline text supports the same properties. See Font resolution for configuration, examples, cache behavior, and Unicode/runtime limitations.
Anchors: top-left, top-center, top-right, center-left, center, center-right, bottom-left, bottom-center, bottom-right.
Wrap modes: none (default), word, character.
Overflow modes:
clip— clip at the rectangle edge (default).ellipsis— replace the end of the last visible line with an ellipsis.shrink— reduce the font towardmin_size, then apply ellipsis.marquee— scroll horizontally.
Transitions
Optional polish. Transitions animate the change from one scene to another.
Transitions animate between the old scene and the new scene:
{
"transition": {"type": "fade", "ms": 250}
}
Available types: cut (instant), fade, slide_left, slide_right.
Events
Intermediate. Events decide which scene or sequence runs in response to BitLCD activity.
The events object maps presentation events to scenes or sequences:
{
"events": {
"boot": "boot-screen",
"selection": "selection",
"media_missing": "missing",
"idle": {"scene": "idle-screen"},
"attract": {"sequence": "attract-loop"}
}
}
For selection events, you can use more specific keys. The engine checks them in order:
selection.image— when only a title image exists.selection.video— when only a title video exists.selection.image_video— when both exist.selection— fallback for any selection.
media_missing fires when neither image nor video is found.
Event reference
| Event | When it fires |
|---|---|
boot | After theme activation at startup |
selection | Host sends a game selection |
media_missing | No media found for a selection |
idle | No activity for idle.after_s seconds |
attract | Attract mode selects an item |
video_ended | A finite video finishes its configured plays |
video_error | Video open/decode failure |
timer | A Lua-created timer expires |
reload | Theme successfully reloaded |
Timelines
Intermediate animation, no Lua required. Timelines animate layer properties on the render thread.
Timelines animate layer properties over time. They run in C++ at the render frame rate without involving Lua.
Opacity timeline
Fade a title out after 2.5 seconds:
{
"id": "title",
"type": "text",
"value": "$title",
"anchor": "bottom-center",
"timeline": {
"trigger": "scene_enter",
"keyframes": [
{"at_ms": 0, "opacity": 1},
{"at_ms": 2500, "opacity": 1},
{"at_ms": 3000, "opacity": 0}
],
"fill": "forwards"
}
}
Transform timeline
Animate position, scale, rotation, and pivot:
{
"id": "logo",
"type": "image",
"source": "logo.png",
"rect": [0.35, 0.2, 0.3, 0.6],
"fit": "contain",
"timeline": {
"trigger": "scene_enter",
"loop": true,
"keyframes": [
{"at_ms": 0, "transform": {"scale": [1.0, 1.0]}},
{"at_ms": 500, "transform": {"scale": [1.15, 1.15]}},
{"at_ms": 1000, "transform": {"scale": [1.0, 1.0]}}
]
}
}
Transform properties are set inside keyframe transform objects:
| Property | Default | Description |
|---|---|---|
translate | [0, 0] | Offset as [x, y] in rect units |
scale | [1, 1] | Scale as [x, y] multipliers |
rotation_deg | 0 | Rotation in degrees |
pivot | [0.5, 0.5] | Rotation/scale center, normalized within the layer |
Independent tracks
Each property is an independent track. A keyframe only affects the properties it mentions — unmentioned properties keep their base values or interpolate between their own keyframes independently.
This example pulses for two seconds, then rotates for one second, and loops:
{
"keyframes": [
{"at_ms": 0, "transform": {"scale": [1.0, 1.0]}},
{"at_ms": 500, "transform": {"scale": [1.15, 1.15]}},
{"at_ms": 1000, "transform": {"scale": [1.0, 1.0]}},
{"at_ms": 1500, "transform": {"scale": [1.15, 1.15]}},
{"at_ms": 2000, "transform": {"scale": [1.0, 1.0], "rotation_deg": 0}},
{"at_ms": 3000, "transform": {"rotation_deg": 360}}
]
}
Scale pulses from 0–2000ms. Rotation only starts at 2000ms and runs to 3000ms. They don’t interfere with each other.
Timeline properties
| Property | Values | Description |
|---|---|---|
trigger | scene_enter, value_change | When to start/restart |
binding | e.g. "$title" | Required with value_change — restarts when this value changes |
fill | forwards, reset | What happens after the last keyframe |
loop | true/false | Restart from the beginning when the last keyframe is reached |
Static transforms
You can set a static transform on a layer without a timeline:
{
"id": "logo",
"type": "image",
"source": "logo.png",
"rect": [0.4, 0.2, 0.2, 0.6],
"transform": {
"rotation_deg": 15,
"scale": [1.2, 1.2]
}
}
Particles
Optional visual effects. Particle layers are a convenient way to add motion such as embers, snow, or sparkles without writing Lua.
A particles layer draws many small sprites that spawn, move, and fade out on their own. Add the layer to a scene to turn the effect on; remove it to turn it off. No Lua is needed.
{
"id": "embers",
"type": "particles",
"blend": "additive",
"max_particles": 320,
"emitter": {"shape": "line", "area": [0, 1.03, 1, 0], "rate": 45, "prewarm_ms": 6000},
"particle": {
"lifetime_ms": [3500, 6000],
"direction_deg": [262, 278],
"speed": [0.12, 0.3],
"gravity": [0, -0.03],
"drag": 0.2,
"size": [0.02, 0.055],
"wobble": {"amplitude": [0.01, 0.03], "hz": [0.2, 0.7]},
"color": [
{"at": 0.0, "color": "#FFD27A00"},
{"at": 0.12, "color": "#FFB04AE0"},
{"at": 1.0, "color": "#8A160000"}
],
"scale": [{"at": 0, "value": 0.6}, {"at": 0.25, "value": 1}, {"at": 1, "value": 0.3}]
}
}
This spawns 45 embers a second along a line just below the panel. They drift upward, sway, glow orange, and fade out. prewarm_ms starts the effect as if it had already been running for six seconds, so the panel is full from the first frame. The bundled themes/embers theme uses this layer.
Units
rectis where particles are drawn and clipped; it defaults to the full panel.- Emitter
areais[x, y, w, h]inside the layer rect, where[0, 0]is its top-left corner and[1, 1]its bottom-right. Values slightly outside 0–1 spawn particles just off screen. speed,gravity,size, and wobbleamplitudeare in panel heights, sospeed: 0.25moves a quarter of the panel height per second in every direction.- Angles are in degrees on screen: 0 points right, 90 down, 270 up.
- Values marked range take a number or
[min, max]. Each particle picks its own value from the range when it spawns.
Layer properties
| Property | Default | Description |
|---|---|---|
sprite | builtin:dot | Image in the theme folder, builtin:dot (soft round glow), or builtin:square |
blend | normal | normal or additive (glows brighten what is behind them) |
max_particles | 128 | Most particles alive at once, 1–1024 |
continuity | theme | theme keeps the effect running across game selections; scene restarts it every time the scene appears |
seed | from id | Integer seed; the same seed always produces the same motion |
required | false | When true, a missing sprite rejects the scene instead of using builtin:dot |
Emitter
| Property | Default | Description |
|---|---|---|
shape | rect | point (at area x/y), line (along area width), or rect |
area | [0, 0, 1, 1] | Spawn region inside the layer rect |
rate | 0 | Particles spawned per second, up to 1000 |
bursts | [] | Up to 8 of {"at_ms": 0, "count": 40, "repeat_ms": 0}; repeat_ms of 0 fires once |
prewarm_ms | 0 | Start as if already running this long, up to 60000 |
duration_ms | 0 | Stop spawning after this long; 0 spawns forever |
An emitter needs a rate, at least one burst, or both.
Particle
| Property | Kind | Default | Description |
|---|---|---|---|
lifetime_ms | range | 2000 | How long each particle lives, 50–60000 |
direction_deg | range | 270 | Starting heading |
speed | range | 0.1 | Starting speed |
gravity | [x, y] | [0, 0] | Constant acceleration; negative y pulls upward |
drag | number | 0 | Slows particles down over time, 0–20 |
size | range | 0.02 | Sprite width and height |
rotation_deg | range | 0 | Starting rotation |
spin_deg_s | range | 0 | Rotation speed |
wobble | object | none | Side-to-side sway: amplitude range and hz range |
color | stops | white | Up to 8 {"at", "color"} stops over the particle’s life |
scale | stops | 1 | Up to 8 {"at", "value"} size multipliers over the particle’s life |
at runs from 0 when a particle spawns to 1 when it dies. Stops must be in increasing at order, and values in between are blended linearly.
Keeping effects running
With the default continuity: "theme", a particle layer continues smoothly when the frontend changes the selected game. That works when the next scene contains the same layer: same id, rect, seed, max_particles, emitter, and particle settings. Scenes may still use a different sprite, blend, opacity, or timeline. To keep snow falling on both the media and missing-media scenes, copy the identical layer into both.
During a fade between two scenes that share the effect, it is drawn once at full strength so it doesn’t dim halfway through.
Use continuity: "scene" for effects tied to a moment, like a burst of sparks each time a game is selected:
{
"id": "sparks",
"type": "particles",
"blend": "additive",
"continuity": "scene",
"max_particles": 48,
"emitter": {"shape": "line", "area": [0.35, 0.96, 0.3, 0], "bursts": [{"at_ms": 0, "count": 40}]},
"particle": {"lifetime_ms": [600, 1100], "direction_deg": [210, 330], "speed": [0.4, 0.9],
"gravity": [0, 0.8], "drag": 1.2, "size": [0.016, 0.032]}
}
Turning an inherited effect off or down
A theme that extends another can remove its particle layer:
{"id": "embers", "remove": true}
Or retune it without repeating everything. emitter and particle merge key by key, while arrays such as color, scale, and bursts are replaced as a whole:
{"id": "embers", "emitter": {"rate": 20}, "opacity": 0.6}
Limits
A scene may have up to 4 particle layers, with at most 1500 max_particles in total. A theme over these limits fails to load and names the offending layer:
scenes.media.layers[1] (embers): emitter.rate must be between 0 and 1000
If rate × longest lifetime needs more particles than max_particles, spawns are skipped while the pool is full and the effect looks thinner. Raise max_particles or lower rate.
Large, overlapping, see-through sprites cost more GPU time than many small ones, so prefer small sizes for dense effects.
Lua
Lua inline scenes can return type = "particles" layers with the same fields as JSON. An invalid particle layer rejects that inline scene, and the manifest’s event mapping is used instead. A procedural animator may target a particle layer’s translation and opacity, for example to make a whole snowfield sway with the wind.
Video playback
Intermediate. Use explicit video layers when you need playback counts, looping, clips, random starts, or decoder sharing.
Basic video
{
"id": "gameplay",
"type": "video",
"source": "$media.video",
"rect": [0, 0, 1, 1],
"fit": "contain",
"playback": {
"loop": true
}
}
Without a playback descriptor, video plays once and stops. Add "playback": {"loop": true} for continuous playback.
Playback properties
| Property | Default | Description |
|---|---|---|
id | layer ID | Playback instance identifier |
loop | false | Play continuously |
count | 1 | Total number of plays (1–1000, mutually exclusive with loop) |
start | 0 | Offset in seconds, or a random-start object |
clip | full file | {"in_s": number, "out_s": number} playback range |
Random start
{
"playback": {
"loop": true,
"start": {"mode": "random", "min_s": 0}
}
}
Each playback instance gets a deterministic random offset. Different playback IDs get different offsets.
Multiple video instances
Each distinct playback.id creates an independent decoder. This example creates four independently phased copies:
{
"id": "tile-1",
"type": "video",
"source": "$media.video",
"rect": [0.01, 0.1, 0.235, 0.8],
"fit": "cover",
"playback": {"id": "player-1", "loop": true, "start": {"mode": "random"}}
},
{
"id": "tile-2",
"type": "video",
"source": "$media.video",
"rect": [0.255, 0.1, 0.235, 0.8],
"fit": "cover",
"playback": {"id": "player-2", "loop": true, "start": {"mode": "random"}}
}
Declare the number of instances you need:
{
"requires": {"video_instances": 4}
}
Themes requiring more instances than the device supports are rejected at activation.
Shared playback
To show the same decoded frame in multiple places (same instant, one decoder), reuse the same playback.id:
{
"id": "copy-1", "type": "video", "source": "$media.video",
"rect": [0.00, 0, 0.25, 1], "fit": "cover",
"playback": {"id": "shared", "loop": true}
},
{
"id": "copy-2", "type": "video", "source": "$media.video",
"rect": [0.25, 0, 0.25, 1], "fit": "cover",
"playback": {"id": "shared", "loop": true}
}
This consumes only one playback instance regardless of how many layers share the ID.
Sequences
Intermediate. Sequences coordinate multiple scenes over time or in response to playback events.
Sequences chain scenes together. Map an event to a sequence instead of a scene:
{
"events": {
"selection": {"sequence": "intro-then-still"}
}
}
Timed steps
Cycle images every five seconds:
{
"sequences": {
"slideshow": {
"loop": true,
"steps": [
{"scene": "slide-1", "duration_ms": 5000},
{"scene": "slide-2", "duration_ms": 5000},
{"scene": "slide-3", "duration_ms": 5000}
]
}
}
}
Video-driven steps
Advance when a video finishes:
{
"sequences": {
"intro-then-still": {
"steps": [
{
"scene": "intro",
"until": [
{"event": "video_ended", "playback_id": "intro-player"},
{"event": "video_error", "playback_id": "intro-player"}
]
},
{"scene": "still"}
]
}
}
}
until accepts one matcher or an array of alternatives — the first match advances the step. A step with no duration_ms or until is terminal.
Counted playback in sequences
Override a video layer’s play count for one sequence step:
{
"scene": "media-video",
"until": [
{"event": "video_played", "playback_id": "media", "count": 3},
{"event": "video_error", "playback_id": "media"}
]
}
The layer’s own loop: true is overridden — the player runs exactly three times for this step. This lets the same scene loop continuously when used directly and play a bounded number of times in a sequence.
Idle and attract mode
Optional behavior. Configure what happens after inactivity and how attract mode cycles through content.
{
"idle": {
"after_s": 60,
"target": {"scene": "idle-screen"}
},
"attract": {
"enabled": true,
"order": "shuffle",
"dwell_s": 20,
"sources": ["rom", "drive"]
}
}
idle.after_s is the seconds of inactivity before the idle scene activates. Set to 0 to disable. idle.media is a shorthand for a full-panel looping video: "media": "idle.mp4".
Attract mode cycles through content from ROM and/or drive sources. order is sequential or shuffle.
Inheritance
Useful for theme families. Extend an existing theme and override only what changes.
Extend an existing theme instead of copying it entirely:
{
"format": 2,
"id": "neon-variant",
"extends": "neon",
"scenes": {
"selection": {
"background": "#1a0033",
"layers": [
{
"id": "title",
"color": "#ff44aa"
}
]
}
}
}
Scenes merge by name. Layers merge by id — a matching layer replaces only the fields you specify, keeping its draw order position.
Adding layers
New layers append after inherited ones. Use before or after to control position:
{
"id": "badge",
"type": "image",
"source": "badge.png",
"rect": [0.9, 0.02, 0.08, 0.2],
"after": "frame"
}
Removing layers
{"id": "title", "remove": true}
Lua scripting
Advanced. Lua is useful when declarative scenes and timelines are not enough—for example, for custom logic, timers, state, or procedural motion.
For themes that need conditional logic, state, timers, or procedural animation, add a theme.lua script.
Reference it in the manifest:
{
"format": 2,
"id": "dynamic-theme",
"script": "theme.lua"
}
Event hook
The script returns a table with an on_event function:
return {
on_event = function(ctx)
if not ctx.media.found then
return "missing"
end
return {
scene = "selection",
vars = {
title = ctx.title,
accent = ctx.payload_type == 2 and "#42e8ff" or "#ffd85a"
}
}
end
}
on_event receives a context table and can return:
nil— keep the current scene.- A string — select a named scene from the manifest.
- A table with
scene(string) — select a named scene, optionally withvarsto override bound values. - A table with
scene(table) — an inline scene definition. - A table with
sequence(string) — start a named sequence.
Context fields
The context table passed to on_event:
| Field | Type | Description |
|---|---|---|
ctx.event | string | Event name (selection, boot, timer, etc.) |
ctx.sequence | number | Presentation sequence number |
ctx.romname | string | ROM filename |
ctx.title | string | Game title |
ctx.title2 | string | Secondary title |
ctx.leaderboard | string | Leaderboard text |
ctx.payload_type | number | Payload type identifier |
ctx.elapsed_ms | number | Milliseconds since Lua state creation |
ctx.media.found | boolean | Whether primary image or video media was found |
ctx.media.kind | string | image, video, image_video, or not_found |
ctx.media.image | string | Title image path |
ctx.media.video | string | Title video path |
ctx.media.images | array | Ordered primary-image collection |
ctx.media.videos | array | Ordered primary-video collection |
ctx.media.slots.<name> | array | Ordered items from any manifest media slot |
ctx.panel.width | number | Panel width in pixels |
ctx.panel.height | number | Panel height in pixels |
ctx.capabilities.video_instances | number | Device video instance limit |
ctx.event_data | table | Event-specific data (timer_id, playback_id, etc.) |
ctx.state | table | Mutable state preserved between callbacks |
Inline scenes
Return a complete scene from Lua:
return {
scene = {
background = "#050505",
layers = {
{
id = "slide",
type = "image",
source = images[current],
rect = {0, 0, 1, 1},
fit = "cover"
}
},
transition = {type = "fade", ms = 200}
}
}
Timers
Request a callback after a delay:
return {
scene = "selection",
timers = {
{id = "next-slide", after_ms = 5000, replace = true}
}
}
When the timer fires, on_event is called with ctx.event == "timer" and ctx.event_data.timer_id == "next-slide". Timers are cancelled when a new host selection arrives.
replace = true replaces an existing timer with the same ID instead of creating a duplicate.
Persistent state
ctx.state is a mutable table that persists between callbacks within the same theme activation:
on_event = function(ctx)
if ctx.event == "selection" then
ctx.state.count = (ctx.state.count or 0) + 1
end
-- ctx.state.count persists across selections
end
State is discarded when the theme is reloaded.
Available libraries
Lua scripts have access to:
math,string,table,utf8(safe portions)- Scene and color constructors
The following are not available for safety: io, os, package, debug, dofile, loadfile, load, require.
Scripts run on a dedicated worker thread with memory and instruction limits. A script that exceeds its budget falls back to the manifest’s declarative event mappings.
Procedural animators
Advanced motion. Procedural animators provide physics-style or simulation-driven movement from Lua at a declared update rate.
For smooth, physics-based motion that can’t be expressed as keyframes, declare a procedural animator in the scene and implement it in Lua.
Declaring an animator
In the scene’s animations array:
{
"scenes": {
"selection": {
"layers": [
{
"id": "logo",
"type": "image",
"source": "logo.png",
"rect": [0.35, 0.2, 0.3, 0.6],
"fit": "contain"
}
],
"animations": [
{
"id": "logo-bounce",
"hook": "logo_bounce",
"tick_hz": 60,
"targets": {
"logo": ["translation", "scale", "rotation", "pivot", "opacity"]
},
"params": {
"spring_k": 200,
"damping": 0.8
}
}
]
}
}
}
| Field | Description |
|---|---|
id | Unique identifier for this animator instance |
hook | Name of the Lua animation table to call |
tick_hz | Simulation rate, 1–120 (default 60) |
targets | Map of layer ID to writable properties |
params | Immutable numeric parameters passed to init |
composition | Set to "additive" to compose with a timeline on the same layer |
Implementing the hook
In theme.lua, the script returns an animations table alongside on_event:
local animations = {}
animations.logo_bounce = {
init = function(ctx, params)
-- Return initial state
return {
y = 0,
velocity = 0,
spring_k = params.spring_k or 200,
damping = params.damping or 0.8
}
end,
update = function(state, frame, out)
local dt = frame.dt_s
-- Spring physics
local force = -state.spring_k * state.y
state.velocity = state.velocity + force * dt
state.velocity = state.velocity * math.exp(-state.damping * dt)
state.y = state.y + state.velocity * dt
-- Write the pose
-- args: layer, tx, ty, sx, sy, rotation_deg, pivot_x, pivot_y, opacity
out:set_transform("logo", 0, state.y, 1, 1, 0, 0.5, 0.5, 1.0)
-- Return true to keep ticking, false to go dormant
return true
end,
on_signal = function(state, signal)
if signal.name == "impulse" then
state.velocity = state.velocity + signal.value
end
end,
destroy = function(state, reason)
-- Optional cleanup
end
}
return {
on_event = function(ctx)
return "selection"
end,
animations = animations
}
Animator lifecycle
-
init — called once when the scene is ready. Receives the presentation context and the
paramsfrom the declaration. Returns the animator’s private state table. - update — called at the declared
tick_hzrate. Receives:state— the mutable state table frominit.frame.dt_s— fixed timestep (always1 / tick_hz).frame.elapsed_s— total simulation time for this animator.frame.tick— monotonic tick counter.out— the pose output object.
Returns
trueto continue ticking,falseto go dormant. -
on_signal — called when an event hook sends a signal to this animator. A dormant animator wakes up when it receives a signal.
- destroy — called when the scene, presentation, or theme is replaced. Optional.
Pose output
The out object in update has one method:
out:set_transform(layer_id, tx, ty, sx, sy, rotation_deg, pivot_x, pivot_y, opacity)
All values are numbers. Translation is in rect units, scale is a multiplier, rotation is degrees, pivot is normalized within the layer, and opacity is 0 to 1.
An animator can only write to layers and properties listed in its targets declaration.
Sending signals to animators
From on_event, send signals to active animators:
return {
scene = "selection",
animation_signals = {
{id = "logo-bounce", name = "impulse", value = 0.4}
}
}
Signals are tagged with the current presentation — stale signals for replaced scenes are discarded.
Composition with timelines
By default, a timeline and a Lua animator cannot write the same property on the same layer. To allow both, set composition: "additive" on the animator declaration. The composition order is:
- Base layer transform
- Declarative timeline result
- Lua animator result
Translation and rotation add. Scale and opacity multiply.
Animation limits
Reference and troubleshooting. Check these limits when a theme works in a simple test but is rejected or becomes too expensive on the device.
Themes that use procedural animation must declare their maximum demand:
{
"limits": {
"animators": 2,
"animated_layers": 16,
"animation_commands_per_tick": 32,
"animation_tick_hz": 60
}
}
The device profile caps each value. If an animator exceeds its budget, it keeps its last valid pose and is disabled after repeated failures.
Cookbook
Copyable recipes. These examples combine the individual features into common theme patterns.
Bezel with fading title
{
"format": 2,
"id": "bezel",
"scenes": {
"selection": {
"background": "#0a0a0a",
"layers": [
{
"id": "media",
"type": "media",
"source": "$media",
"rect": [0.04, 0.06, 0.92, 0.82],
"fit": "contain"
},
{
"id": "frame",
"type": "image",
"source": "frame.png",
"rect": [0, 0, 1, 1]
},
{
"id": "title",
"type": "text",
"value": "$title",
"font": "font.ttf",
"size": 0.07,
"color": "#ffffff",
"anchor": "bottom-center",
"rect": [0.05, 0, 0.9, 0.96],
"timeline": {
"trigger": "scene_enter",
"keyframes": [
{"at_ms": 0, "opacity": 0},
{"at_ms": 300, "opacity": 1},
{"at_ms": 3000, "opacity": 1},
{"at_ms": 3500, "opacity": 0}
],
"fill": "forwards"
}
}
],
"transition": {"type": "fade", "ms": 250}
},
"missing": {
"background": "#0a0a0a",
"layers": [
{"id": "frame", "type": "image", "source": "frame.png", "rect": [0, 0, 1, 1]},
{
"id": "title",
"type": "text",
"value": "$title",
"size": 0.14,
"color": "#ffffff",
"anchor": "center"
}
]
}
},
"events": {
"selection": "selection",
"media_missing": "missing"
}
}
Image/video cycle (classic behavior)
Show the title image for three seconds, play the video once, and repeat:
{
"format": 2,
"id": "classic-cycle",
"scenes": {
"image-only": {
"layers": [
{"id": "img", "type": "image", "source": "$media.image", "rect": [0, 0, 1, 1], "fit": "contain"}
]
},
"video-only": {
"layers": [
{"id": "vid", "type": "video", "source": "$media.video", "rect": [0, 0, 1, 1], "fit": "contain", "playback": {"loop": true}}
]
},
"image-scene": {
"layers": [
{"id": "img", "type": "image", "source": "$media.image", "rect": [0, 0, 1, 1], "fit": "contain"}
]
},
"video-scene": {
"layers": [
{"id": "vid", "type": "video", "source": "$media.video", "rect": [0, 0, 1, 1], "fit": "contain", "playback": {"id": "media", "loop": true}}
]
}
},
"sequences": {
"image-video-cycle": {
"loop": true,
"steps": [
{"scene": "image-scene", "duration_ms": 3000},
{
"scene": "video-scene",
"until": [
{"event": "video_played", "playback_id": "media", "count": 1},
{"event": "video_error", "playback_id": "media"}
]
}
]
}
},
"events": {
"selection.image": "image-only",
"selection.video": "video-only",
"selection.image_video": {"sequence": "image-video-cycle"}
}
}
Pulsing logo with Lua
{
"format": 2,
"id": "pulse-demo",
"script": "theme.lua",
"scenes": {
"selection": {
"background": "#000000",
"layers": [
{"id": "media", "type": "media", "source": "$media", "rect": [0, 0, 1, 1], "fit": "contain"},
{"id": "logo", "type": "image", "source": "logo.png", "rect": [0.42, 0.3, 0.16, 0.4], "fit": "contain"}
],
"animations": [
{
"id": "pulse",
"hook": "pulse",
"tick_hz": 30,
"targets": {"logo": ["scale", "opacity"]}
}
]
}
},
"events": {"selection": "selection"},
"limits": {"animators": 1, "animated_layers": 1}
}
theme.lua:
local animations = {}
animations.pulse = {
init = function(ctx, params)
return {t = 0}
end,
update = function(state, frame, out)
state.t = state.t + frame.dt_s
local s = 1.0 + 0.1 * math.sin(state.t * 2 * math.pi)
local a = 0.7 + 0.3 * math.sin(state.t * 2 * math.pi)
out:set_transform("logo", 0, 0, s, s, 0, 0.5, 0.5, a)
return true
end
}
return {
on_event = function(ctx)
return "selection"
end,
animations = animations
}
Thread model
Advanced reference. This explains where animation and Lua work runs, which is useful when diagnosing timing or performance behavior.
Understanding the thread model helps when debugging or writing advanced themes:
- Render thread — owns SDL. Evaluates declarative timelines, applies poses, draws layers. Never runs Lua.
- Presentation worker — resolves events, prepares scenes, decodes images.
- Theme script worker — owns the Lua state. Runs
on_eventand animatorupdatecalls. Publishes pose snapshots that the render thread picks up without waiting.
The render thread never blocks on Lua. If a Lua animator runs slowly, the render thread simply repeats the last valid pose.