Perch
Building modsA complete mod

A complete mod

Steam Player Count, read line by line: a settings list, one widget per game with a shared schema, Steam lookups with callbacks, a trend line, and a Workshop preview.

7 min readUpdated Sep 9, 2026

A real, published package: the user picks games in the mod’s settings, each becomes a card with the live player count and a 48-hour trend, and removing a game removes its card. It uses a settings.list of settings.game rows, widget handles created and removed on the fly, the steam library’s callback lookups, a when-gated sparkline, and a preview.luau for the Workshop thumbnail. Every file is --!strict.

mod.json

{
  "api": 3,
  "id": "steam-player-count",
  "title": "Steam Player Count",
  "version": "0.7",
  "description": "Live player counts for the Steam games you pick - one widget per game.",
  "entry": "main.luau",
  "preview": "preview.luau",
  "scopes": ["steam"]
}

steam installs perch.steam. No network scope: Perch fetches from Steam itself.

displays.luau

--!strict
--- One game widget's fields. `chart` binds whole to ui.sparkline.
export type fields_t = {
	game: field_ref_t<string>,
	count_text: field_ref_t<string>,
	icon: field_ref_t<string>,
	trend: field_ref_t<boolean>,
	chart: record_ref_t<chart_record_t>,
}

--- The schema every game widget (and the preview) declares.
local function schema(): fields_t
	return {
		game = field.string(""),
		count_text = field.string("-"),
		icon = field.image(),
		trend = field.boolean(true),
		chart = field.record { points = field.numbers(), up = field.boolean(true) },
	}
end

local function card(f: fields_t): node_t
	return ui.column { gap = ui.gap.xs, flex = 1, pad = { 4, 10, 4, 10 },
		ui.row { gap = ui.gap.s, align = ui.align.center,
			ui.image { src = f.icon, w = 24, h = 24, radius = 6, flex = 0, fit = ui.fit.cover,
				fallback = ui.box { bg = ui.bg.well, w = 24, h = 24, radius = 6 } },
			ui.text { text = f.game, role = ui.role.h2 },
		},
		ui.text { text = f.count_text, role = ui.role.huge, size = 34, color = ui.color.accent_warm },
		ui.text { text = "players online", role = ui.role.label, color = ui.color.half },
		ui.sparkline { points = f.chart, h = 22, flex = 0, when = f.trend },
	}
end

return { schema = schema, card = card }

The schema lives beside the display that binds it, so main.luau and preview.luau both call displays.schema() and can never drift from the fields_t type. The ui.image has a fallback box that shows until the icon lands; the sparkline is gated on the trend toggle.

The Steam Player Count card: game icon, name, a large count and a trend line

main.luau

--!strict
local displays = require("displays")

type game_row_t = { appid: string? }
type settings_t = { games: { game_row_t }?, trend: boolean? }
type chart_t = { points: { number }, up: boolean }
type payload_t = { game: string?, count_text: string?, icon: string?, trend: boolean?, chart: chart_t? }
type tracked_t = { w: widget_t<displays.fields_t>, data: payload_t }

perch.settings.declare {
	__order = { "games", "trend" },
	games = settings.list("Games", { add_label = "Add game", item = { appid = settings.game("Game") } }),
	trend = settings.toggle("Show the trend line", { default = true }),
}

local games: { [string]: tracked_t } = {}
local trend: boolean = true
local started: boolean = false

The settings page: a list whose single item is a settings.game row, so Add opens the game picker directly, and a toggle. s.games arrives as { { appid = "730" }, { appid = "440" } }. Each tracked game keeps its handle and the whole of its current data.

The mod settings page with three games picked

local function chart_of(history: { number }): chart_t
	local n = #history
	return { points = history, up = n < 2 or history[n] >= history[n - 1] }
end

-- Merge a partial update into the game's data and show it. Before on_start a
-- present carries the whole state; after it, a push of just the change.
local function apply(appid: string, delta: payload_t)
	local g = games[appid]
	if not g then
		return
	end
	for k, v in pairs(delta) do
		(g.data :: any)[k] = v
	end
	if started then
		g.w:push(delta)
	else
		g.w:present(g.data)
	end
end

apply is the one place data moves: it merges the change into the game’s state and pushes it. The up flag is decided in Luau; the tree binds a ready chart record.

