velven
Docs
Menu

Leaderboards

Velven hosts your space's leaderboards and hands the rows back. Your space draws each board, in its own style, from the rows it reads; a board declared with display page or both is also shown on the space's Velven page.

View guide as Markdown

Declare a leaderboard

Declare boards in the page's <head>, in a <script type="application/velven+json"> block beside the proof tag. A score posted to a key you never declared is refused with no_board. Up to 10 boards per space.

index.html
<meta name="velven" content="@handle"><script type="application/velven+json">{"boards":[{"key":"main","trust":"client","metric":"time","sort":"asc","cooldown":10}]}</script>

A space Velven hosts declares them in velven.json instead, with the same boards key: Velven never reads a hosted page's block. A version that names them syncs them when it goes live; a preview uses the live space's.

Every field except key has a default. trust defaults to server, which takes scores only from your own server. So the smallest block for a page that posts its own scores is {"boards":[{"key":"main","trust":"client"}]}.

Velven reads the block when the space is listed and on its background check every 6 hours. To have it read the block now, press “Check my page now” on the Leaderboards tab of the space's edit page.

Note: Remove a board from the block and it is withdrawn: hidden from every read, with its scores kept. Name it again and it comes back.

Board fields

Each board in the block is an object with these fields. Only key is required.

key1 to 32 lowercase letters, digits, - or _
The name your calls use for the board. A call that names no board uses main.
trustserver, clientDefault server
Who may post. server: only your own server, with the secret and the player's token. client: the page, under the board's range, cooldown and caps. Server scores helps you choose.
metricpoints, time, distance, level, customDefault points
What the value measures. Use custom with a label and unit for anything else.
labelUp to 40 characters
The board's name, for you to draw. Optional.
unitUp to 16 characters
Drawn after the value. Optional.
sortdesc, ascDefault desc
desc: higher is better. asc: lower is better, as for a time.
modebest, sumDefault best
best: a player's best submission ranks. sum: their submissions add up.
entriesplayer, runDefault player
player: one row per player. run: one row per submission, like an arcade table, so one player can hold several ranks. run needs mode best.
minNumber, decimals allowed
The lowest value accepted. Anything lower is out_of_range.
maxNumber, decimals allowed
The highest value accepted. Anything higher is out_of_range.
cooldown0 to 86400 secondsDefault 0
The least time between 2 submissions from one player.
periodall, daily, weeklyDefault all
daily and weekly boards roll over at 00:00 UTC, weeks starting on Monday. Past days and weeks stay readable.
seasonUp to 32 characters
A name for a fresh start. Changing metric needs a new season. Past seasons stay readable.
displaypage, game, bothDefault game
Where the board is drawn. game: by your space alone. page: by Velven, in the Leaderboards section of the space's page and beside the frame from the trophy button in the bar. both: in both places. From 0.7.0.

Change sort, mode and entries at any time. Velven keeps every submission and ranks from them, so a rule change never loses a score. Velven never clears a board: period rolls it over for you, and season is your own reset.

Change boards without a deploy

Send the same shape to the REST API with your API token. Only the boards you name are written, and the page's next sync overwrites a key the page also names.

Terminal
curl -s -X PUT https://velven.ai/api/spaces/<slug>/boards \  -H "authorization: Bearer $VELVEN_TOKEN" \  -H "content-type: application/json" \  -d '{"boards":[{"key":"main","trust":"client","metric":"time","sort":"asc","cooldown":10}]}'

The Leaderboards tab of the space's edit page lists each board. Its “For developers” section says whether a board came from the page or the API. You cannot edit boards there.

Post a score

Post a score when a run ends. Inside Velven, the Velven page posts it under the player's own session, so nothing your space sends is a credential.

