This is the reference for the Eggox scripting API, version 1. Scripts are
Lua, run on the server in every copy of a room, and see the world through
the verbs below. Each section is its own page; the whole reference is one
file at [/api/reference.md](/api/reference.md).

## Versions

A room is made on the current API version and keeps it: what its scripts were
written against goes on working when the API moves on. The Studio's top bar
shows the room's version; when a newer one exists it offers UPGRADE, and the
version's page here lists what changed so you can read first and
adjust after. Published copies run the version their room was published on.

## Objects, bricks and scripts

A game is also a folder of files (rooms, things, scripts, bricks) that the
eggox command line pulls and pushes, so it can be worked on from your own
machine, in your own git, by any editor or an AI agent: see
[A game as files](/project/folder).

The Studio has the shape other game engines share. A room's HIERARCHY lists
its objects: the GAME (the rules), the ROOM (its settings and its script
files), and the THINGS standing in it, grouped under their ITEM (a stock mint:
what is set on the item holds for every instance of it, like a prefab). The
INSPECTOR shows the selected object's bricks (components) and its script.

Bricks on the game: `rounds`, `winner`, `teams`, `lives`, `score`, `welcome`.
No `rounds` makes a hangout. Bricks on a thing or an item: `spawn`, `goal`,
`pickup`, `hazard`, `finish`, `door`, `sign`, `switch`, and `action` (a trigger
with a verb). The Studio writes one engine from them into the room script's
block between `-- <studio>` and `-- </studio>`: waiting, countdown, the round
clock on the HUD, the winner, results, the next round. Do not edit the block;
the next save rewrites it.

A script ON a thing is Lua like the room's, in its own block. It goes on the
item (the stock mint), so it runs for every instance of it, in every room of
the game; a placed instance carries bricks of its own but no script of its
own. Its `on("click")`, `on("walk_on")` and `on("walk_off")` fire for that
thing alone; other events fire as usual. `self.item` names the item. Plain (global) functions and tables from the
room's files are shared with scripts on things; `local` names are not. The
`thing` argument carries `item`, the item id of the thing, so a room script can
tell instances of one item apart from another.

## Build, playtest, publish

Buy an experience in the Shop, place its door, and open its setup panel. Enter
the room to build its scenery. Its script runs in copies of the room.

**Save & playtest** saves the settings and script shown in the panel, then enters
a private copy of the draft. It does not publish. All rooms belonging to the game
are snapshotted together, including their saved scripts and scenery.

During a playtest:

- **Restart** takes a new snapshot of saved drafts and starts fresh script state.
  It keeps test saves, so you can test returning players.
- **Reset progress** starts again with empty test saves, including global values
  and leaderboards. Published-game saves are never read or changed.
- **Script** opens the entry room's script. **Save & restart** applies its changes.
- **Logs** keeps the latest 100 messages and errors for this run. Use `log()`
  inside a handler. Runtime errors identify the handler; window errors name
  the invalid field. Errors during `start` are retained too.
- **Stop** returns to the authoring room and discards test saves. Test data lives
  in memory and also disappears when the player session ends or the server restarts.

Only the creator can enter a playtest. Invitations and spectating are disabled.
Scripts can use `send` and doors to visit the game's draft rooms. A playtest is
currently a solo test; testing several real players together still uses published
copies. Restart always starts at the room where you began the playtest.

**Publish** snapshots the saved game for players. Existing published copies keep
their version until they end; a playtest always starts a new copy. Publishing
from the setup panel uses the saved script, so save script edits first.

## Runtime and state

Scripts execute on the server in Luerl, an Erlang implementation of Lua.
Each room copy has its own Lua variables and handlers. Use `save` and `load`
for state that survives copies and visits. Rooms in one game share saved values.

Register handlers with `on(event, function...)`. Registering the same event again
adds a handler: every handler for an event runs, in the order registered, so
the studio's generated handlers and your own share an event. Put world actions inside handlers, rather than at the top
level. An ordinary handler error rolls back that handler's Lua state and effects.

