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;

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', ... }.

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