Perch
Building modsSettings

Settings

The settings page a mod declares: the keyed table, every row kind and its options, the value each one delivers, the rows a mod drives itself, and the settings types.

8 min readUpdated Sep 9, 2026

A mod’s settings page is declared in Luau with perch.settings.declare, passing a table keyed by setting name whose values are settings.* constructors. Perch renders the rows under Settings, Features, stores the values, and hands them to perch.on_configure before perch.on_start and on every change. The call returns the same keys as row objects, which is how a mod drives the rows that show or act instead of storing.

--!strict
type settings_t = { unit: string?, trend: boolean?, games: { { appid: string? } }? }

local rows = perch.settings.declare {
  __order = { "unit", "trend", "games" },
  unit = settings.choice("Unit", { options = { "metric", "imperial" }, default = "metric" }),
  trend = settings.toggle("Show the trend line", { default = true }),
  games = settings.list("Games", { add_label = "Add game", item = { appid = settings.game("Game") } }),
}

perch.on_configure:connect(function(s: settings_t)
  -- s.unit == "metric", s.trend == true, s.games == { { appid = "730" }, ... }
end)

Declare at the top of the entry file so the rows exist before the boot on_configure. Declaring again replaces the row set; stored values survive.

The Steam Player Count settings page: a Games list with three picked games and a toggle

perch.settings.declare

perch.settings.declare<R>(rows: R): R

The table key is the setting key: the name the value arrives under in on_configure and the storage key. A Luau table has no order, so pass __order = { "key", ... } to fix the page order; keys it omits are appended after the ones it names, and a key that was never declared raises perch.settings.declare: __order names '<key>', which is not a declared row.

Every constructor is settings.<kind>(label, opts?). A missing label raises (settings.toggle: label (arg 1) must be a non-empty string); a key that is not an identifier, a value that is not a row, or an option the kind does not have raise at the call too.

Options every kind accepts:

OptionMeaning
defaultthe value before the user touches the row. Set it for anything but a toggle
inline_with_previousrender this control in the previous row (a text field followed by its choice chips); not allowed after a note

Rows that store a value

ConstructorValue in on_configureNotes
settings.toggle(label, opts?)boolean
settings.slider(label, opts)numbermin, max, step (default 0.01). Fires while the user drags, so keep the handler light
settings.choice(label, opts)string, one of optionsrendered as chips
settings.text(label, opts?)string, up to 1,000 charactersplaceholder; committed on Enter or blur
settings.game(label, opts?)the appid as a string of digits, or ""a picker over the installed library and Steam store search
settings.launch(label, opts?)a launch_target_t, all fields "" when nothing is pickeda website, a Steam game or an installed app, with its icon resolved at pick time; options restricts the types
settings.timezone(label, opts?)an IANA zone name, or ""a searchable list of the OS zone database
settings.ticker(label, opts?)a symbol ("AAPL", "^GDAXI", "crypto:bitcoin"), or ""Yahoo Finance symbols and CoinGecko coins
settings.list(label, opts)an array of row tables, keyed like itemsee below
work_min = settings.slider("Work minutes", { min = 15, max = 60, step = 5, default = 25 }),
unit = settings.choice("Unit", { options = { "metric", "imperial" }, default = "metric" }),
name = settings.text("Display name", { default = "", placeholder = "Your name" }),
appid = settings.game("Game"),
target = settings.launch("Open", { options = { "steam", "app" } }),
zone = settings.timezone("Zone"),
symbol = settings.ticker("Symbol", { default = "AAPL" }),

Pair settings.game with perch.steam, settings.launch with perch.open, settings.timezone with perch.time.

The World clock settings page with three time zone rows

settings.list

A repeating group of rows with an Add button and per-row remove. This is how “a dynamic number of things” is configured: one game per row, one shortcut per row.

OptionMeaning
itema table keyed like declare itself, describing one row; any kind except a list or a button
add_labelthe Add button’s text, default "Add"
maxthe row cap, 1 to 200, default 64
slots = settings.list("Shortcuts", { add_label = "Add shortcut", max = 12, item = { target = settings.launch("Open") } }),
type settings_t = { slots: { { target: launch_target_t? } }? }

perch.on_configure:connect(function(s: settings_t)
  for i, row in ipairs(s.slots or {}) do
    -- row.target.type, row.target.ref, row.target.label, row.target.icon
  end
end)

A list whose single item is a settings.game or settings.launch row makes Add open the picker directly. Diff the array in perch.on_configure to add and remove widgets, as in Widgets.

The Shortcuts settings page with an empty list and an Add shortcut button

Rows that show or act

These carry no value and never appear in on_configure. The mod drives them through the row objects declare returns.

ConstructorWhat it isVerbs
settings.status(label)a live chip: text and a green or red staterow:set(text, ok)
settings.button(label, text, on_click)a button that runs a functionrow:set_text(text), row:set_enabled(bool)
settings.note(label, opts)a full-width instruction block: text (each \n a new line), links ({ label, url } chips that open the browser, or { label, copy } clipboard chips), step (a numbered card), image (a package-relative screenshot, at most 600 KB)