```lua
on("enter", function(p)
  local visits = (load(p, "visits") or 0) + 1
  save(p, "visits", visits)
  panel(p, "WELCOME", {{"VISITS", visits}})
  window(p, {
    id = "welcome",
    title = "WELCOME ABOARD",
    text = "Find the signal station.\nThen light the beacon.",
    items = {{kind = "button", id = "begin", label = "LET'S GO"}}
  })
end)

on("ui", function(p, id, value)
  if id == "begin" then close(p) end
end)
```

## Room events

| Event | Handler arguments and meaning |
| --- | --- |
| `start` | `function()` once when the copy starts, before players enter |
| `enter` | `function(player)` when someone joins |
| `leave` | `function(player)` when someone leaves |
| `click` | `function(player, thing)` after the player reaches the clicked thing |
| `walk_on` | `function(player, thing)` when stepping onto a visible thing |
| `walk_off` | `function(player, thing)` when stepping off |
| `timer` | `function(tag)` for an `after` or `every` timer |
| `key` | `function(player, key)` for game keys such as `space`, `w`, `up`, `1` |
| `ui` | `function(player, id, value)` for a button, submitted input or selected friend |
| `window_closed` | `function(player, id)` when the player clicks the native window X |
| `send_failed` | `function(player, room, why)` when a scripted room transfer fails |

`player` contains `id` and `name`. `thing` contains `id`, `name`, `x`, `y`,
`hidden`, and `item` (the id of the item it is an instance of). Most object/player arguments accept a table or its id string; use the
player table for overloaded UI verbs such as `banner(player, text)`.
`players()` additionally provides player `x` and `y`. `player(idOrPlayer)`
returns the current row for just one player in this copy, or nil if absent. Use
that when updating a bounded group in a large room, to avoid constructing a Lua
table for everyone on each tick.
Coordinates are tiles local to the room, starting at `(0, 0)`.

## Scene and players

| Call | Effect or return value |
| --- | --- |
| `things()` | All things in the copy, including hidden stock |
| `named(name)` | Things with that exact name, as an array |
| `at(x, y)` | Things on that tile |
| `players()` | Players currently in the copy |
| `move(thing, x, y[, seconds])` | Move scenery; default glide is one tick, `0` snaps |
| `hide(thing)`, `show(thing)` | Hide scenery or restore it |
| `spawn(thing, x, y)` | Reveal a hidden stock object at a tile |
| `tint(thingOrPlayer, "#rrggbb"[, seconds])` | Tint; optional duration fades it |
| `tint(thingOrPlayer)` | Remove tint |
| `floor(x, y, "#rrggbb")` | Colour a tile of the ground; `floor(x, y)` takes it off, `floor()` clears the whole floor as one effect |
| `walk(player, x, y)` | Request normal walking |
| `teleport(player, x, y)` | Place a player on a tile |
| `size(width, height)` | Resize a copy within its authored room bounds |
| `lock()`, `unlock()` | Close or reopen admission to the current copy |
| `input(mode)`, `input(player, mode)` | Set controls for the copy or one player |

Input modes: `grid` is ordinary walking and queued keys; `keys` sends keys
immediately and disables ordinary walking; `none` ignores movement/game keys.
Enter belongs to chat. Text fields capture their own typing. UI buttons still
work when movement is disabled. Phone walking uses double tap; dragging pans.

The floor is a colour layer on the copy, not scenery: a painted arena needs no
floor things, so a script may colour thousands of tiles (an event still counts
each `floor(x, y, colour)` as one effect; `floor()` is one). Late joiners receive
the whole floor. It is drawn in the isometric view.

Scene changes go through the server's room rules. `spawn` reuses authored stock;
it does not manufacture arbitrary new items. Use unique names when selecting
one object and check that `named(name)[1]` exists.

## Windows and responsive layouts

`window(player, spec)` opens or replaces one window for that player.
`close(player)` removes it. Welcome windows, HUDs and scoreboards are retained
and replayed after scene entry or reconnect; scripts do not need an arrival-delay
timer to display them.

Spec fields: `id`, `title`, `text`, `skin`, `layout`, `items`.
`id` identifies the window in `window_closed`; it is optional, then `nil`.
Window text preserves line breaks. A long window scrolls on small screens.

