The timeline
Perch boots (or Run in Perch reloads)
│
├─ 1. load mod.json is parsed, the globals are injected, the entry file runs once
│ widget:register() and perch.settings.declare calls are recorded
│ display functions run once against the field refs; every tree is validated
│ a failure here skips the mod and shows the error on its card
│
├─ 2. configure perch.on_configure fires with the stored settings
│ register, present and push made here queue until start
│
├─ 3. start perch.on_start fires: presence, timers, row state
│
├─ 4. live timer on_fire, w.on_visibility, the node functions,
│ settings button functions, voice intents,
│ perch.on_configure again on every settings change,
│ provider signals, request callbacks
│
└─ 5. stop perch.on_stop fires: Perch quits or the mod is disabled
timers cleared, in-flight requests dropped
A mod whose widgets are all below the divider in the Priority Stack and that has no page placement is stopped entirely. Dragging a row back up starts it again.
Declaring things at the right time
| What | Where | Why |
|---|---|---|
static widgets, with their fields and :preview | top level of the entry file | recorded before the package constructs, so they exist in the gallery even before presence |
| the settings page | top level of the entry file | the rows must exist before the boot perch.on_configure |
| signal connections and intent hooks | top level of the entry file | connect once; a later connection misses what came before |
| widgets that depend on a setting (one per list row) | inside perch.on_configure | it runs first with the stored list, and again on every change |
presence, timer:start(), driving settings rows | inside perch.on_start | the mod is live |
| anything expensive | perch.on_start or a timer | the entry file shares the handler budget |
State
File-level locals live for the mod’s lifetime: the widget and timer handles, a table of verbs the display functions call at click time, whatever mirrors a setting. One Luau state per mod, so nothing is shared. State that must survive a restart goes to perch.storage.
local games: { [string]: game_t } = {} -- appid -> the handle and its data
local trend: boolean = true -- mirrors a setting, refreshed by perch.on_configure
local act: displays.act_t = {} -- filled after the trees are built; displays call act.play_pause() at click time
A display function runs inside w:place, usually before the verbs it needs exist, so a button that captured act.play_pause at build time would capture nil. on_click = function() act.play_pause() end looks it up when the click happens.
The budget
Each handler call may run for 50 ms of wall clock; the whole state may use 64 MiB. A call over budget aborts with budget: callback exceeded its time allowance and counts a strike; a clean call resets strikes; three in a row disable the mod until Perch restarts. Ordinary errors are logged per call and never disable anything.
The budget is generous for view-ready computation. Mods that hit it are usually formatting whole payloads on every perch.media.on_change (twice a second while playing), rebuilding large tables per tick, or concatenating strings in a loop over list data. Building ui.* trees inside a handler is another way to waste it: trees are built once at load, never per push.
Idle cost
Perch runs all day on gaming machines and must cost nothing at rest, so idle mods are free by construction:
- nothing wakes your Luau except a timer you started, a signal you connected to, a request that landed, or a user action;
- repeating timers floor at 250 ms, one-shots at 16 ms;
w.on_visibilitysays when your page card is on screen: poll then, and stop the timer when it leaves;- live time (
ui.ticker), the seconds scale (ui.gauge) and glide animations run on the view side; w:pushis rate-limited to 40 per 4 seconds; over that, pushes are merged and delivered a moment later.
When things go wrong
| Failure | Behaviour |
|---|---|
| a syntax error, or a builder raising on a bad prop | the mod is skipped; the card and the log window show the error with file and line |
a manifest whose api is not 3 | skipped with mod.json api must be 3 |
| an unknown component, prop or value, or a tree over the limits | rejected at load with the exact path, never at render |
| a push, present or preview with an undeclared key or a wrong type | the call raises; in a handler that is one logged failure, at load it skips the mod |
| a handler raises | logged as mod[<id>] <handler> failed: <error>; the mod keeps running |
| a handler exceeds the budget | aborted; strike 1 of 3 |
a runaway w:push | parked and merged per key, delivered within 250 ms |
| an HTTP request to a host you did not pin, or over the rate | the callback receives nil and "scope" or "rate" |
| a handle for a widget you do not own | ignored, one warning in the log |
| a bad theme value | the theme is rejected and never reaches the picker |
| anything else | your Luau state alone is affected; the overlay and other mods never notice |
The sandbox itself is described on The Lua API.