Leaderboards
Velven hosts your space's leaderboards and hands the rows back. It draws no board of its own: your space draws it, in its own style, from the rows it reads.
Declare a board
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.
<meta name="velven" content="@handle"><script type="application/velven+json">{"boards":[{"key":"main","trust":"server","metric":"time","sort":"asc","cooldown":10}]}</script>Every field except key has a default, so the smallest block is {"boards":[{"key":"main"}]}.
Velven reads the block when the space is listed, on its background check every few hours, and when you press Check now or Sync now on 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
key1 to 32 lowercase letters, digits,-or_- The name calls use for the board. A call that names no board uses
main. trustserver,clientDefaultserver- 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,customDefaultpoints- What the value measures. Use
customwith alabelandunitfor 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,ascDefaultdescdesc: higher is better.asc: lower is better, as for a time.modebest,sumDefaultbestbest: a player's best submission ranks.sum: their submissions add up.entriesplayer,runDefaultplayerplayer: one row per player.run: one row per submission, like an arcade table, so one player can hold several ranks.runneedsmodebest.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 secondsDefault0- The least time between two submissions from one player.
periodall,daily,weeklyDefaultalldailyandweeklyboards 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
metricneeds a new season. Past seasons stay readable.
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 the creator's token. Only the boards you name are written, and the page's next sync overwrites a key the page also names.
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":"server","metric":"time","sort":"asc","cooldown":10}]}'The space's edit page lists each board and where it came from, the page or the API. It does not edit them.
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.
const result = await Velven.scores.submit(1240, { board: "main", meta: { car: "red" }, requestId: runId });if (result.ok) { result.rank; // the player's rank now 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.
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 the board
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 scoretoplimit1 to 100Default10- Rows from the top. Page on by passing the last row back as
after. Ties go to the earlier submission. aroundeach0 to 50Default5- Rows either side of the player. Needs a signed-in player. On a
runboard it centres on their best run. mine- The player's own row, or
nullbefore 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
inforather than repeating them in your page.infoisnullonly from an older Velven page. - Every read carries
trust. Show it: aclientboard 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.
Past days and seasons
Every read takes bucket and season, so past days, weeks and seasons stay readable.
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 aYYYY-MM-DDdateDefaultcurrent- 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.
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
Delete a submission or ban a player from 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 | Meaning |
|---|---|
signed_out | No player is signed in, or they chose to stay a guest. Offer signIn() from a button, then send again. |
unavailable | Not inside Velven, or the space is not published with a verified owner. Nothing to fix in the space. |
no_board | No board has that key. Declare it in the page's block or through the REST API. |
server_only | The board's trust is server, so only your own server may post to it, with the secret. |
out_of_range | The value is below the board's min or above its max. |
cooldown | Too soon after this player's last submission. retryAfter is the wait in seconds. |
banned | You banned this player from the space's boards. |
invalid_value | meta is over 1 KB. Answered before anything is sent. |
rate_limited | Too many calls in a minute. Wait; do not retry in a loop. |
failed | Velven could not answer just now. Try once more later. |