Perch
Lua APISignals and timers

Signals and timers

How signals connect, the lifecycle signals, timers, callbacks, an index of every provider signal, and the signal and timer types. Always granted.

6 min readUpdated Sep 9, 2026

Perch never calls a function by name. Every event is a signal you connect a function to, and the connection is what subscribes you. A signal is named on_ plus what happened, and there is one signal per event.

perch.on_start:connect(function() ... end)
perch.media.on_change:connect(function(m: media_state_t) ... end)
w.on_visibility:connect(function(visible: boolean) ... end)
tick.on_fire:connect(function() ... end)

Handlers receive only their payload; their return value is ignored. Several handlers on one signal run in connection order. Connecting inside a handler is safe: the new handler runs from the next fire. Every payload has a named type; annotate the parameter with it and Studio completes the fields.

Connecting and disconnecting

MethodMeaning
signal:connect(fn): connection_tattach a handler
signal:once(fn): connection_tattach a handler that detaches itself before its first run
connection:disconnect()detach
connection:connected(): booleanwhether it is still attached

connect with anything but a function raises connect: expected a function, got <type>.

local conn: connection_t
conn = perch.media.on_change:connect(function(m: media_state_t)
  if m.has_player then
    seed(m)
    conn:disconnect()   -- only the first real state was wanted
  end
end)

Lifecycle

perch.on_configure: signal_t<any>
perch.on_start: signal_t<>
perch.on_stop: signal_t<>

perch.on_configure fires once before perch.on_start with the mod’s stored settings, keyed by the row keys you declared, and again on every change the user commits. Annotate the parameter with your own settings type. It is the place to diff a settings.list into widgets.

perch.on_start fires when the mod is live: every register, present and push made earlier has been applied. Declare presence, start timers and drive settings rows here.

perch.on_stop fires when the mod is stopping: Perch is quitting, or every widget was disabled. Timers are cleared afterwards; a request in flight is dropped and its callback never runs.

type settings_t = { zone: string?, fmt24: boolean? }

perch.on_configure:connect(function(s: settings_t)
  zone = s.zone or perch.time.zone()
end)

perch.on_start:connect(function()
  w:present(payload())
  tick:start()
end)

Timers

Timers are the only way a mod wakes itself up. There is no per-frame hook and no tick; for text that changes every second use ui.ticker, which runs on the view side.

timer.new

timer.new(ms: number, opts: timer_options_t?): timer_t

A timer handle, not yet running. ms is the period, floored to 250 ms for a repeating timer and 16 ms for a one-shot ({ once = true }). A non-positive period raises.

local poll = timer.new(60 * 1000)
poll.on_fire:connect(function() refresh() end)

perch.on_start:connect(function() poll:start() end)

local settle = timer.new(500, { once = true })
settle.on_fire:connect(function() w:push({ ready = true }) end)
settle:start()

timer.at

timer.at(hour: number, minute: number, opts: timer_at_options_t?): timer_t

A one-shot for a wall-clock time: it fires at that time today, or tomorrow if it has passed. hour is 0..23, minute 0..59; { zone = "Europe/Berlin" } reads the clock in that zone. Every :start() recomputes the delay from the clock, so re-arming the handle inside its own handler fires at the same time the next day.

local alarm = timer.at(7, 30)
alarm.on_fire:connect(function()
  w:present({ line = "Good morning" })
  alarm:start()      -- tomorrow, same time
end)
alarm:start()

The handle

MemberMeaning
t.on_firesignal_t<>; fires each period with no payload
t:start(ms?)arm it, or re-arm it (restarts the period; the argument changes it)
t:stop()disarm it; connections survive a later start
t:running()whether it is armed

Timers are cleared when the mod stops.

Callbacks

A call that has to wait takes its callback as the last argument and returns nothing. HTTP calls back exactly once with (res, err); a Steam lookup calls back at once when Perch already knows the answer and when it lands otherwise. Both run on the main thread. See Net and Steam.

perch.net.http.get(url, function(res: http_response_t?, err: string?)
  if not res then perch.log(err) return end
  w:push({ body = res.body })
end)

Every signal

Each row exists only when the scope in the second column is declared. The payload types are on the library pages.

SignalScopePayloadFires
perch.on_configurealwaysyour settings tablebefore start, and on every settings change
perch.on_start, perch.on_stopalwaysnonethe mod goes live, or stops
w.on_visibilityalwaysbooleanthe page card comes on screen or leaves
t.on_firealwaysnonea timer period elapses
perch.media.on_changemediamedia_state_tnow-playing state changed
perch.discord.on_calldiscorddiscord_call_ta call was joined or left
perch.discord.on_notificationdiscorddiscord_notification_ta message notification
perch.discord.on_ringdiscorddiscord_ring_tan incoming call started ringing
perch.discord.on_statusdiscorddiscord_state_tthe link status changed
perch.weather.on_changeweatherweather_state_ta forecast arrived or the unit changed
perch.steam.on_game_changesteamgame_change_ta game started or stopped
perch.steam.on_app_info, on_artwork, on_store_details, on_news, on_player_count, on_player_history, on_searchsteamthe matching steam_*_ta lookup landed
perch.audio.volume.on_changesystem.transientsvolume_state_toutput volume or mute changed
perch.system.display.on_brightness_changesystem.transientsbrightness_state_tdisplay brightness changed
perch.system.display.on_night_light_changesystem.transientsnight_light_state_tnight light toggled
perch.system.connectivity.on_changesystem.transientsconnectivity_event_ta device connected or disconnected
perch.system.timers.on_set, on_tick, on_done, on_cancelsystem.transientstimer_set_t, timer_tick_t, timer_done_t, nonea voice timer was set, counts down, finished, was cancelled
perch.system.power.on_changesystem.powerpower_event_ta battery threshold or a device with a battery
perch.system.downloads.on_progresssystem.downloadsdownload_progress_ta browser download progressed
perch.system.notifications.on_notificationsystem.notificationssystem_notification_ta Windows notification was mirrored
perch.system.clipboard.on_changesystem.clipboardstringthe clipboard changed
perch.apps.on_focus_changeappsfocused_app_tthe foreground application changed
handle.on_pressinput.hotkeysnonea claimed shortcut was pressed
channel.on_messagemods.messagingT, stringanother mod published on that channel
socket.on_messagenet.websocket:<host>stringa complete message arrived
socket.on_closenet.websocket:<host>number, stringthe socket closed, cleanly or by failing

Types

export type signal_t<T...> = {
	connect: (self: signal_t<T...>, handler: (T...) -> ()) -> connection_t,
	once: (self: signal_t<T...>, handler: (T...) -> ()) -> connection_t,
}

Every on_* in the API. T... is what the handler receives: signal_t<media_state_t> hands over one table, signal_t<number, string> two values, signal_t<> nothing.

export type connection_t = {
	disconnect: (self: connection_t) -> (),
	connected: (self: connection_t) -> boolean,
}

What connect and once return.

export type timer_t = {
	on_fire: signal_t<>,
	start: (self: timer_t, ms: number?) -> timer_t,
	stop: (self: timer_t) -> timer_t,
	running: (self: timer_t) -> boolean,
}
export type timer_options_t = { once: boolean? }
export type timer_at_options_t = { zone: string? }

The timer handle and the option tables of timer.new and timer.at.

declare timer: {
	new: (ms: number, opts: timer_options_t?) -> timer_t,
	at: (hour: number, minute: number, opts: timer_at_options_t?) -> timer_t,
}
esc
Type to search
navigate open