| Item kind | Fields and interaction |
| --- | --- |
| `button` | `id`, `label`; sends `ui(player, id, nil)` |
| `card` | `id`, `label`, `image` (room asset name/id), `text`, `badge`, `accent` (`#rrggbb`), `disabled`; illustrated action tile |
| `input` | `id`, `label`, `placeholder`; submits trimmed text, up to 40 characters |
| `text` | `text`; preserves paragraphs and newlines |
| `friends` | `id`, optional `label`; selects an online friend, sends their id as value |
| `list` | `rows = {{"name", "value"}, ...}`; display only |

Action ids must be present and unique in a window. Player actions are input to
your rules: validate them against the current game state, including UI events
that arrive after a different screen has opened.

Default windows fit phone and desktop screens. `layout` supports:

- `width`: integer from 240 to 960, in layout pixels.
- `dock`: `center` (default) or `right`. Right-docked flow windows have a draggable desktop header and a scrolling two-column phone panel above combat controls.
- `columns`: 1 or 2 for buttons and cards. Text, lists and fields span all columns.
- `skin`: `auto`, `fit`, or `flow`.
- `phone` and `desktop`: tables overriding these same settings.

Phone layout is selected below 700 CSS pixels wide or 500 high. A small desktop
window can use it too. Players interact with the creator's controls; they do not
edit the creator's UI. Docked desktop windows can be moved by dragging their header.

`skin` at the spec's top level names a thing from the room, including stock.
Its flat voxel view supplies the window artwork. Items can use `x`, `y`, `w`, `h`
in that skin's voxel pixels, with item-level `phone` and `desktop` overrides.
Use `layout.phone.skin = "flow"` to replace precise skin positioning with a
scrolling phone layout. Start with flow layouts for dependable touch controls.

```lua
on("enter", function(p)
  window(p, {
    id = "signals", title = "SIGNALS",
    layout = {width = 600, columns = 2,
      phone = {width = 320, columns = 1, skin = "flow"}},
    items = {
      {kind = "text", text = "Watch the light.\nChoose its colour."},
      {kind = "button", id = "red", label = "RED"},
      {kind = "button", id = "blue", label = "BLUE"}
    }
  })
  every(1, "signals:" .. p.id)
end)

on("window_closed", function(p, id)
  if id == "signals" then stop_timer("signals:" .. p.id) end
end)

on("leave", function(p)
  stop_timer("signals:" .. p.id)
end)
```

The X reports `window_closed` once for the window the player actually dismissed.
Calling `close(player)` from Lua does not emit it. Closing does not automatically
cancel all of a game's timers: stop the relevant timer or mark the screen closed
in your handler, so a subsequent timer does not reopen it.

Window validation refuses invalid fields with an actionable error, for example
`window.items[1].text allows at most 4096 characters`. It does not silently shorten
the window. Limits per spec:

| Field | Maximum |
| --- | --- |
| `title`, item `label`, item `placeholder`, list cell | 80 characters |
| Top-level `text`, each text item's `text` | 4096 characters |
| Window `id`, item `id` | 64 characters |
| Items | 12 |
| Rows per list, cells per row | 12 rows, 3 cells |
| Skin coordinates | Whole numbers from -4096 to 4096; width/height positive |

## Readouts and messages

| Call | Purpose |
| --- | --- |
| `say(text)` / `say(player, text)` | Brief line for everyone / one player |
| `panel(title, rows)` / `panel(player, title, rows)` | HUD with `{{"SCORE", 3}, {"TIME", "0:12"}}` rows |
| `panel("")` | Clear the shared HUD |
| `banner(text[, seconds])` / `banner(player, text[, seconds])` | Brief centered announcement |
| `scoreboard(rows)` / `scoreboard(title, rows)` | Shared score table |
| `scoreboard(player, rows)` / `scoreboard(player, title, rows)` | Personal score table |
| `scoreboard({})` | Clear the shared table |
| `hud(text)` / `hud(player, text)` | Legacy title-only HUD |
| `log(text)` | Server log and private playtest log |

Shared and personal widgets use the most recent update for that player.
These compact readouts still have their original limits: 12 rows, titles of
32 characters, and message/row/log text of 200 bytes. Use a window for paragraphs.

## Timers