JavaScript
const result = await Velven.scores.submit(1240, { board: "main", meta: { car: "red" }, requestId: runId });if (result.ok) {  result.rank;     // the player's rank now; on a run board, this run's  result.value;    // this submission  result.total;    // what the board ranks them by: their best, or their sum  result.improved; // whether this submission improved it} else if (result.error === "signed_out") {  showSignInButton(); // then send again}
boardBoard keyDefault "main"
The board to post to.
metaObject, up to 1 KB as JSON
Anything to keep with the score, such as the car or the level. Returned untouched with every read.
requestId1 to 64 letters, digits, - or _
A retry key for this run. See Retry safely.
contentIdA content item's id
A replay or level of the player's own to keep with the score. See Keep a replay with a score. From 0.7.0.

rank and total are the player's standing on the board now, not this run's: on a best board total is their best, on a sum board their sum. On a run board, where every submission is a row, rank is this run's row and total is still their best. improved says whether this run changed total; on a sum board it is always true.

So a board you already drew can be updated without reading it again: move the player's row to rank with total, or on a run board add a row at rank with value. Other players' rows stay as fresh as your last read.

A value that is not a number, or a meta that is not an object, is a programming mistake and throws a TypeError.

Note: The page cannot post to a server board: submit answers server_only. Your server posts instead; see Server scores.

Read and draw the leaderboard

Read the top rows, the rows around the player, or the player's own row, then draw them yourself.

JavaScript
const top = await Velven.scores.top({ board: "main", limit: 10 });// { ok: true, board, trust: "server" | "client", info, rows }// row:  { rank, value, meta, setAt, user: { id, handle, avatar } }// info: { label, unit, metric, sort, mode, entries, period, bucket, season }//        bucket: the UTC date the day or week starts on, null on an all-time boardconst next = await Velven.scores.top({ board: "main", limit: 10, after: top.rows.at(-1) });const near = await Velven.scores.around({ board: "main", each: 5 });const mine = await Velven.scores.mine({ board: "main" });// { ok: true, board, trust, info, row }, row null before their first score
toplimit 1 to 100Default 10
Rows from the top. Page on by passing the last row back as after. Ties go to the earlier submission.
aroundeach 0 to 50Default 5
Rows either side of the player. Needs a signed-in player. On a run board it centres on their best run.
mine
The player's own row, or null before their first score. Needs a signed-in player.
  • Show a personal best from mine, not from a copy in your own storage: that copy drifts from the board when a run is refused or played signed out.
  • Draw the board's label and unit from info rather than repeating them in your page. info is null only from an older Velven page.
  • Every read carries trust. Show it: a client board is only as honest as a browser can be.

Warning: Rows and meta are other players' data, and any signed-in account can put up to 1 KB of JSON in meta. Draw them as text (textContent, never innerHTML) and never merge a row into your own state, or one player's entry can run in every other player's browser.

Friends only

top and around take friends: true: the rows of the player and their Velven friends only, ranked among themselves from 1. Friends are mutual, made on Velven. It needs a signed-in player.

JavaScript
const friends = await Velven.scores.top({ board: "main", friends: true, limit: 10 });const nearFriends = await Velven.scores.around({ board: "main", friends: true, each: 2 });

It works in scores.read too, and with bucket and season. From 0.7.0; on an older Velven page it answers unavailable.

The rows name the player's friends who have a score, so your space sees which of its players are friends of this one: that is what the read is for. A player who keeps their friends list private on Velven hides it from other people, not from their own friends reads.

Keep a replay with a score

Upload the run as a content item, then name it on the score. Every read answers the item's id on the row, as contentId, so a player can watch the run behind a rank.

JavaScript
const upload = await Velven.content.upload({ kind: "replay", title: "Run 42", data: replayBytes });if (upload.ok) await Velven.scores.submit(points, { contentId: upload.item.id });const top = await Velven.scores.top();const replay = top.ok && top.rows[0].contentId ? await Velven.content.download(top.rows[0].contentId) : null;

The item must be the player's own, in this space; anything else answers invalid_content. A private item stays private: the id shows on the row, but only its uploader can download it.

Past days and seasons

Every read takes bucket and season, so past days, weeks and seasons stay readable.

JavaScript
const yesterday = await Velven.scores.top({ board: "daily", bucket: "previous" });const thatWeek = await Velven.scores.top({ board: "weekly", bucket: "2026-09-23" }); // the week from Monday 2026-09-21const lastSeason = await Velven.scores.mine({ season: "s1" });
bucketcurrent, previous, or a YYYY-MM-DD dateDefault current
A date reads the day or week it falls in.
seasonA season nameDefault The board's current season
Any season the board has had.

Note: Paging with after stays in the bucket and season the row was read from, so a board paged across midnight stays on its day. Pass the row object the read returned: a copy (a spread, a JSON round trip, a framework's store) is not recognised and pages the current bucket, unless you also pass bucket and season from the page's info.

Read several boards at once

A game that shows several boards, or the player's row on each, reads them in one call: one message to the Velven page and one request to Velven, instead of one per read.

JavaScript
const [allTime, today, yesterday, myBest] = await Velven.scores.read([  { kind: "top", board: "main", limit: 10 },  { kind: "top", board: "daily", limit: 5 },  { kind: "top", board: "daily", bucket: "previous", limit: 1 },  { kind: "mine", board: "main" },]);// Each is what its own call answers: { ok: true, board, trust, info, rows }, a mine's row, or { ok: false, error }
  • Pass 1 to 10 reads. Each is { kind, ...options }, where kind is top, around or mine and the options are that call's own.
  • The answers come back in the order of the reads. One read's ok: false, such as no_board or a guest's signed_out on a mine, never fails the others.
  • A mistake in any read, or an array that is empty or longer than 10, throws a TypeError before anything is sent.
  • Each read counts as 1 against the read limits. A batch that does not fit in what is left of the minute is refused whole: every read answers rate_limited.
  • Page on from a batch's rows with after, in another batch or in top, as from a single read's.

Retry safely

Give each run a requestId. After a failed, such as a lost connection or the 30-second wait running out, send the same run with the same id: it is entered once, and the repeat answers as the first did. Without an id, every call is a new submission.

Moderate a board

Remove a score or block a player on the Leaderboards tab of the space's edit page, or through the REST API. Deleting a submission re-ranks the player from what is left.

Error codes

A failed score call answers one of these codes in error:

Code
signed_out
Meaning
No player is signed in, or they chose to stay a guest. Offer signIn() from a button, then send again.
Code
unavailable
Meaning
Not inside Velven, or the space is not published with a verified owner. A space still processing, or not yet claimed, answers this too, and so does a call that uses an option (requestId, bucket, season) the Velven page is too old to understand.
Code
no_board
Meaning
No board has that key. Declare it in the page's block and press “Check my page now” on the edit page's Leaderboards tab, or use the REST API. A space Velven hosts declares it in velven.json instead, synced when a version that names it goes live.
Code
server_only
Meaning
The board's trust is server, the default, so only your own server may post to it. For a page that posts its own scores, declare "trust":"client".
Code
out_of_range
Meaning
The value is below the board's min or above its max.
Code
cooldown
Meaning
Too soon after this player's last submission. retryAfter is the wait in seconds.
Code
banned
Meaning
You blocked this player on the space's boards, or Velven banned their account.
Code
invalid_value
Meaning
meta is over 1 KB. Answered before anything is sent.
Code
invalid_content
Meaning
contentId is not a content item of this player's own in this space, or it was deleted.
Code
rate_limited
Meaning
Too many calls in a minute. Wait; do not retry in a loop.
Code
failed
Meaning
Velven could not answer just now. Try once more later.