local function new_widget(appid: string, name: string): widget_t<displays.fields_t>
	local w = widget.new { id = "g" .. appid, label = name, urgency = urgency.ambient, fields = displays.schema() }
	w:preview { game = name, count_text = "1,436,921", icon = "", trend = true, chart = { points = { 1, 8, 3, 4, 2, 8, 4, 12 }, up = true } }
	w:place(surface.page, displays.card)
	w:register()
	return w
end

local function on_count(pc: steam_player_count_t)
	apply(pc.appid, { count_text = perch.text.number(pc.count) })
end

local function on_history(h: steam_player_history_t)
	apply(h.appid, { chart = chart_of(h.points) })
end

local function on_artwork(art: steam_artwork_t)
	if art.icon ~= "" then
		apply(art.appid, { icon = art.icon })
	end
end

local function on_app_info(info: steam_app_info_t)
	local g = games[info.appid]
	if g and info.name ~= "" and info.name ~= g.data.game then
		g.w:relabel(info.name)
		apply(info.appid, { game = info.name })
	end
end

One typed handler per answer. Each ignores a game the user removed meanwhile.

local function add_game(appid: string)
	local placeholder = "App " .. appid
	games[appid] = { w = new_widget(appid, placeholder), data = { game = placeholder, count_text = "-", icon = "", trend = trend, chart = chart_of({}) } }
	games[appid].w:present(games[appid].data)
	perch.steam.app_info(appid, on_app_info)
	perch.steam.artwork(appid, on_artwork)
	perch.steam.player_history(appid, on_history)
	perch.steam.player_count(appid, on_count)
end

local function remove_game(appid: string)
	local g = games[appid]
	if g then
		g.w:remove()
	end
	games[appid] = nil
end

add_game is the whole dynamic-widget recipe: create the handle with its schema, seed a preview, place, register, present with placeholders, then ask Steam. A cached answer calls back at once; a fetched one when it lands.

perch.on_configure:connect(function(s: settings_t)
	trend = s.trend ~= false
	local want: { [string]: boolean } = {}
	for _, row in ipairs(s.games or {}) do
		if row.appid ~= nil and row.appid ~= "" then
			want[row.appid] = true
		end
	end
	for appid in pairs(want) do
		if not games[appid] then
			add_game(appid)
		end
	end
	for appid in pairs(games) do
		if not want[appid] then
			remove_game(appid)
		end
	end
	for appid, g in pairs(games) do
		if g.data.trend ~= trend then
			apply(appid, { trend = trend })
		end
	end
end)

-- The store's title for a game the local library did not know.
perch.steam.on_app_info:connect(on_app_info)

local poll = timer.new(150 * 1000)
poll.on_fire:connect(function()
	for appid in pairs(games) do
		perch.steam.player_count(appid, on_count)
		perch.steam.player_history(appid, on_history)
	end
end)

perch.on_start:connect(function()
	started = true
	poll:start()
end)

perch.on_configure fires before perch.on_start with the stored list, which re-creates every widget on boot, and again on every change. It diffs the list against games and re-pushes the trend gate when the toggle flips. The on_app_info signal catches the store’s title for a game that is not installed, which app_info cannot answer at once. One timer polls; Perch floors Steam fetches at 60 seconds per game anyway.

preview.luau

--!strict
local displays = require("displays")

return {
	surface = surface.page,
	fields = displays.schema(),
	display = displays.card,
	data = {
		game = "Team Fortress 2",
		count_text = "1,436,921",
		icon = "https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/440/f568912870a4684f9ec76277a1a404dda6bab213.jpg",
		trend = true,
		chart = { points = { 1, 8, 3, 4, 5, 8, 4, 12 }, up = true },
	},
}

The Workshop thumbnail: the same card, demo data, framed in the page chrome by Perch Studio at publish.

What to take from it

  • Settings drive widgets. A settings.list plus a diff in perch.on_configure is the pattern for any “N of something”.
  • Declare once, bind by ref. One schema() shared by every widget and the preview; the display binds f.game, never a name it could misspell.
  • Keep the whole state. Presenting the full table before start and pushing deltas after it is robust to any order the answers arrive in.
  • Ask with a callback, listen for the rest. A cached answer and a fetched one run the same function; the landing signal catches what a callback cannot.
  • Push finished values. perch.text.number and the up flag are applied in Luau.

The source is on GitHub as hlpdev/perch-mod-steam-count.

esc
Type to search
navigate open