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.


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:

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


6. Checking it works

  1. Ask the moderator to post the board and press ▶ Play yourself. Your game should open with the launch parameters and strip them at once.
  2. 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.
  3. Run /mystanding in Discord to see your own row, or call GET …/standings/me?session=<token> from the game.
  4. Finish a second game with the same idempotencyKey on purpose. The answer must be 409 duplicate and your game must show it as already counted.
  5. 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