Perch
Building modsRuntime and lifecycle

Runtime and lifecycle

What runs when: load, configure, start, live, stop. Where to declare things, the budget, idle cost, and how failures behave.

5 min readUpdated Sep 9, 2026

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

WhatWhereWhy
static widgets, with their fields and :previewtop level of the entry filerecorded before the package constructs, so they exist in the gallery even before presence
the settings pagetop level of the entry filethe rows must exist before the boot perch.on_configure
signal connections and intent hookstop level of the entry fileconnect once; a later connection misses what came before
widgets that depend on a setting (one per list row)inside perch.on_configureit runs first with the stored list, and again on every change
presence, timer:start(), driving settings rowsinside perch.on_startthe mod is live
anything expensiveperch.on_start or a timerthe 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_visibility says 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:push is rate-limited to 40 per 4 seconds; over that, pushes are merged and delivered a moment later.

When things go wrong

FailureBehaviour
a syntax error, or a builder raising on a bad propthe mod is skipped; the card and the log window show the error with file and line
a manifest whose api is not 3skipped with mod.json api must be 3
an unknown component, prop or value, or a tree over the limitsrejected at load with the exact path, never at render
a push, present or preview with an undeclared key or a wrong typethe call raises; in a handler that is one logged failure, at load it skips the mod
a handler raiseslogged as mod[<id>] <handler> failed: <error>; the mod keeps running
a handler exceeds the budgetaborted; strike 1 of 3
a runaway w:pushparked and merged per key, delivered within 250 ms
an HTTP request to a host you did not pin, or over the ratethe callback receives nil and "scope" or "rate"
a handle for a widget you do not ownignored, one warning in the log
a bad theme valuethe theme is rejected and never reaches the picker
anything elseyour Luau state alone is affected; the overlay and other mods never notice

The sandbox itself is described on The Lua API.

esc
Type to search
navigate open