-
Notifications
You must be signed in to change notification settings - Fork 0
Adding a Scene
One of four ODD author guides. Siblings: Building an App, Building an Icon Set, Building a Widget.
A scene is a generative wallpaper that paints the ODD desktop. It's a
single JavaScript file that self-registers with ODD, plus a painted
backdrop (still WebP, 1920×1080) and a preview thumbnail (WebP,
~640×360). Zip them together with a manifest.json and drop the .wp
on the ODD Shop — ODD picks it up, enqueues the JS on every admin
page, and surfaces your scene in the Wallpapers department.
You don't touch WordPress, REST, or PHP.
my-scene.wp
├── manifest.json
├── scene.js ← self-registers into window.__odd.scenes[slug]
├── preview.webp ← ~640×360, shows up on the Shop card
└── wallpaper.webp ← 1920×1080, painted backdrop beneath the scene
Every file except the manifest is addressable from the manifest — you
can rename them and point entry / preview / wallpaper at the new
paths. The tree above is the convention ODD's first-party catalog
scenes follow.
{
"type": "scene",
"slug": "my-scene",
"name": "My Scene",
"label": "My Scene",
"version": "1.0.0",
"category": "Generative",
"tags": [ "blue", "slow", "particles" ],
"fallbackColor": "#112233",
"added": "2026-04-26",
"entry": "scene.js",
"preview": "preview.webp",
"wallpaper": "wallpaper.webp",
"description": "A quiet drift of blue particles over a painted haze."
}| Field | Required | Purpose |
|---|---|---|
type |
yes | Must be "scene". |
slug |
yes |
^[a-z0-9-]+$, globally unique across apps / icon sets / scenes / widgets. |
name |
yes | Display name in the Shop quilt + hero. |
label |
no | Falls back to name when absent. |
version |
yes | Semver-ish string; shown in the tile + debug inspector. |
category |
no | Optional grouping label for Shop shelves and catalog tooling. |
tags |
yes | Array of short strings; used for search + muse mood. |
fallbackColor |
yes |
#hex painted behind the canvas before the first frame draws. |
added |
yes |
YYYY-MM-DD; used for "new" badges and sort-by-freshness. |
entry |
yes | Relative path to your JS file (^[a-zA-Z0-9._/-]+$, no ..). |
preview |
yes | Relative path to the 640×360-ish WebP that drives Shop cards. |
wallpaper |
yes | Relative path to the 1920×1080 WebP painted behind the canvas. |
description |
no | Longer copy shown on the detail sheet. |
Your entry JS is enqueued on every admin page with odd as a
dependency, so window.__odd and PIXI exist by the time your file
runs. Self-register into window.__odd.scenes and do nothing else at
load time:
( function () {
'use strict';
window.__odd = window.__odd || {};
window.__odd.scenes = window.__odd.scenes || {};
window.__odd.scenes[ 'my-scene' ] = {
setup: function ( env ) {
// Build Pixi display objects, attach them to env.app.stage.
// Return a "state" blob you want tick / cleanup to see.
var g = new env.PIXI.Graphics();
env.app.stage.addChild( g );
return { g: g, t: 0 };
},
tick: function ( state, env ) {
state.t += env.dt; // env.dt is clamped to 2.5 after a backgrounded tab
state.g.clear()
.circle( env.app.screen.width / 2, env.app.screen.height / 2, 40 + Math.sin( state.t / 30 ) * 8 )
.fill( { color: 0x88ccff, alpha: 0.7 } );
},
onResize: function ( state, env ) { /* optional */ },
cleanup: function ( state, env ) { /* optional — free non-stage resources */ },
stillFrame: function ( state, env ) { /* optional — pose for prefers-reduced-motion */ },
transitionOut: function ( state, env, done ) { /* optional — call done() when finished */ },
transitionIn: function ( state, env ) { /* optional */ },
onAudio: function ( state, env ) { /* optional — only when env.audio.enabled */ },
onEgg: function ( name, state, env ) { /* 'festival' | 'reveal' | 'peek' */ },
};
} )();| Field | Meaning |
|---|---|
app |
Shared Pixi v8 Application. Reused across scene swaps. |
PIXI |
Pixi v8 module; use env.PIXI.Graphics() rather than importing. |
ctx |
Per-scene bag you can read/write; survives swap-in-place. |
helpers |
{ makeBloomLayer, lerp, clamp, rand, randInt, noise, … }. |
dt |
Delta time for this tick; already clamped to ≤ 2.5 by the runner. |
parallax |
{ x, y } in [-1, 1], driven by cursor / device tilt. |
reducedMotion |
true when the user asks for reduced motion. |
tod |
"dawn" | "day" | "dusk" | "night" based on local time. |
todPhase |
0..1 within the current tod band. |
season |
"spring" | "summer" | "fall" | "winter". |
audio |
{ enabled, level, bass, mid, high } — enabled gates onAudio. |
perfTier |
"high" | "normal" | "low" from a rolling FPS sampler. |
Scenes that ignore new fields keep working. If you branch on
perfTier === 'low', downshift particle counts / shader cost; low-tier
devices run closer to 30 fps and dt is higher.
-
new PIXI.Application()thenawait app.init({ … })— do not pass options to the constructor (v7 pattern). - Use
app.canvas, notapp.view. - Fluent
Graphics:g.rect( … ).fill( { color, alpha } ),g.moveTo( x, y ).lineTo( … ).stroke( { color, width } ). - Ticker callback receives a
Ticker, not a number:app.ticker.add( t => { const dt = t.deltaTime } ). The shared runner already drivestick, so you typically don't add your own. - Bloom:
env.helpers.makeBloomLayer( env.PIXI, strength )returns aContainerwithblendMode='add'+BlurFilter. - Never call
app.destroy()yourself — the runner owns Pixi lifecycle.
ODD reuses a single Pixi Application across scene swaps. Between
swaps the runner calls your cleanup, then app.stage.removeChildren(),
then hands the next scene a fresh-but-reused app.
That means:
-
Anything you add to
app.stageis cleaned up for you. Don't double-unparent incleanup. -
Anything outside the stage graph is your problem. Timers
(
setInterval,setTimeout),window/documentevent listeners,AudioContextnodes,ResizeObservers — tear them down incleanup. -
Global state survives. If you cached a texture on
windowor stashed data onenv.ctxand still want it next mount, re-check it insetuprather than rebuilding blindly.
If the user prefers reduced motion (env.reducedMotion === true), the
runner calls your stillFrame once and skips tick. Paint a pleasant
static composition — often a single hero shape over your wallpaper — and
return. If you omit stillFrame, the painted wallpaper.webp stays
visible on its own, which is almost always fine.
The runner pauses tick when:
- The browser tab is hidden (
document.visibilitychange). - WP Desktop Mode's
desktop-mode.wallpaper.visibilityhook reports{ id, state: 'hidden' }— e.g. when the dock covers the desktop.
You don't need to subscribe to either. dt is already clamped to 2.5,
so the first tick after a pause won't jump.
-
preview.webp — 640×360-ish,
q=80is a good starting point. This is what the Shop cards paint. Aim for ≤ 80 KB. -
wallpaper.webp — 1920×1080,
q=82. Painted backdrop beneath the canvas. Designed for subtlety — most of the motion should come from the scene, not the still layer.
Installed bundles live at wp-content/uploads/odd/scenes/<slug>/ and are
served publicly via content_url() (no admin auth required for the
.webp files themselves). First-party scenes published through the
remote catalog live at https://odd.regionallyfamous.com/catalog/v1/bundles/scene-<slug>.wp;
when ODD installs one it extracts to the same wp-content/uploads/odd/scenes/<slug>/
path so runtime addressing is identical regardless of source.
If you're contributing a scene back to the first-party catalog, clone
this repo and drop your scene under _tools/catalog-sources/scenes/<slug>/{scene.js,meta.json,wallpaper.webp}.
Then npm run build:previews boots Chromium headless, evaluates your
scene against a real Pixi v8, samples a frame ~2 s in, and writes
preview.webp back into the same directory. For fully third-party
bundles, copy odd/bin/build-previews
into your own toolchain — it's a standalone Node script. Flags:
npm run build:previews # rebuild all previews
npm run build:previews -- --only flux # one scene
npm run build:previews -- --diff # only scenes whose scene.js is newer than preview.webp-
Zip the folder:
cd my-scene/ zip -r ../my-scene.wp manifest.json scene.js preview.webp wallpaper.webp -
Open the ODD Shop → Upload (or drop the
.wpanywhere on the Shop). Alternatively, submit it to the first-party catalog by opening a PR that drops the source folder into_tools/catalog-sources/scenes/<slug>/— the next Pages deploy publishes it tohttps://odd.regionallyfamous.com/catalog/v1/and any ODD install world-wide can browse + install it from Discover. -
Confirm the JavaScript-execution prompt. Scenes ship JS that runs in your admin session, so ODD asks once per session before installing a scene or widget. Consent is remembered on
window.__odd.store. -
On success, the Shop jumps to Wallpapers and flashes your new scene's tile. Click Apply to activate it.
- Sanity-check the manifest via the Shop tile + hero — a misnamed
previeworwallpapershows up as a dark card. - Open DevTools — your
scene.jsis enqueued asodd-scene-<slug>withversionpinned tomanifest.version, so cache invalidation works automatically on upgrade. - The debug inspector (see Building on ODD)
lists installed scenes under
window.__odd.debug.scenes(). - Console errors inside your scene propagate to the
odd.errorbus event withsource: 'scene.<slug>'. Reduced-motion and audio-off errors are common — guard accesses toenv.audio.levelwithif ( env.audio.enabled ).
-
.wpManifest Reference — full scene manifest schema. - Building on ODD — registry internals + debug inspector.
- Sibling author guides: Building an App, Building an Icon Set, Building a Widget.