`after(seconds, tag)` runs once. `every(seconds, tag)` repeats.
Both emit `timer(tag)`; `stop_timer(tag)` cancels the timer. Reusing a tag replaces
its timer. Timers belong to the copy, not a player, so include the player id
in a tag for personal timers and cancel them on `leave` as appropriate.

`after`: 0 to 3600 seconds. `every`: 0.35 to 3600 seconds. Tags are limited to
64 characters, and there can be 64 timers in a copy. Timers run on the room tick;
they are not a high-frequency animation loop.

## Saved progress and leaderboards

| Call | Purpose |
| --- | --- |
| `save(player, key, value)` / `load(player, key)` | Personal progress in this game |
| `save(key, value)` / `load(key)` | Shared values for this game |
| `save(player, key, nil)` / `save(key, nil)` | Forget a value |
| `score(board, player, value)` | Keep the player's best numeric score |
| `top(board[, n])` | Best rows as `{{name, value}, ...}`, up to 50 |

Keys are 1 to 32 bytes. Values are JSON-compatible numbers, strings, booleans,
arrays or string-keyed tables, up to 2048 encoded bytes. Maximum 64 keys per
player and 256 global keys. Tables must be shallow, not cyclic. A missing key
returns `nil`. A save is immediately readable in the same handler. The server
can refuse oversized or excessive saves; the playtest log reports that refusal.
Leaderboards share the global save budget and retain up to 100 scores within
the same value-size limit. Do not use keys beginning `_board_` for other data.

## Rooms, copies and invitations

| Call | Purpose |
| --- | --- |
| `send(player, roomName)` | Enter a copy of another room in this game |
| `send({p1, p2}, roomName, true[, tag])` | Start a fresh copy for these players |
| `send(player, roomName, code)` | Enter that room's copy by its code |
| `here()` | This copy's four-character code |
| `tag()` | Its creation tag, or `nil` |
| `crowd(roomName)` | Approximate player count across copies, refreshed every two seconds |
| `matches(roomName)` | The running copies of that room, `{{code, players, tag, watch, locked}, ...}`, refreshed with `crowd` |
| `watch(player, roomName, code)` | Into the stands of that copy: no avatar, sees and hears everything; refused where the room's watching is off |
| `invite(player, friendId[, text])` | Invite an online friend into this copy |

Room names must match the game's room names. `send_failed` reasons include
`no_such_room`, `no_such_copy`, and `full`. A copy's lock blocks ordinary admission,
but a valid invitation can enter if capacity permits. Each room still has its
own Lua state. Use saves to share progress across rooms.

## Lua library and limits

Useful library functions include `pairs`, `ipairs`, `type`, `tonumber`,
`tostring`, `assert`, `error`, `pcall`, `math`, `table`, and `string`.
`string.gmatch(text, pattern[, start])` supports iteration, captures, position
captures and empty matches. Eggox supplies this iterator because Luerl 1.5's
built-in function is a stub. Patterns follow Lua syntax, not regular expressions.

```lua
on("start", function()
  for word in string.gmatch("moon wave star", "%a+") do log(word) end
end)
```

This is a sandbox, not a full desktop Lua installation. `os`, `io`, `package`,
`require`, `dofile`, `loadfile`, `loadstring`, `debug`, `print`, `eprint`,
`collectgarbage`, and `string.dump` are unavailable. The global `load` is Eggox's
saved-progress function, not Lua's code loader. `string.rep` is capped at 65,536
bytes. Other library details follow the installed Luerl implementation.

Scripts have a 65,536-byte source limit (the Studio keeps a room's script as up
to eight files of 16,384 bytes each, compiled into one) and at most 256 effects
per event. Each file is its own block: `local` names stay in their file; share
through globals or handlers.
Ordinary item scripts have an 8 MiB heap ceiling and a 20 ms handler deadline.
Published rooms add bounded allowances for their configured capacity. Script validation
has a one-second deadline. A runaway handler is killed; the container restarts
that script up to three times, clearing its timers. Repeated failure stops it.
Use Restart in the playtest controls after fixing the draft.

## Thing scripts

