gipity add leaderboard adds leaderboards to any game with a database (web-fullstack or api): three functions, six tables, and a small browser helper. It fits high scores, best times, speedruns, puzzle solves and racing alike. Native games call the same functions over HTTPS (the Godot addon wraps them; see steam-game).
1. Declare the boards
Boards live in an app migration, so they ship with the code. Each board's columns are its server-side checks:
-- migrations/001-boards.sql
INSERT INTO lb_boards (board, sort, tiebreak_sort, periods, min_score, max_score) VALUES
('arcade:score', 'desc', NULL, ARRAY['all', 'day', 'week'], 0, 5000000),
('puzzle:classic', 'desc', 'asc', ARRAY['all', 'season'], 0, 10000)
ON CONFLICT (board) DO UPDATE SET sort = EXCLUDED.sort, tiebreak_sort = EXCLUDED.tiebreak_sort,
periods = EXCLUDED.periods, min_score = EXCLUDED.min_score, max_score = EXCLUDED.max_score;
sort:desc(higher wins: points) orasc(lower wins: times).tiebreak_sort: optional. A second number orders equal scores, e.g. "most points, then fastest" isdesc+ tiebreakasc. On such boards every submission must sendtiebreak. Without one, equal scores share a rank and the earlier run lists first.periods: which rankings the board keeps, any ofall,day,week,month,season. Defaultall+week. Day, week (ISO) and month are UTC.min_score/max_score: reject anything outside. For times, setmin_scorejust under the fastest possible run.splits: optional checkpoint count (racing, speedruns). Each submission sends cumulative checkpoint times that increase and end at the score.rulesets: optional official ruleset hashes (a hash of difficulty or tuning settings). Only these rank;NULLaccepts any, and each ranks separately.max_ghost_bytes(default 65536) andsubmit_per_hour(per player, per board; default 60).
Seasons. A board with season in periods ranks within the running season: the latest lb_seasons row whose window contains now. Declare them in a migration:
INSERT INTO lb_seasons (name, starts_at, ends_at) VALUES ('Season 1', '2026-10-01', '2027-01-01') ON CONFLICT (name) DO NOTHING;
Name boards <mode>:<kind> (e.g. arcade:score, level-3:time, oval-1:lap). Separate variants (a car, a character, a difficulty) are separate boards, or send the variant in meta.
2. Submit and read
Submitting needs a signed-in player: Sign in with Gipity on the web, or a Steam or guest player in a native game. Reading is public.
import { submitScore, top, aroundMe, myEntry, friends, ghost, seasons } from '@gipity/leaderboard';
const r = await submitScore('puzzle:classic', 9200, { tiebreak: 61400 });
// { accepted: true, improved: { all: true, season: true }, period: 'all', personalBest, rank, entryId }
// or { accepted: false, reason } - a rejection is a normal result, not an error
await top('arcade:score', { period: 'day', limit: 10, offset: 0 }); // { entries, total, period }
await aroundMe('arcade:score', { radius: 3 }); // { entry, entries }
await friends('arcade:score', steamFriendIds); // Steam ids, you included
await top('puzzle:classic', { period: 'season' }); // or 'season:Season 1'
await ghost(entryId); // { ghost: base64 }
await seasons(); // { current, seasons }
Over HTTPS: POST /api/<appGuid>/fn/leaderboard-submit with the same body, and POST /api/<appGuid>/fn/leaderboard-read with { action: 'top' | 'around' | 'me' | 'friends' | 'ghost' | 'boards' | 'seasons', ... }.
- Periods: a kind for the current window (
all,day,week,month,season) or a key for a past one (2026-09-28,2026-W39,2026-09,season:Season 1). The default is all-time when the board keeps it, else its first period. A period the board doesn't keep comes back as{ error }. - Submit results:
improvedhas one flag per period the board keeps;rankandpersonalBestare for the main period (all-time, or the board's first period). Every accepted run updates every period's personal best it beats. - Entries:
{ rank, entryId, userGuid, playerRef, displayName, score, tiebreak, splits, meta, gameVersion, hasGhost, updatedAt }.playerRefissteam:<SteamID64>for Steam players. - Replays ("ghosts") are opaque bytes (base64 over the wire) kept with each personal best, up to
max_ghost_bytes: a racing line, an input log, a puzzle solution. Record positions, not inputs, if the physics isn't deterministic across machines. - Bad input (an unknown board or period) comes back as
{ error }in the result.
Racing boards add splits, a ruleset and a ghost: ('oval-1:lap', 'asc', 12000, 90000, 3, ARRAY['a1f09c']) for (board, sort, min_score, max_score, splits, rulesets), then submitScore('oval-1:lap', 31250, { ruleset: 'a1f09c', splits: [10400, 21010, 31250], ghost }).
3. Moderate
leaderboard-admin is auth: member (the owner and project members only):
gipity fn call leaderboard-admin '{"action":"submissions","board":"arcade:score","rejectedOnly":true}'
gipity fn call leaderboard-admin '{"action":"ban","userGuid":"u_...","reason":"impossible score"}'
gipity fn call leaderboard-admin '{"action":"remove","entryId":"lbe_..."}'
gipity fn call leaderboard-admin '{"action":"reset","board":"arcade:score","period":"day"}'
Also unban and bans. A ban deletes the player's entries and hides them from every view.
Limits of the checks
These are plausibility checks. A careful cheater can submit a believable fake, so review the rejected-submissions log and ban. Replay bytes aren't inspected. Real cheat resistance needs the game to run on a server, which this kit doesn't do.
Common mistakes
- Submitting before the board exists. Declare it in a migration and deploy first; unknown boards return
{ error: "No board ..." }. - Forgetting
tiebreakon a board withtiebreak_sort, or sending one to a board without it. Both are rejected with a reason. - Asking for a period the board doesn't keep (
dayon anall+weekboard). Add it toperiodsin the migration. - Treating
accepted: falseas a failure. It's the check working. Showreasonin development. - A float score. Scores and tiebreaks are integers (points, milliseconds).