Integrating an external game
Who this is for
You have a game that runs outside this bot: a browser game on your own site, a game with its own backend, or a script that scores something. A moderator wants its results on a leaderboard in their Discord server. This page is the short path from nothing to a working integration. Every field, status code and rule is in the API reference; this page tells you which ones you need and in what order.
Example host throughout: https://village-vote.fun. The moderator's setup page has an API tab that shows the real URLs with their server id, board slug and key filled in. Ask them for a screenshot of it, or for the four values below.
1. What the moderator gives you, and what you give them
From the moderator:
| Value | Looks like | Where it goes |
|---|---|---|
| Server id | 123456789012345678 |
The {serverId} part of every URL |
| Board slug | friday-arcade |
The {boardSlug} part of every URL |
| Public key | lbk_3f9a2c… |
Authorization: Bearer lbk_… on every request |
| Game id | snake |
gameId in every result body; it must match the key's game id |
| Secret | lbsec_… (optional) |
Only for a game with a backend; see step 4 |
Also ask them two questions: what is the ceiling (the highest single result the board accepts), and does the board require a play session (it should, if players launch from Discord).
To the moderator you give one thing: your game's launch URL, https:// only. They put it on the key, and from then on the board's ▶ Play button and the /play command send players to it with the launch parameters appended.
2. The launch link
A player who presses Play gets a private link like this:
https://example.com/snake/?lbs=<token>&key=lbk_…&server=123456789012345678&game=snake&boards=friday-arcade&api=https://village-vote.fun
lbs is a play session token: it proves which Discord user is playing, so your game never has to ask who they are. It lives 30 minutes from its last renewal and up to twelve hours from launch. boards lists every board this launch may score on. The other parameters are the values from step 1, so a game that reads the link needs no configuration at all.
Two rules about the link:
- Read the parameters on load and remove them from the address bar with
history.replaceState. A URL leaks through history, referrers and logs. The helper in the next step does this for you. - Never put the token in a URL of your own. It goes in the request body as
sessionToken, nowhere else.
A game opened without a launch link has no session. It can still submit with a discordUserId, unless the board requires sessions, in which case the answer is 401 session_required. Let the player play anyway and tell them to launch from Discord to get on the board.
3. A browser game, in ten minutes
Load the bot's helper script and let it read the link:
<script src="https://village-vote.fun/js/leaderboard-client.js"></script>
const lb = LeaderboardClient.fromLaunchUrl(); // reads and strips the launch parameters,
// keeps the session alive while the page is open
let gameKey = null;
function onGameStart() {
gameKey = crypto.randomUUID(); // one key per finished game, made when it starts
}
async function onGameOver(points, initials) {
if (!lb.canSubmit) return showBanner('Launch this from Discord to get on the board.');
const [first] = await lb.submit({ points, initials, idempotencyKey: gameKey });
if (first.ok && first.body.standing) {
showBanner(`You are #${first.body.standing.rank} as ${first.body.standing.displayName}`);
} else if (first.ok) {
showBanner('Already counted'); // a 409 duplicate: the retry of a result that landed
} else {
showBanner(first.body.message); // e.g. "This play session has expired; launch again"
}
}
That is the whole integration for a solo points game. submit fills in the session token and game id, posts to every board in the link, and returns one entry per board. initials is optional and only shows on boards in initials mode. The helper's other calls (preferences, myStanding, standings, serverNow) are listed under The client helper.
Versus games on an Elo board report an outcome (win, loss or draw) from the reporting player's side. On a challenge launch (the link carries match, opponent and role) that is all: lb.submit({ outcome }). A game that pairs players itself also sends matchId and opponentId, the same matchId from both sides. A 202 means the board is waiting for the opponent's report; treat 200 confirmed like 201. Details under Versus games and challenges.
4. A game with a backend
Submit from the backend, not the browser, and sign the request with the key's secret. One POST, one header, one JSON body; the signature is an HMAC over the exact bytes you send:
import { createHmac, randomUUID } from 'node:crypto';
const secret = process.env.LEADERBOARD_SECRET; // lbsec_…, never in client code
const body = JSON.stringify({
gameId: 'snake',
sessionToken, // from your client, which read the launch link;
// or discordUserId: '…' when there is no session
idempotencyKey: randomUUID(),
points: 1240,
});
const timestamp = Math.floor(Date.now() / 1000);
const signature = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
const res = await fetch('https://village-vote.fun/api/leaderboard/<serverId>/<boardSlug>/results', {
method: 'POST',
headers: {
Authorization: 'Bearer lbk_…',
'Content-Type': 'application/json',
'X-Leaderboard-Timestamp': String(timestamp),
'X-Leaderboard-Signature': signature,
},
body, // the string that was signed, as is
});
The three things that go wrong with signing, in order of frequency: the body is re-serialised by an HTTP library (send the string you signed, not the object); the timestamp is in milliseconds (it must be seconds, within five minutes of the server clock); the moderator rotated the secret (deploy the new one before they press Rotate). A Python version and the full rules are under Request signing.
Start signing as soon as you have the secret. Signatures are checked whenever they are sent, so the moderator can see your results arriving signed and then flip Require signing without a gap.
5. The rules that catch people
- One
idempotencyKeyper finished game, created when the game ends (or starts) and kept until the submit succeeds. Retry freely on network errors,429and500with the same key;409 duplicatemeans it already landed and is good news. A retry under a new key is a second result and counts again on a sum board. - Read the status, not just
ok.201 appliedscored now.202 pendingis an Elo match waiting for the opponent.200 confirmedor200 disputedanswered an earlier report of the same match.409 duplicateis a replay. 401 session_expired: the player has been on the page too long without the helper, or the launch is more than twelve hours old. Tell them to launch again from Discord. The helper renews the session every ten minutes while the page is open.422 points_too_highis above the moderator's ceiling. It is recorded struck and never counts. Treat it as a bug in your scoring, not something to retry.429carriesRetry-Afterin seconds. A backend submitting for many players from one machine hits the per-key-and-IP limit first; ask the moderator to raise the key's limit.- Several boards, one result. The link's
boardsmay name more than one slug when the moderator grouped boards. The helper posts to each; a backend can use the fan-out routePOST /api/leaderboard/{serverId}/resultsonce and read one answer per board. - Preferences are public. Anything the moderator sets for your game and that your game reads from the preferences route can be read from a browser's network tab. Nothing a player must never see belongs there.
- Send
meta. Level reached, duration, anything that makes a fake easier to spot in the moderator's history view. Up to 1 KB, never shown on the board.
6. Checking it works
- Ask the moderator to post the board and press ▶ Play yourself. Your game should open with the launch parameters and strip them at once.
- Finish a game. The board's live message updates within a few seconds, and the server's log channel (if the moderator set one) gets a line for the result.
- Run
/mystandingin Discord to see your own row, or callGET …/standings/me?session=<token>from the game. - Finish a second game with the same
idempotencyKeyon purpose. The answer must be409 duplicateand your game must show it as already counted. - For a backend, send one request with a wrong signature. It must be refused with
401 bad_signature, and the moderator's history should show no result.
The moderator can strike any result from the setup page, so a test result on a live board is not a problem.
7. Where to read more
- Leaderboard API: every route, field, status code and error, with worked examples.
- Leaderboards (Moderators): what the moderator sees and does, including keys, play sessions and challenges.