The server-owned interactive-furniture API also accepts a smaller script type.
Its events are `walk_on`, `walk_off`, and `click`, each receiving the player.
Its verbs are `set_state(name)`, `get_state()`, and `log(text)`, plus `on` and the
sandboxed Lua library. State names are at most 24 bytes. It does not receive
room verbs. Ordinary creator experiences use the room API above.

## Implementation and examples

The API is implemented in `server/lib/eggox/scripts/runtime.ex`; window validation
is in `server/lib/eggox/scripts/window_spec.ex`. Executable reference examples
are checked by the test suite. Responsive rendering is described further in
`docs/19-responsive-experience-ui.md`.

Lua pattern semantics: https://www.lua.org/manual/5.4/manual.html#6.4.1

## Costumes, combat feedback and interactive readouts

An experience can temporarily dress a player using native artwork in that room
(visible placements or hidden stock). Use a placement id, an asset's name, or its
thing table. Keep the costume assets in every room that uses them.

`costume(player, {character="Ninja", body="White Belt", hand="Katana", companion="Fox"})`
sets the visual layer. Slots are `character`, `head`, `body`, `legs`, `hand` and
`companion`. `costume(player, nil)` removes it. Each asset keeps its placement's
filter and selected version. Matching wearable and character grids compose frame
for frame, exactly as in the wardrobe. The player's own hat stays unless the
creator supplies `head`; other outfit slots come from the costume.

The player's inventory and real equipped items never change. **Hide costume**
restores their own appearance and suppresses scripted visual effects and command
animations; gameplay continues. Keep earned rank and equipped game items in saved
script data, so cosmetic visibility never becomes an access check. Leaving the
experience restores the real wardrobe automatically. A `companion` is a small
following native character asset, visible while the costume is shown.

`effect(player, spec)` broadcasts short feedback near a thing or player:

- `kind`: `hit`, `burst` or `rise`.
- `style`: `plain` (default) or `comic`, with outlined lettering and a jagged burst.
- `target`: optional visible thing/player id or table; defaults to the actor.
- `animation`: optional command clip on the character, such as `strike`.
- `text`: optional floating text, at most 40 characters.
- `color`: `#rrggbb`.

Clients bound simultaneous effects and skip distant remote effects. This is
presentation only. Validate range, cooldowns, damage and rewards in the script.
An authored command turns the character toward its target and finishes before
the next command starts. Repeated effects retain only the latest pending command,
so rapid input cannot continually restart a windup or build an animation backlog.
The associated visual impact plays midway through the clip. Author anticipation,
contact and recovery around that timing. Events without a matching clip play
their effect immediately. Costume changes and departures clear pending commands.
`burst` feedback displays immediately even with an animation command, so a
finishing cue or reward cannot be lost behind queued swings. Its character
gesture still plays. A recent hit whose contact frame was skipped during loading
is delivered once; stale combat after a background-tab wakeup is discarded.

These are temporary world-space visuals, not inventory items. Text can be a word,
a computed damage number or another short value. Comic labels sit above the whole
target, including multi-tile objects, while sparks appear on its body. Clients
limit the room to 32 active effects (16 for remote effects) and two labels per
target. Nearby players can see comic hits; local feedback gets label priority.

```lua
on("click", function(p, monster)
  effect(p, {target=monster, animation="strike", kind="hit",
    style="comic", text="SLASH!", color="#ffd27b"})
  -- Or show a number computed by your damage logic:
  -- effect(p, {target=monster, kind="rise", text="-" .. tostring(damage)})
end)
```

### Animating placed things

Room scripts can call `set_state(thing, name)` to play a state authored in that
native item's Behavior. Thing scripts retain the one-argument `set_state(name)`.
A room can only change its own visible or stock things. Names are 1–24 bytes;
unknown names fall back to the item's initial state. Repeating a state restarts
it, so throttle hit reactions if the current pose should finish first.

All depth columns follow one clock. A looping state repeats; a one-shot follows
its authored `onEnd` state, or holds its final pose when there is no `onEnd`.
For example, author `hurt → idle`, `defeated` with no successor, and
`recover → idle`, then call:

```lua
local function flinch(monster) set_state(monster, "hurt") end
-- Call when your server-side HP reaches zero:
local function defeat(monster) set_state(monster, "defeated") end
-- Call later, from your respawn timer:
local function revive(monster) set_state(monster, "recover") end
```

