Perch talks to Steam’s web APIs itself, caches the answers and enforces the request floors. A Steam mod needs only the steam scope.
| Scope | Installs |
|---|---|
steam | perch.steam.installed(), .running(), .on_game_change; the lookups .app_info, .artwork, .store_details, .news, .player_count, .player_history, .search; and one landing signal per lookup |

Every appid is a string of digits, which is what a settings.game row stores. A number answers nothing.
perch.steam.installed, perch.steam.running
perch.steam.installed(): { steam_app_t }
perch.steam.running(): steam_app_t?
The local library, read from the app manifests; and the game Perch currently detects as running, or nil. Both synchronous.
perch.steam.on_game_change
perch.steam.on_game_change: signal_t<game_change_t>
Fires the moment the running game starts or stops. Connect it instead of polling running().
perch.steam.on_game_change:connect(function(g: game_change_t)
if not g.running then w:retire() return end
w:present({ game = g.name, icon = "" })
perch.steam.artwork(tostring(g.appid), function(art: steam_artwork_t) w:push({ icon = art.icon }) end)
end)
The lookups
perch.steam.app_info(appid: string, cb: (steam_app_info_t) -> ()): ()
perch.steam.artwork(appid: string, cb: (steam_artwork_t) -> ()): ()
perch.steam.store_details(appid: string, cb: (steam_store_details_t) -> ()): ()
perch.steam.news(appid: string, count: number?, cb: (steam_news_t) -> ()): ()
perch.steam.player_count(appid: string, cb: (steam_player_count_t) -> ()): ()
perch.steam.player_history(appid: string, cb: (steam_player_history_t) -> ()): ()
perch.steam.search(term: string, cb: (steam_search_t) -> ()): ()
Each takes its callback last and returns nothing. If Perch already knows the answer the callback runs at once, inside your call; otherwise a fetch starts and the callback runs when it lands. A failed fetch calls nothing and the last good value stands. Calling a lookup with no callback just starts the fetch.
perch.steam.player_count(appid, function(pc: steam_player_count_t)
w:push({ count_text = perch.text.number(pc.count) })
end)
| Lookup | Answers | Refresh floor |
|---|---|---|
app_info | what the local library knows at once (name, icon, installed), with store = false; for a game that is not installed name is "" until the store answers on on_app_info | 300 s |
artwork | the store art for any appid, installed or not: the square client icon and the capsules | cached per session |
store_details | the store listing: title, description, price, genres | cached per session |
news | the newest count announcements (1 to 20, default 5) | 15 min |
player_count | the live player count | 60 s per app |
player_history | up to 48 hourly samples, oldest first; bind points to a ui.sparkline | 1 h |
search | up to 25 store results for a term, normalised (lowercased, 64 bytes) | 5 min per term |
search drops a term under 2 characters, or a call inside 1 second of the previous one, and then never calls back. Drive it from a debounce, not a keystroke.
The landing signals
perch.steam.on_app_info: signal_t<steam_app_info_t>
perch.steam.on_artwork: signal_t<steam_artwork_t>
perch.steam.on_store_details: signal_t<steam_store_details_t>
perch.steam.on_news: signal_t<steam_news_t>
perch.steam.on_player_count: signal_t<steam_player_count_t>
perch.steam.on_player_history: signal_t<steam_player_history_t>
perch.steam.on_search: signal_t<steam_search_t>
One per lookup, carrying the same payload the callback delivers. Connect one when a single listener per kind suits a mod that tracks many apps, and for answers that arrive without a pending callback: the store title of a game that is not installed lands on on_app_info after app_info already answered with the local name.
perch.steam.on_app_info:connect(function(info: steam_app_info_t)
local g = games[info.appid]
if g and info.name ~= "" then g.w:relabel(info.name) g.w:push({ game = info.name }) end
end)
The complete package this comes from is read through in A complete mod.
Types
export type steam_app_t = { appid: string, name: string, installed: boolean? }
export type game_change_t = { running: boolean, appid: number, name: string }
An app in the library, and the payload of on_game_change (appid and name are empty when nothing is running).
export type steam_app_info_t = { appid: string, name: string, icon: string, header: string, installed: boolean?, store: boolean }
| Field | Meaning |
|---|---|
name | from the local library if installed, else from the store once known, else "" |
icon | a file:// URL of the local library icon, else the store’s small capsule, else "" |
header | the wide store header URL, or "" until the store answers |
installed | in this machine’s library |
store | the store has answered |
export type steam_artwork_t = { appid: string, icon: string, portrait: string, header: string, capsule: string, hero: string, background: string }
Every slot is its declared shape or ""; Perch never substitutes one shape for another. icon is the square client icon (works for games that are not installed); portrait the tall library capsule; header the wide store header; capsule the small wide capsule; hero the library banner; background the page background.
export type steam_store_details_t = { appid: string, name: string, description: string, developers: { string }, publishers: { string }, genres: { string }, released: string, coming_soon: boolean, free: boolean, price: string, discount: number, metacritic: number }
price is the formatted current price or ""; discount the percent off and metacritic the score, both 0 when there is none.
export type steam_news_t = { appid: string, items: { steam_news_item_t } }
export type steam_news_item_t = { title: string, url: string, author: string, contents: string, feed: string, date: number }
Newest first; date is epoch seconds; contents the plain announcement body.
export type steam_player_count_t = { appid: string, count: number, at: number }
export type steam_player_history_t = { appid: string, points: { number }, at: { number } }
at is epoch seconds: one value for the count, one per sample for the history.
export type steam_search_t = { term: string, results: { steam_search_result_t } }
export type steam_search_result_t = { appid: string, name: string, icon: string }
The normalised term, and its results.