Every row answers row:set_visible(bool). Row state is kept by Perch and merged into the settings page, so a page opened later shows what the mod last set.

The builtin Discord page is the reference: a status chip, numbered step cards with the Application ID field between them, and Connect and Disconnect buttons that swap on the link state.

--!strict
local rows = perch.settings.declare {
  __order = { "link", "step1", "app_id", "step2", "connect", "disconnect" },
  link = settings.status("Discord account"),
  step1 = settings.note("Create your Discord app", {
    step = 1, image = "guide/new-app.png",
    text = "Open the developer portal, click New Application and create it.",
    links = { { label = "Open the developer portal", url = "https://discord.com/developers/applications" } },
  }),
  app_id = settings.text("Application ID", { default = "" }),
  step2 = settings.note("Let Perch sign in", {
    step = 2,
    text = "On the OAuth2 page add this redirect and save.",
    links = { { label = "Copy http://127.0.0.1", copy = "http://127.0.0.1" } },
  }),
  connect = settings.button("Connection", "Connect", function() perch.discord.connect() end),
  disconnect = settings.button("Sign out", "Disconnect", function() perch.discord.disconnect() end),
}

local function sync_rows(connected: boolean)
  rows.link:set(if connected then "Connected" else "Not connected", connected)
  rows.step1:set_visible(not connected)
  rows.step2:set_visible(not connected)
  rows.connect:set_visible(not connected)
  rows.disconnect:set_visible(connected)
end

perch.on_start:connect(function() sync_rows(perch.discord.read().connected) end)
perch.discord.on_status:connect(function(s: discord_state_t) sync_rows(s.connected) end)

The Discord settings page: a status chip, numbered step cards with screenshots and chips, with the Application ID field between them

Validation and storage

Perch validates every value the settings window tries to store against the row’s kind: a toggle must be a boolean, a slider a number within its range, a choice one of its options, text at most 1,000 characters, a game id digits only, a URL https://, a list an array within max whose rows all validate. A value that fails is not stored, so on_configure only ever sees well-formed data. Values live under the mod’s own config subtree; uninstalling a mod does not delete them.

Types

export type row_t = { set_visible: (self: row_t, visible: boolean) -> row_t }
export type toggle_row_t = row_t
export type slider_row_t = row_t
export type choice_row_t = row_t
export type text_row_t = row_t
export type game_row_t = row_t
export type launch_row_t = row_t
export type timezone_row_t = row_t
export type ticker_row_t = row_t
export type list_row_t = row_t
export type note_row_t = row_t
export type status_row_t = { set_visible: (self: status_row_t, visible: boolean) -> status_row_t, set: (self: status_row_t, text: string, ok: boolean) -> status_row_t }
export type button_row_t = { set_visible: (self: button_row_t, visible: boolean) -> button_row_t, set_text: (self: button_row_t, text: string) -> button_row_t, set_enabled: (self: button_row_t, enabled: boolean) -> button_row_t }

The row objects declare returns, one type per kind.

export type row_options_t = { inline_with_previous: boolean? }
export type toggle_options_t = row_options_t & { default: boolean? }
export type slider_options_t = row_options_t & { min: number, max: number, step: number?, default: number? }
export type choice_options_t = row_options_t & { options: { string }, default: string? }
export type text_options_t = row_options_t & { default: string?, placeholder: string? }
export type launch_options_t = row_options_t & { options: { string }? }
export type ticker_options_t = row_options_t & { default: string? }
export type list_options_t = row_options_t & { item: { [string]: row_t }, add_label: string?, max: number? }
export type note_options_t = row_options_t & { text: string, links: { link_t }?, step: number?, image: string? }
export type link_t = { label: string, url: string } | { label: string, copy: string }

The option tables, and a note’s chip.

export type launch_target_t = { type: "steam" | "url" | "app", ref: string, label: string, icon: string }

What a settings.launch row stores; see Open.

declare settings: {
	toggle: (label: string, opts: toggle_options_t?) -> toggle_row_t,
	slider: (label: string, opts: slider_options_t) -> slider_row_t,
	choice: (label: string, opts: choice_options_t) -> choice_row_t,
	text: (label: string, opts: text_options_t?) -> text_row_t,
	game: (label: string, opts: row_options_t?) -> game_row_t,
	launch: (label: string, opts: launch_options_t?) -> launch_row_t,
	timezone: (label: string, opts: row_options_t?) -> timezone_row_t,
	ticker: (label: string, opts: ticker_options_t?) -> ticker_row_t,
	list: (label: string, opts: list_options_t) -> list_row_t,
	status: (label: string) -> status_row_t,
	button: (label: string, text: string, on_click: () -> ()) -> button_row_t,
	note: (label: string, opts: note_options_t) -> note_row_t,
}
esc
Type to search
navigate open