The visual state and start time travel with the thing in snapshots and survive
hide/show. Late joiners see the current pose. This does not change collision,
inventory or HP, and it is independent of players hiding their costumes. Keep
gameplay state in Lua and time the recovery before accepting another attack.

### Interactive HUD

`panel(player, spec)` adds an interactive HUD alongside the existing panel forms:

```lua
on("ui", function(p, id)
  if id == "open_training" then
    panel(p, {
      title = "TRAINING",
      rows = {{"SPIRIT", load(p, "spirit") or 0}},
      meter = {label = "Guardian", value = 75, max = 100},
      actions = {{id = "strike", label = "STRIKE"}, {id = "shop", label = "UPGRADES"}}
    })
  end
end)
```

Up to six rows and six uniquely named buttons are allowed. Buttons fire `ui`,
like window buttons. Meter values are non-negative with a positive maximum.
HUDs replay after reconnect; touch controls retain usable tap targets.

Set `layout="combat"` for a compact bottom action dock above chat. Each action
may name an `icon`: `sword`, `forge`, `belt`, `map`, or `leaf`. Keep rows brief
(three small stats work well on a phone). With this layout, `meter.target` may
be the placement id of a visible room object, such as `guardian.id`. Its health
bar follows the complete object's lower edge while the camera moves or zooms.
Hidden objects and placements in other rooms have no label. Costume visibility
does not affect this readout. Omit combat layout to retain the original readout.

Card images resolve only to scenery or stock in the current room, never remote
URLs or another player's inventory. The client loads visible cards through the
bounded preview worker queue and preserves the artwork's filter and version.
Locked cards remain visible with their requirements. `disabled` prevents normal
clicks; your script must still check purchases and unlocks on the server.

`now()` returns server Unix time in seconds, with fractional precision. It can be
saved for cooldowns or offline crop growth; it is not the client's clock.

## Public and private copies

`send(player, "Room", "private"[, tag])` finds or creates that player's personal
copy. Only one player may be sent by this form. Other players cannot enter it,
join by code, receive invitations to it, or spectate. The copy retains the same
game save namespace as public play. `send(player, "Room", "public")` returns to
normal public matching. These explicit public/private modes preserve the game’s
outer exit while travelling between its rooms. The existing boolean fresh-copy and four-letter code
forms still work. A fresh public copy is not private.

Rooms may configure up to 1,000 places. Their bounded Lua memory and event-time
allowances grow with configured capacity; the ceiling does not preallocate
memory. Batch work in large games, especially effects and HUD updates. Capacity
is an admission setting, not a guarantee of client rendering performance.
Queued script effects and the `leave` handler's saves finish before a departing
player can load their progress in the destination.


## Real reward shops inside experiences

Place a normal player stall in an authored room to sell real items that stay in
the buyer's inventory outside the experience. Stock it with minted editions,
set the price to **0** for a free claim, and set **Per player** to 1 for a
once-per-player reward (0 in the editor means unlimited). Limits count actual
items, including bundle quantities, for that stall and lineage across versions,
restocks, public/private copies, reconnects and resale. A failed purchase consumes
neither stock, money nor allowance. Stock is shared by all copies of the room.

The creator can bind the stall to saved progression using the authenticated
`stall:access` channel operation. Example JSON (creator owns both stall and game):

```json
{"id":"stl_example","rules":{"experience_id":"xp_root","save_key":"progress","save_field":"rank","minimum":10}}
```

The buyer must meet the persisted numeric requirement and stand within three
tiles of the visible stall in a published copy of that game. Knowing a listing id
or opening its shop window grants no purchase access. `rules:null` removes the
requirement; only the stall owner may configure it. Use the root experience id
for games with several rooms. Private and public copies share the same save.

Normal stall UI handles the purchase, with an explicit **Claim free** or **Buy**
button. Lua cannot debit wallets or mint transferable rewards. Creator-owned,
finite stock supplies the rewards, not the temporary objects cloned into a room.
Persistent purchase totals are separate from script saves, so scripts cannot
reset a player's claim allowance.
