# Velven SDK

The Velven SDK adds sign-in, leaderboards, saved progress, achievements and stats, player-made content and multiplayer rooms to your space. Load one script and there is nothing to register: being listed on Velven is the registration. Velven stores the scores, saves and content, and hands them back for your space to draw in its own style.

## Overview

### Install the Velven SDK

Use the script tag for a single page with no build step, or the npm package if you bundle. Both give you the same `Velven` object.

Script tag:

```html
<script src="https://velven.ai/sdk/v1.js"></script>
```

npm:

```bash
npm install @velven/sdk
# or: bun add @velven/sdk
```

With the package, import it where you need it:

```js
import { Velven } from "@velven/sdk";
```

> Note: If your page sends its own Content-Security-Policy, add `https://velven.ai` to its `script-src` so the script tag can load. The npm package needs no change, since it ships inside your bundle.

### A first look

The fastest way in is [Add a leaderboard in 10 minutes](https://velven.ai/docs/sdk/leaderboard-quickstart). In short, declare a board in the page's `<head>`, beside the [proof tag](https://velven.ai/docs/hosting#proof) that names you as the creator:

index.html:

```html
<meta name="velven" content="@handle">
<script type="application/velven+json">
{"boards":[{"key":"main","trust":"client"}]}
</script>
<script src="https://velven.ai/sdk/v1.js"></script>
```

Then, in your game:

game.js:

```js
await Velven.ready();

// A player signed in to Velven is known from the start.
if (Velven.user) showName(Velven.user.handle);

// When a run ends, post the score, then draw the board.
const result = await Velven.scores.submit(points);
if (!result.ok && result.error === "signed_out") showSignInButton();

const top = await Velven.scores.top({ limit: 10 });
if (top.ok) drawBoard(top.rows);
```

Where to go next:

- [Leaderboard quickstart](https://velven.ai/docs/sdk/leaderboard-quickstart): a working board, tested locally and then on Velven.
- [Sign-in](https://velven.ai/docs/sdk/sign-in): who is playing, and asking a guest to sign in.
- [Leaderboards](https://velven.ai/docs/sdk/leaderboards): declaring boards, posting scores and reading them back.
- [Saves](https://velven.ai/docs/sdk/saves): keeping progress across sessions and devices.
- [Server scores](https://velven.ai/docs/sdk/server): boards only your own server can post to, and trusted unlocks.
- [Achievements and stats](https://velven.ai/docs/sdk/achievements): unlocks worth points on the player's profile, and counters that unlock them.
- [Player content](https://velven.ai/docs/sdk/content): levels, replays and maps your players make and share.
- [Rooms and presence](https://velven.ai/docs/sdk/rooms): play together in real time, invite friends, and tell them what you are playing.
- [Gameplay and sound](https://velven.ai/docs/sdk#gameplay): when play is on, and a sound button for the player.
- [Send visitors to your Velven page](https://velven.ai/docs/sdk#redirect): players who open your space's own address play it on Velven.
- [Local testing](https://velven.ai/docs/sdk/local): building all of it on your laptop before the space is listed.
- [Troubleshooting](https://velven.ai/docs/troubleshooting): what to do when a call answers an error you did not expect.

### Gameplay and sound

Call `Velven.game.start()` when play begins or resumes, and `Velven.game.stop()` at a natural break: a menu, the end of a level, a game over. Velven counts the time between the two calls as time spent playing.

game.js:

```js
function startLevel() {
  Velven.game.start();
  // ...
}

function showMenu() {
  Velven.game.stop();
  // ...
}
```

Both calls are safe to repeat, since only a change reaches Velven. They return nothing, so there is nothing to `await`. You can call them before `ready()` settles; the Velven page gets the state once it answers.

If your space can turn its own sound off, register a handler with `Velven.onMute`. The Velven page then shows a sound button in the bar under your space (on a phone, in the row under its title) and calls your handler with the player's choice: `true` to mute, `false` to play sound again. Your handler hears the choice the player already made first, then every change.

game.js:

```js
const off = Velven.onMute((muted) => {
  music.muted = muted;
  effects.muted = muted;
}); // off() to stop listening
```

Mute everything your space plays, music and effects alike. Without a handler, the Velven page shows no sound button. The player's choice is kept in their browser and applies to every space they play.

> Note: On your own site (the `site` environment), both calls do nothing and the handler is never called, so the same code runs everywhere. To test a muted player locally, add [`?velven_muted=1`](https://velven.ai/docs/sdk/local#local-params).

### Leave Escape to the browser

The fullscreen button under your space uses the browser's own fullscreen, so Escape leaves fullscreen even while your space has the keys. If your space locks the pointer, Escape releases it too. Your page cannot stop either.

So don't bind Escape to anything in your space. If Escape pauses the game or opens a menu, one press does that and also drops the player out of fullscreen. Use another key, such as `P`, to pause. If you lock the pointer, pause when the lock is lost and lock it again on the next click:

game.js:

```js
canvas.addEventListener("click", () => canvas.requestPointerLock());

document.addEventListener("pointerlockchange", () => {
  if (!document.pointerLockElement) pause(); // Escape released the pointer
});

document.addEventListener("keydown", (event) => {
  if (event.code === "KeyP") togglePause(); // not Escape: the browser takes it
});
```

Call `Velven.game.stop()` in `pause()`, as at any other break.

### Where it runs

`Velven.ready()` resolves with the environment the page is in, and `Velven.environment` holds it after that. Everything works in `velven`. In `site` and `local` your space still plays, with what the table lists.

| Environment | When | What works |
| --- | --- | --- |
| `velven` | Inside the Velven frame, once the Velven page has answered. | Everything: sign-in from the start, scores, reads and saves. |
| `site` | A top-level window on a real host, or a frame where nothing answered within 7 seconds. | The space plays. Sign-in and score calls answer `unavailable`; saves use the page's own `localStorage`. |
| `local` | `localhost`, `127.0.0.1` or `[::1]`, or any host with `?velven_local=1`. | Fake sign-in and in-memory boards, for [local testing](https://velven.ai/docs/sdk/local). |

On your own site there is no board to draw, so check the environment first:

game.js:

```js
const environment = await Velven.ready();
if (environment !== "site") drawBoard();
```

To have players who open your own address play on Velven instead, [send them to your Velven page](https://velven.ai/docs/sdk#redirect).

### Send visitors to your Velven page

Players who open your space's own address, from a bookmark or an old post, can be sent to its page on Velven, where sign-in, boards and saves work. Add `data-redirect` to the script tag, in the page's `<head>` so it runs before anything draws:

Script tag:

```html
<script src="https://velven.ai/sdk/v1.js" data-redirect></script>
```

npm (0.6.0 and later):

```js
import { createVelven } from "@velven/sdk";

const Velven = createVelven({ redirect: true });
```

#### A full example

A game listed on Velven at `https://star-runner.example.com`. The script tag sits right after the proof tag and the boards block, above every stylesheet, font and game script, so a visitor who is sent on leaves before the rest loads:

index.html:

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Star Runner</title>
<meta name="velven" content="@handle">
<script type="application/velven+json">
{"boards":[{"key":"main","trust":"client"}]}
</script>
<script src="https://velven.ai/sdk/v1.js" data-redirect></script>
<link rel="stylesheet" href="style.css">
</head>
<body>
<canvas id="game"></canvas>
<script src="game.js"></script>
</body>
</html>
```

Where each visit ends up:

| Opened | Ends up |
| --- | --- |
| `https://star-runner.example.com` from a bookmark | Star Runner's page on Velven. |
| `https://star-runner.example.com/play?room=k7f2`, an invite a friend sent | Star Runner's page on Velven. The path is under the listed address, but `?room=k7f2` is dropped, so the friend lands on the game, not in the room. |
| Star Runner's page on Velven | The game plays in the frame, signed in, as always. |
| `https://preview.star-runner.example.com`, a preview deployment | Back on itself with `?velven_stay=1`, and it stays for the rest of the tab. |
| Your game on `localhost`, while you build it | Stays, in the `local` environment. |

#### What goes and what stays

- Only a page opened directly goes. Inside Velven, in another site's frame, in a popup your game opens, from a file on disk, on `localhost` or on your own network (a phone testing over Wi-Fi) it stays, and Velven's own recorder sees your page as it is.
- Only the page's address and path go along, never its query or hash, so an invite like `?room=k7f2` does not carry over. Keep secrets out of the path too, and leave the browser's default referrer policy in place: a page set to `unsafe-url` sends its whole address, query included.
- A page at or under a listed space's address goes to that space, so list a new game first, then add `data-redirect`. An address that is not a published space on Velven, such as a preview deployment, another domain of yours, or a space not listed yet, comes straight back to itself with `?velven_stay=1` and stays for the rest of the tab. That needs the browser to say where it came from, so a page that sends no referrer (`<meta name="referrer" content="no-referrer">`) sees a Velven page saying the space is not there instead, with a link back when the space is listed. With npm, call `createVelven` before anything rewrites the page's address, so it can read `?velven_stay=1`.
- Every direct visit goes, your own sign-in callback or admin page included. Search engines that run scripts may follow it and show the Velven page for your address.

> Note: The game may show for a moment before the Velven page arrives. The script tag at the top of the `<head>` keeps that moment short; a bundled copy starts later, so it shows longer.

### How calls answer

Every call resolves, with `ok` telling success from failure. A signed-out player or a refused score is an answer, not an exception, so you never wrap a call in `try`/`catch`. Only a programming mistake throws, such as a score that is not a number.

```js
const result = await Velven.scores.submit(1240);
if (result.ok) {
  console.log(result.rank);
} else {
  console.log(result.error); // a code, such as "signed_out"
}
```

The script has no dependencies.

### Versions

The script tag always serves the latest release, so a page that loads it gets every fix with no change. Releases only add: nothing a page already calls changes or goes away. The npm package stays on the release you installed: update it when a release comes out, since a fix reaches a bundled copy only then. The [changelog](https://velven.ai/docs/sdk/changelog) lists what each release added.

> Note: For up to 2 minutes after a release, a player's browser may still run the release before. If you use a recently added call, check for it first, as in `if (Velven.scores.mine)`, and do without it when it is missing. `Velven.version` reports the release, such as `0.3.0`; it arrived in 0.3.0 itself, so when it is absent the release is older.

It works the other way too. A player who opened your space's Velven page before Velven last updated keeps that page until they reload it, while your space, reloaded inside it, may get the newest release. From 0.5.1, each release asks the Velven page what it supports and uses a newer message only when the page knows it, so every call still answers, as it did in the release before. 0.5.0 sent its batch reads without asking, so a page that bundles 0.5.0 should update.

## Leaderboard quickstart

Give your browser game a hosted leaderboard. You declare a board, post a score when a run ends, and draw the top 10. You test it on your laptop first, then on Velven. You need a game whose runs end with a score, and a Velven account.

### 1. Declare the board

Add these 3 tags to your page's `<head>`, with your own Velven handle in the first:

index.html:

```html
<meta name="velven" content="@handle">
<script type="application/velven+json">
{"boards":[{"key":"main","trust":"client","min":0,"max":100000,"cooldown":5}]}
</script>
<script src="https://velven.ai/sdk/v1.js"></script>
```

- The `<meta>` tag is the [proof tag](https://velven.ai/docs/hosting#proof): it names you as the creator.
- The block declares one board, `main`. `"trust":"client"` lets the page post its own scores; without it the board takes scores only from [your own server](https://velven.ai/docs/sdk/server), and the page's posts answer `server_only`.
- `min`, `max` and `cooldown` refuse a score out of range, or one sent less than 5 seconds after the last. Set them to fit your game.
- The script loads the SDK as the global `Velven`.

[Board fields](https://velven.ai/docs/sdk/leaderboards#board-fields) lists every other field, such as `sort` for a board where lower is better.

### 2. Post the score and draw the board

Add a list for the board and a sign-in button to the page, then this script. Call `onRunOver` from your game when a run ends.

index.html:

```html
<ol id="board"></ol>
<button id="sign-in" hidden>Sign in to be ranked</button>
<script>
  const board = document.getElementById("board");
  const signInButton = document.getElementById("sign-in");

  async function drawBoard() {
    const top = await Velven.scores.top({ limit: 10 });
    if (!top.ok) return;
    board.replaceChildren(); // rows are other players' data: text only, never markup
    for (const row of top.rows) {
      const li = document.createElement("li");
      li.textContent = `#${row.rank} @${row.user.handle} ${row.value}`;
      board.append(li);
    }
  }

  // Your game calls this when a run ends.
  async function onRunOver(points) {
    const result = await Velven.scores.submit(points);
    if (!result.ok && result.error === "signed_out") signInButton.hidden = false;
    await drawBoard();
  }

  // Ask a guest to sign in from a button, never on load.
  signInButton.addEventListener("click", async () => {
    const auth = await Velven.signIn();
    if (auth.ok) signInButton.hidden = true;
  });

  Velven.ready().then((environment) => {
    if (environment !== "site") drawBoard();
  });
</script>
```

A guest's score answers `signed_out` and is not stored, so the button offers sign-in. [Examples](https://velven.ai/docs/sdk/examples) has a complete page, with the player's own row under the top 10.

### 3. Test it on localhost

Serve the folder from any local server, such as:

```bash
python3 -m http.server 8000
```

Then open `http://127.0.0.1:8000/?velven_user=alice`. On `127.0.0.1` and `localhost` the SDK answers with fakes, so nothing is listed or stored:

- The board starts with 3 seeded players, so it has rows to draw.
- `?velven_user=alice` signs in a fake player. Play a run, and `@alice` joins the board at her rank.
- Without `?velven_user`, a run answers `signed_out` and your sign-in button shows. Locally it stays a guest.
- A reload empties the board.

[Local testing](https://velven.ai/docs/sdk/local) has more switches, such as `?velven_seed=` to give the seeded players values like your game's.

### 4. List and prove the space

Deploy the page to a [supported host](https://velven.ai/docs/hosting#hosts) and list it by following the [quickstart](https://velven.ai/docs/quickstart). The proof tag you added in step 1 proves the space is yours, and Velven reads the board from the block when it lists the space.

Scores work once the space is published with you as its verified owner. Until its clip lands, the space is Getting ready, and score calls answer `unavailable`.

> Note: Listed the space before you added the block? Deploy, open the Leaderboards tab of the space's edit page, and press “Check my page now” in its “For developers” section. Velven also reads the block on its background check every 6 hours. A space Velven hosts declares the board in [`velven.json`](https://velven.ai/docs/hosting#velven-json) instead, synced when a version that names it goes live.

### 5. Play on Velven and see your row

Open your space on velven.ai, signed in, and play a run. When it ends, your handle appears on the board your page draws, at its rank.

The Leaderboards tab of the space's edit page shows the latest scores, each with Remove, and blocks a player by handle.

Nothing on the board? [Troubleshooting](https://velven.ai/docs/troubleshooting) goes through each error a score call can answer.

### Next steps

- [Read past days and seasons](https://velven.ai/docs/sdk/leaderboards#history) for a daily or weekly board.
- [Post from your own server](https://velven.ai/docs/sdk/server) when your server can check the run.
- [Keep saves](https://velven.ai/docs/sdk/saves) so players pick up where they left off.

## Sign-in

A player signed in to Velven is signed in to your space too, with no consent screen and no prompt on load. A guest is asked only when they press a button of yours.

### Know who is playing

After `ready()`, `Velven.user` holds the signed-in player, or `null` for a guest. Use it for display: a name on the title screen, an avatar beside a score.

```js
// Registered before ready(), it runs on every sign-in: the one known at ready(), and any later one.
const stop = Velven.onAuth((user) => showName(user.handle)); // stop() to stop listening

const environment = await Velven.ready(); // "velven" | "site" | "local"
Velven.user; // { id, handle, avatar } or null
```

`ready()` resolves once and never rejects. It usually settles in under a second; the [timing](https://velven.ai/docs/sdk/reference#reference-timing) is in the reference.

### Ask a guest to sign in

Call `signIn()` from a button. Inside Velven, a guest sees Velven's sign-in card over your space and signs in without your page reloading. The promise resolves when they have signed in, or chosen to stay a guest.

```js
button.addEventListener("click", async () => {
  const result = await Velven.signIn();
  if (result.ok) showName(result.user.handle); // also result.token and result.expiresAt
  else console.log(result.error);
});
```

A `signIn()` call that fails resolves with one of these codes:

| Code | Meaning |
| --- | --- |
| `signed_out` | The player chose to stay a guest or left the card open for 10 minutes, or the call was silent and nobody is signed in. |
| `unavailable` | Not inside Velven, or the space is not published with a verified owner. |
| `rate_limited` | More than 12 sign-in requests in a minute from this page. |
| `failed` | Velven did not answer. |
| `cancelled` | You cancelled the request with an `AbortSignal`. |

### Check without showing anything

`signIn({ silent: true })` never shows the card. It resolves with the token when the player is signed in, and `signed_out` otherwise. Use it before a call that needs the token, or to recover when a session ends.

```js
const quiet = await Velven.signIn({ silent: true });
```

### Cancel a sign-in request

Pass an `AbortSignal` to bring the card down when your own UI moves on, for example when your menu closes. The call resolves `cancelled`, which counts as the player staying a guest.

```js
const controller = new AbortController();
const asked = Velven.signIn({ signal: controller.signal });
controller.abort(); // the card comes down; asked resolves "cancelled"
```

### When the card shows

The card shows only when a player could have asked for it. These rules keep it from nagging:

- Ask from a button, or at the end of a run the player chose to finish. Never ask on load.
- Within 5 seconds of a player choosing to stay a guest, a plain `signIn()` resolves `signed_out` without the card, since no click of theirs could have asked for it.
- Once a player has stayed a guest twice on one page load, the card stops coming back and every plain `signIn()` resolves `signed_out`.
- A sign-in is cached. A token lasts 1 hour. `signIn` answers from memory until the token has under a minute left, then asks Velven again.

### When a session ends

A player can sign out in another tab mid-game. The next call that answers `signed_out` clears the cached token and sets `Velven.user` to `null`. Try `signIn({ silent: true })` once; if that answers `signed_out` too, show your sign-in button again.

### Requirements

Sign-in needs a listed space, played inside Velven:

- Only [a published space with a verified owner](https://velven.ai/docs/quickstart#prove) can sign players in. An unclaimed space answers `unavailable` until its creator [claims it](https://velven.ai/docs/quickstart#claim).
- Sign-in works inside Velven only. On your own site `signIn` answers `unavailable`, so a space with accounts of its own keeps using them there.

> Note: Is your space also embedded somewhere else, such as another game portal or a blog? There the SDK waits up to 7 seconds for Velven before it settles on `site`. Shorten the wait with `data-probe-timeout="3000"` on the script tag. The floor is 3 seconds, since inside Velven the page around your space can take that long to start on a slow phone.

### The token

The token is for your own server, to prove who scored on a [server board](https://velven.ai/docs/sdk/server). A space without a backend never needs it.

> Warning: The SDK keeps the token in memory and never writes it to storage. But `Velven` is a global, so any script on your page can call `signIn({ silent: true })` and read it. It opens only your own boards, and only together with your secret; still, keep third-party scripts off a page that handles it.

## 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.

### Declare a leaderboard

Declare boards in the page's `<head>`, in a `<script type="application/velven+json">` block beside the [proof tag](https://velven.ai/docs/hosting#proof). A score posted to a key you never declared is refused with `no_board`. Up to 10 boards per space.

index.html:

```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`](https://velven.ai/docs/hosting#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](https://velven.ai/docs/sdk/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.

- `key` (1 to 32 lowercase letters, digits, `-` or `_`): The name your calls use for the board. A call that names no board uses `main`.
- `trust` (`server`, `client`, default `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](https://velven.ai/docs/sdk/server) helps you choose.
- `metric` (`points`, `time`, `distance`, `level`, `custom`, default `points`): What the value measures. Use `custom` with a `label` and `unit` for anything else.
- `label` (Up to 40 characters): The board's name, for you to draw. Optional.
- `unit` (Up to 16 characters): Drawn after the value. Optional.
- `sort` (`desc`, `asc`, default `desc`): `desc`: higher is better. `asc`: lower is better, as for a time.
- `mode` (`best`, `sum`, default `best`): `best`: a player's best submission ranks. `sum`: their submissions add up.
- `entries` (`player`, `run`, default `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`.
- `min` (Number, decimals allowed): The lowest value accepted. Anything lower is `out_of_range`.
- `max` (Number, decimals allowed): The highest value accepted. Anything higher is `out_of_range`.
- `cooldown` (0 to 86400 seconds, default `0`): The least time between 2 submissions from one player.
- `period` (`all`, `daily`, `weekly`, default `all`): `daily` and `weekly` boards roll over at 00:00 UTC, weeks starting on Monday. Past days and weeks stay readable.
- `season` (Up to 32 characters): A name for a fresh start. Changing `metric` needs a new season. Past seasons stay readable.
- `display` (`page`, `game`, `both`, default `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](https://velven.ai/docs/api#boards) with [your API token](https://velven.ai/docs/api#auth). Only the boards you name are written, and the page's next sync overwrites a key the page also names.

```bash
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.

```js
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
}
```

- `board` (Board key, default `"main"`): The board to post to.
- `meta` (Object, up to 1 KB as JSON): Anything to keep with the score, such as the car or the level. Returned untouched with every read.
- `requestId` (1 to 64 letters, digits, `-` or `_`): A retry key for this run. See [Retry safely](https://velven.ai/docs/sdk/leaderboards#retries).
- `contentId` (A 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](https://velven.ai/docs/sdk/leaderboards#score-content). 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](https://velven.ai/docs/sdk/server).

### Read and draw the leaderboard

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

```js
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 board

const 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
```

- `top` (`limit` 1 to 100, default `10`): Rows from the top. Page on by passing the last row back as `after`. Ties go to the earlier submission.
- `around` (`each` 0 to 50, default `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.

```js
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](https://velven.ai/docs/sdk/content), 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.

```js
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.

```js
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-21
const lastSeason = await Velven.scores.mine({ season: "s1" });
```

- `bucket` (`current`, `previous`, or a `YYYY-MM-DD` date, default `current`): A date reads the day or week it falls in.
- `season` (A season name, default 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.

```js
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](https://velven.ai/docs/sdk/reference#reference-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](https://velven.ai/docs/api#moderation). 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. 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. |
| `no_board` | 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. |
| `server_only` | 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"`. |
| `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 blocked this player on the space's boards, or Velven banned their account. |
| `invalid_value` | `meta` is over 1 KB. Answered before anything is sent. |
| `invalid_content` | `contentId` is not a content item of this player's own in this space, or it was deleted. |
| `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. |

## Saves

`Velven.data` keeps a player's progress: levels, unlocks, settings. It works like `localStorage`, with the same 4 calls and string values, so a game that saves there changes its calls and nothing else.

### Read and write

Read and write with the 4 calls you know from `localStorage`, once `ready()` has settled.

```js
await Velven.ready();

// getItem answers a string, or null
const level = Number(Velven.data.getItem("level") ?? 1);
// setItem stores a string, like localStorage
Velven.data.setItem("level", level + 1);
Velven.data.removeItem("tutorial");
Velven.data.clear();
```

Reads are synchronous once `ready()` has settled. Calling `Velven.data` before then throws, because the save is not loaded yet.

### Where saves live

Where a save lives depends on where the page runs:

- Inside Velven, the save is kept in the player's browser. On a published space with a verified owner, a signed-in player's save is kept in their account too, so it follows them to another device.
- Writes land in memory at once and are sent within a second, and at once when the frame is hidden or closed. A write in the last moment before a tab closes may not arrive, so save at checkpoints, not only on exit.
- On your own site (the `site` environment), the save is the page's own `localStorage`, under the key `velven:data`. It never reaches Velven and is separate from the save inside Velven.

### When a guest signs in

A new account takes the guest's save. An account that already has a save keeps its own, and the guest's stays in that browser for when they sign out. Either way, the account's save is in place before `onAuth` runs, so read it again there:

```js
Velven.onAuth(() => loadProgress(Velven.data.getItem("level")));
```

### When the save is slow

If the save cannot be fetched in time, the game starts from an empty save that stays in memory. Velven never writes it over the player's real save. Velven asks for the save again before the next send; when it arrives, it replaces what was written meanwhile, with a warning in the console.

### Save size limits

A save is small, private, and one per player:

- One save per player per space, up to 1 MB as JSON. A `setItem` that would go over throws a `RangeError` and changes nothing, as `localStorage` does when full.
- The key `__proto__` throws a `TypeError`.
- Nobody can read a player's save, you included. To let a player start over, give the game a reset that calls `Velven.data.clear()`.
- Keep a personal best on a board, where [`scores.mine`](https://velven.ai/docs/sdk/leaderboards#reads) reads it, not in a save.

## Achievements

An achievement is a goal a player unlocks once, worth 10 points on their Velven profile when your space is listed and has a creator. A stat is a named counter per player, and an achievement with a trigger unlocks by itself when its stat gets there. You declare both; Velven keeps them, draws a toast for each unlock and lists them on your space's Velven page.

### Declare achievements and stats

Declare them in the page's block, beside the [boards](https://velven.ai/docs/sdk/leaderboards#boards). An unlock of a key you never declared answers `no_achievement`, and a stat you never declared answers `no_stat`.

index.html:

```html
<script type="application/velven+json">
{"boards":[{"key":"main","trust":"client"}],
 "stats":[{"key":"kills","label":"Kills"}],
 "achievements":[
   {"key":"first-blood","title":"First Blood","description":"Win a round"},
   {"key":"centurion","title":"Centurion","description":"100 kills","trigger":{"stat":"kills","atLeast":100}},
   {"key":"hidden-door","title":"Found it","secret":true,"icon":"icons/door.png"}
 ]}
</script>
```

Velven reads the block when the space is listed and on its background check every 6 hours; “Check my page now” on the edit page's Leaderboards tab reads it at once. There are 2 other places to declare them:

- A space hosted on Velven declares them in `velven.json`, with the same `stats` and `achievements` keys: a version that names them syncs them when it goes live; a preview uses the live space's. [Hosting](https://velven.ai/docs/hosting#velven-json) has the file.
- Without a deploy, send `{ "stats": [...], "achievements": [...] }` with `PUT /api/spaces/<slug>/achievements` and [your API token](https://velven.ai/docs/api#achievements). Only what you name is written, and the page's next sync overwrites a key it also names. `GET` on the same address answers what Velven holds.

> Note: Remove an achievement or a stat from the block and it is withdrawn: hidden, with the players' unlocks and values kept. Name it again and it comes back.

### Fields

An achievement takes these fields; only `key` and `title` are required:

- `key` (1 to 32 lowercase letters, digits, `-` or `_`): The id your calls use. Unique among the space's achievements.
- `title` (1 to 60 characters): The name the toast, the space's page and the player's profile show.
- `description` (Up to 200 characters, default empty): What earns it.
- `secret` (`true` or `false`, default `false`): Hidden, title and all, until the player earns it. `list` answers it with a null title and description until then.
- `icon` (Up to 500 characters): An https address, or a path read against your page's address: a file next to a linked page, or a file in a hosted upload. Anything else shows no icon.
- `trigger` (`{ "stat": key, "atLeast": number }`): Unlocks the achievement when the named stat reaches `atLeast`. The stat must be declared in the same place.

A stat takes `key` (the same rule) and an optional `label` of up to 40 characters, for you to draw. Up to 50 achievements and 50 stats per space, withdrawn ones aside; declaring more answers `too_many`.

### Unlock and count

Unlock when the player earns it, and write stats as they change. Each call needs a signed-in player.

```js
const got = await Velven.achievements.unlock("first-blood");
// { ok: true, achievement: "first-blood", title, unlocked, points: 10, total }, points 0 where it counts none

const kills = await Velven.stats.add("kills", 1);   // or stats.set("kills", 42)
// { ok: true, stat: "kills", value: 43, unlocked: [ ... ] }, the triggers this value reached
const now = await Velven.stats.get("kills");        // { ok: true, stat: "kills", value: 43 }, 0 before a first write

const all = await Velven.achievements.list();
// { ok: true, achievements: [{ key, title, description, icon, secret, trigger, points, unlocked, unlockedAt, holders, players, share }] }
```

- An unlock is idempotent: `unlocked` is `false` when the player already held it, so call it whenever the goal is met. `total` is the player's points across Velven.
- Points count on a listed space with a creator: its creator's own unlocks count none, and an achievement you stop declaring stops counting. Where an unlock counts none (your own, a page published without an account, the sandbox), it answers `points: 0` and Velven's toast shows no points.
- `stats.add` takes a negative number to take away. A value that is not a finite number, or a key that is not one, throws a `TypeError` before anything is sent.
- `list` answers every achievement in the order you declared them, with the player's own unlocks. `share` is the part of the space's players who hold it, from 0 to 1: `holders` out of `players`, where a player is anyone with an achievement or a stat in the space.

`onAchievement` hears every unlock the player earns, from `unlock` or from a stat reaching a trigger:

```js
const stop = Velven.onAchievement((a) => console.log(`${a.title} +${a.points}`)); // { key, title, description, icon, points }
```

The page and [your server](https://velven.ai/docs/sdk/server#server-achievements) (with the space's secret) unlock the same achievements. An unlock from your server is marked trusted, and a page unlock your server repeats becomes trusted, so for a goal your server referees, such as beating a boss, unlock it there.

### The unlock toast

Velven draws a toast over the corner of the frame for each unlock, such as “Achievement unlocked: First Blood, +10”, so you need draw nothing. If your space draws its own from `onAchievement`, turn Velven's off in one of 3 ways:

Script tag:

```html
<script src="https://velven.ai/sdk/v1.js" data-toasts="false"></script>
```

npm:

```js
import { createVelven } from "@velven/sdk";

const Velven = createVelven({ toasts: false });
```

velven.json:

```json
{ "toasts": false }
```

`velven.json` is for a space hosted on Velven, where Velven adds the script tag for you.

### On your space's Velven page

The space's Velven page has an Achievements section: every achievement with its icon, the share of players who hold it and, for a signed-in player, which ones they have. A secret achievement shows as hidden until the player earns it. An unlock that counts adds 10 points to the player's profile, where the total and their recent unlocks show; the section shows points only where they count.

### Error codes

| Code | Meaning |
| --- | --- |
| `signed_out` | No player is signed in. Offer `signIn()` from a button. |
| `no_achievement`, `no_stat` | The space declares no such key, or it was withdrawn. |
| `invalid_value` | The stat's value is not a finite number: an answer to your server, since the page's call throws first. |
| `unavailable` | Not inside Velven, on a space not published with a verified owner, or on a Velven page too old to know achievements. |
| `banned`, `rate_limited`, `failed` | As for [scores](https://velven.ai/docs/sdk/leaderboards#score-errors). |

On `localhost` achievements and stats come from your block and live in memory: an unlock reaches `onAchievement`, and a stat reaching a trigger unlocks it. [Local testing](https://velven.ai/docs/sdk/local) has the rest.

## Player content

`Velven.content` stores what your players make, levels, replays, maps, and lets everyone in your space find and download the public ones. Velven keeps the data and never shows it on its own pages: your space lists and draws it.

### Upload an item

An upload needs a signed-in player. `data` is a string, an `ArrayBuffer`, a typed array or a `Blob`:

```js
const up = await Velven.content.upload({
  kind: "level",            // level, replay, map or other
  title: "Castle one",      // 1 to 80 characters
  data: JSON.stringify(level),
  visibility: "public",     // the default; "private" keeps it to the uploader
});
if (up.ok) shareCode(up.item.id); // item: { id, kind, title, visibility, size, createdAt, by: { id, handle, avatar } }
```

- An item is 5 MB at most, checked before anything is sent: `too_large`. A player's items in one space are 50 MB at most: `quota_exceeded`.
- A public item is listed for everyone in the space. A private one is listed and downloaded only by its uploader, such as a draft or a ghost run.
- A wrong `kind`, a title out of range or empty data throws a `TypeError` before anything is sent.

### List and download

```js
const page = await Velven.content.list({ kind: "level", search: "castle" }); // { items, cursor }, 20 a page, newest first
const more = await Velven.content.list({ kind: "level", cursor: page.cursor });  // cursor is null on the last page
const mine = await Velven.content.list({ by: Velven.user.id });

const got = await Velven.content.download(page.items[0].id); // { item, data }: a string for text, an ArrayBuffer for bytes
```

`list` takes `kind`, `by` (a player's id), `search` (words in the title, up to 80 characters) and `cursor` (from the page before). Reading needs no sign-in.

> Warning: Content is other players' data. Parse it defensively, never run it as code, and draw titles and handles as text (`textContent`, never `innerHTML`).

### Attach it to a score

Name an item on a score with `contentId`, and every read of the board answers it on the row, so a player can watch the run behind a rank. [Keep a replay with a score](https://velven.ai/docs/sdk/leaderboards#score-content) has the code.

### Remove and report

```js
await Velven.content.remove(itemId);            // its uploader, the space's creator, or Velven
await Velven.content.report(itemId, "offensive"); // offensive, spam, harassment, cheating or other
```

An uploader removes their own items, and you, as the space's creator, remove any in your space. A report goes to Velven's team, not to you; the [content policy](https://velven.ai/content-policy) says what is not allowed. Give players a way to report what your space shows.

### Error codes

| Code | Meaning |
| --- | --- |
| `too_large` | Over 5 MB. Answered before anything is sent. |
| `quota_exceeded` | The player's items in this space would pass 50 MB. Removing old ones makes room. |
| `not_found` | No such item, or a private one of someone else's. |
| `forbidden` | Only its uploader, the space's creator or Velven may remove it. |
| `signed_out`, `unavailable`, `banned`, `rate_limited`, `failed` | As for [scores](https://velven.ai/docs/sdk/leaderboards#score-errors). |

On `localhost` content lives in memory until a reload. [Local testing](https://velven.ai/docs/sdk/local) has the rest.

## Rooms and presence

A room is a relay on Velven's servers for 2 to 16 signed-in players of your space: what one sends, the others get at once. You need no server of your own. Presence tells a player's friends what they are playing and lets them join.

### Open and join a room

A player opens a room and is its host; others join by its id. A player is in one room at a time, and has at most five rooms open; a room nobody enters within two minutes closes.

```js
const made = await Velven.rooms.create({ visibility: "friends", maxPlayers: 4 });
// { ok: true, room: { id, you, host, players, data, maxPlayers, visibility } }

const open = await Velven.rooms.list();  // { ok: true, rooms: [{ id, visibility, host, players, maxPlayers, full, createdAt }] }
const joined = await Velven.rooms.join(open.rooms[0].id);

Velven.rooms.leave();
```

- `visibility` (`public`, `friends` or `private`, default `public`): `public`: listed for every player of the space. `friends`: listed for the host's friends only, and only they may join. `private`: never listed; joined through an invite or its link.
- `maxPlayers` (2 to 16, default `8`): The most players in the room at once. A join past it answers `full`.

`Velven.rooms.current` is the room the player is in, or null. `list()` answers full rooms too, marked `full`, so you can show them.

### Invite friends

```js
await Velven.rooms.invite("mara");           // a notification to a friend, with a Join button
const link = Velven.rooms.inviteLink();       // your space's Velven page with ?room=<id>, or null outside a room

// Velven put the player in a room: they opened an invite link, or pressed Join.
Velven.rooms.on("room", ({ room }) => enterLobby(room));
```

Only a player in the room, or the one who opened it, invites. An invite reaches only a friend: friends are mutual, made on Velven. `invite` answers `{ ok: true }` whether or not the handle is a friend's, so an invite tells your space nothing about who is one. A friend is invited only when they may join: a friends room takes the opener's friends only. The link works for anyone signed in who may join the room. Whoever opens it lands on your space's Velven page and joins the room as the page loads; the `room` event tells your space, even when you start listening after the join.

### Send and receive

```js
Velven.rooms.send({ x: 3, y: 4 });            // JSON to everyone else
Velven.rooms.send(bytes, { to: playerId });   // or bytes (an ArrayBuffer or a typed array), to one player
Velven.rooms.on("message", ({ from, data }) => apply(from, data)); // data: JSON as sent, or an ArrayBuffer

Velven.rooms.chat("gg");                       // to everyone, you included
Velven.rooms.on("chat", (line) => showChat(line.handle, line.text)); // { id, from, handle, text, at, sig }
await Velven.rooms.report(line, "harassment"); // a chat line as on("chat") heard it, to Velven's team
```

`send`, `chat`, `setData`, `kick` and `leave` answer at once: `{ ok: true }`, or `{ ok: false, error }` with `not_in_room`, `not_host`, `too_large` or `too_long`. A message is 16 KB at most and a chat line 500 characters. The relay signs each chat line (`sig`), and a report is taken only for a line it signed, so pass the line to `report` as you heard it.

### The host

The player who opens the room is its host whenever they are in it. For the first 30 seconds nobody else hosts, so a player who joins before the opener's page connects does not take it; after that, a room without its opener is hosted by the player in longest. When the host leaves, the player in longest takes over, and the opener takes it back on coming in; everyone hears a `host` event. Only the host sets the room's data and removes players:

```js
Velven.rooms.setData("round", 2);              // null deletes a key
Velven.rooms.on("data", ({ data }) => drawRound(data.round)); // everyone, the host too, hears the whole map
Velven.rooms.kick(playerId);                   // they cannot come back into this room
```

Room data is a map of keys of 1 to 64 characters, 64 KB in all, and a player who joins gets it in `current.data`. Keep what every player must agree on there, such as the round or the map; send moves as messages.

### Events

`Velven.rooms.on(event, handler)` returns a function that stops listening.

| Event | Payload | When |
| --- | --- | --- |
| `join` | `{ player, players }` | A player came in. `player` is `{ id, handle, avatar, joinedAt }`. |
| `leave` | `{ player, reason, players }` | A player went; `reason` is `left` or `kicked`, and `player` their id. |
| `host` | `{ host }` | The host changed; `host` is a player's id. |
| `message` | `{ from, data }` | Another player sent to everyone, or to you. |
| `chat` | `{ id, from, handle, text, at, sig }` | A chat line, yours included; `sig` is the relay's signature, which `report` sends along. |
| `data` | `{ data }` | The host changed the room's data. |
| `room` | `{ room }` | Velven put the player in a room: an invite link or Join. |
| `close` | `{ reason }` | You are out of the room: `left`, `kicked`, `replaced` (you joined again from another tab), `full`, `wrong_space` or `lost` (the connection dropped). |
| `error` | `{ code, message }` | The relay refused something, such as `rate_limited`, or a join from a link failed. |

### A small example

A page where everyone in a room sees each other's pointer. The first player opens a room and shares the link; whoever opens it joins.

index.html:

```html
<!doctype html>
<html>
<head>
  <meta name="velven" content="@handle">
  <script src="https://velven.ai/sdk/v1.js"></script>
</head>
<body>
  <button id="open">Open a room</button>
  <p id="status"></p>
  <canvas id="board" width="640" height="400"></canvas>
  <script>
    const status = document.getElementById("status");
    const ctx = document.getElementById("board").getContext("2d");
    const pointers = new Map(); // player id -> { x, y }

    function draw() {
      ctx.clearRect(0, 0, 640, 400);
      for (const [id, p] of pointers) {
        ctx.fillStyle = id === Velven.rooms.current?.you.id ? "#fff" : "#888";
        ctx.fillRect(p.x - 4, p.y - 4, 8, 8);
      }
    }

    function enter(room) {
      status.textContent = `In a room with ${room.players.length} of ${room.maxPlayers}. Share: ${Velven.rooms.inviteLink()}`;
    }

    document.getElementById("open").onclick = async () => {
      const made = await Velven.rooms.create({ visibility: "private", maxPlayers: 4 });
      if (made.ok) enter(made.room);
      else if (made.error === "signed_out") await Velven.signIn();
      else status.textContent = `Could not open a room: ${made.error}`;
    };

    Velven.rooms.on("room", ({ room }) => enter(room));
    Velven.rooms.on("join", () => enter(Velven.rooms.current));
    Velven.rooms.on("leave", ({ player }) => { pointers.delete(player); draw(); });
    Velven.rooms.on("message", ({ from, data }) => { pointers.set(from, data); draw(); });

    let last = 0;
    document.getElementById("board").onpointermove = (e) => {
      const room = Velven.rooms.current;
      if (!room || e.timeStamp - last < 50) return; // 20 a second, under the relay's 30
      last = e.timeStamp;
      const p = { x: e.offsetX, y: e.offsetY };
      pointers.set(room.you.id, p);
      Velven.rooms.send(p);
      draw();
    };
  </script>
</body>
</html>
```

The status line is set with `textContent`, so a handle is never read as markup. A real game keeps the same shape: moves as messages, shared state in the room's data, a lobby drawn from `current.players`.

### Test with a friend

On `localhost` a room holds only you: `chat` and `setData` come back to you, `send` reaches nobody. To test with others, publish a [preview](https://velven.ai/docs/sdk/local#sandbox) and send them its link: anyone signed in who holds it can play there, and rooms opened in a preview stay in your space's sandbox, apart from the live space's rooms.

### Limits

- 2 to 16 players a room, and one room at a time per player.
- A message is 16 KB at most, JSON or bytes. A chat line is 1 to 500 characters. Room data is 64 KB in all.
- 30 messages a second per player, with a burst of 60. Past that the relay drops them and sends one `error` a second with `rate_limited`; send state at a steady rate rather than on every frame.
- Per account: 30 rooms opened an hour and 30 room passes a minute.
- A room closes 30 seconds after its last player leaves.

### Error codes

| Code | Meaning |
| --- | --- |
| `in_room`, `not_in_room` | Leave the room first, or join one. |
| `not_found`, `closed`, `full` | No such room in this space, it has closed (a room nobody enters within two minutes closes), or it is full. |
| `friends_only` | A friends room of someone who is not your friend. |
| `kicked` | The host removed this player from the room. |
| `not_host` | Only the host sets data or kicks. |
| `too_large`, `too_long` | A message over 16 KB, or a chat line over 500 characters. |
| `signed_out`, `unavailable`, `banned`, `rate_limited`, `failed` | As for [scores](https://velven.ai/docs/sdk/leaderboards#score-errors). |

### Presence

While a signed-in player is on your space's Velven page, their friends see “Playing” and your space's name, in their friends list and on the player's profile; once they leave, “Last seen” and when. You need do nothing for this. Only friends see it.

Add a line of your own with `presence.set`:

```js
await Velven.presence.set({ status: "Level 3, 2 lives left" }); // 80 characters at most; null clears it
```

- When the player is in a room a friend may join, that friend also gets a Join button beside it, which opens your space's page and joins the room.
- `presence.set` answers `{ ok: true }`, or `{ ok: false, error }` with `signed_out`, `unavailable`, `too_long`, `rate_limited` or `failed`. Up to 30 lines a minute per account.
- Say what the player is doing, not who they are with: friends see the line as it is.

## Server scores

A `server` board takes scores only from your own server, with the space's secret and the player's token. It is as honest as the checks your server runs.

### Choose a tier

Choose `server` when your server has something to check, and `client` otherwise.

| Tier | Who posts | What it proves |
| --- | --- | --- |
| `client` | Your space's page, through the Velven page | The player's word, within the board's range, cooldown and caps. |
| `server` | Your server, with the secret and the player's token | That the score came through your server. |

A server board is only worth more when your server checks something: it runs the game, or checks the run against its own record. A server that relays the number the page sent proves no more than `client`, and costs you a function.

The server can live anywhere. Velven checks the secret and the token, not where the post came from. A space on a static host, such as a ChatGPT site or GitHub Pages, can post through a small function on Vercel, Netlify, Cloudflare or Replit. With no server at all, declare the board `client`.

> Note: A function on another origin needs CORS. The page sends it a JSON body, so the browser sends a preflight first. Answer `OPTIONS` with `Access-Control-Allow-Origin` set to your page's origin and `Access-Control-Allow-Headers: content-type`. Send the same `Access-Control-Allow-Origin` on the POST's answer.

### 1. Issue the secret

Declare the board with `"trust":"server"`, the default, in the page's block:

index.html:

```html
<script type="application/velven+json">
{"boards":[{"key":"main","trust":"server","metric":"time","sort":"asc","cooldown":10}]}
</script>
```

Then issue the secret: on the Leaderboards tab of the space's edit page, open “For developers” and press “Make a key” under “Server key”. Or issue it through the [REST API](https://velven.ai/docs/api#secret) with your API token. It is shown once and only its hash is kept; issuing again replaces it.

```bash
curl -s -X POST https://velven.ai/api/spaces/<slug>/secret -H "authorization: Bearer $VELVEN_TOKEN"
# { "secret": "…", "message": "Shown once. Put it in the host's server environment …" }
```

> Warning: Put the secret in your server's environment, never in the page.

### 2. Send the run from the page

When a run ends, get the player's token with `signIn()` and send it to your server with the run. Use a plain `signIn()`, not a silent one: the run's end is the player's own action, so a guest may be shown the [sign-in card](https://velven.ai/docs/sdk/sign-in#card-rules).

game.js:

```js
const auth = await Velven.signIn();
if (auth.ok && Velven.environment === "local") {
  await Velven.scores.submit(score); // the in-memory board, so the page is built and drawn locally
} else if (auth.ok) {
  await fetch("https://your-function.example.com/api/score", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ token: auth.token, value: score, request_id: crypto.randomUUID() }),
  });
}
```

On your laptop there is no function and the token is fake, so in the `local` environment the example posts to the in-memory board instead. The SDK takes it with a console note; add `?velven_strict=1` to refuse it as Velven does.

### 3. Post from your server

On your server, POST the run to `https://velven.ai/api/v1/scores` with the secret in `x-velven-secret` and the player's token in the body. Velven verifies the token and its space on every post, so a server that only forwards the score needs nothing else. `postScore` from `@velven/sdk/server` does it with no dependencies, on Node 20 and later, Deno, Bun and Cloudflare Workers. The package has no local mode: a token is Velven's or it is refused.

api/score.js:

```js
import { postScore } from "@velven/sdk/server";

export default async (request) => {
  const { token, value, request_id } = await request.json();
  // Check the run here, such as by replaying its inputs: a bare relay proves no more than a client board.
  const result = await postScore({ secret: process.env.VELVEN_BOARD_SECRET, token, value, requestId: request_id });
  return Response.json(result); // { ok: true, rank, total, improved, ... } or { ok: false, error }, for the page to read
};
```

Verify the token yourself only when your server needs the player's identity: to check the run against what it holds for that player, to key your own records by player, or to refuse a bad token before posting. Otherwise skip it: a cold start saves fetching Velven's keys. To verify, check the token against `https://velven.ai/.well-known/jwks.json`: the issuer is `https://velven.ai`, the audience is your space's origin, the algorithm is ES256, and the `space` claim is your space's id. `verifyToken` does that, or use any JWT library, such as `jose`:

@velven/sdk/server:

```js
import { postScore, verifyToken } from "@velven/sdk/server";

export default async (request) => {
  const { token, value, request_id } = await request.json();
  // Your space's id (Velven.space.id in the page), checked when the variable is set.
  const space = process.env.VELVEN_SPACE_ID ? Number(process.env.VELVEN_SPACE_ID) : undefined;
  const player = await verifyToken(token, { audience: "https://your-space.netlify.app", space });
  if (!player.ok) return Response.json({ error: player.error }, { status: 401 });
  // Check the run against your own record for player.user.id: this is what makes a server board worth more than a client one.
  const result = await postScore({ secret: process.env.VELVEN_BOARD_SECRET, token, value, requestId: request_id });
  return Response.json(result); // { ok: true, rank, ... } or { ok: false, error }, for the page to read
};
```

jose:

```js
import { createRemoteJWKSet, jwtVerify } from "jose";

const VELVEN = "https://velven.ai";
const SPACE_ORIGIN = "https://your-space.netlify.app";
const SPACE_ID = Number(process.env.VELVEN_SPACE_ID); // your space's id: Velven.space.id in the page
const jwks = createRemoteJWKSet(new URL(`${VELVEN}/.well-known/jwks.json`));

export default async (request) => {
  const { token, value, request_id } = await request.json();
  try {
    const { payload } = await jwtVerify(token, jwks, { issuer: VELVEN, audience: SPACE_ORIGIN, algorithms: ["ES256"] });
    if (payload.space !== SPACE_ID) throw new Error("wrong space");
  } catch {
    return Response.json({ error: "invalid_token" }, { status: 401 });
  }
  const res = await fetch(`${VELVEN}/api/v1/scores`, {
    method: "POST",
    headers: { "content-type": "application/json", "x-velven-secret": process.env.VELVEN_BOARD_SECRET },
    body: JSON.stringify({ token, board: "main", value, request_id }),
  });
  return Response.json(await res.json(), { status: res.status });
};
```

Either way, Velven checks the token itself, so a leaked secret alone cannot post as a player.

> Note: Moving your space to a new URL (Move to a new address, on the Settings tab of its edit page) keeps its id, boards and secret, but tokens from then on carry the new origin as their audience. Update the audience your server checks when you move.

### Request body

`POST /api/v1/scores` with the header `x-velven-secret` and a JSON body:

- `token` (String): Required. The player's token, from `signIn()` in the page.
- `board` (Board key): Required. The board to post to. `postScore` sends `main` when you leave it out; the raw API has no default.
- `value` (Number): Required. The score.
- `request_id` (1 to 64 letters, digits, `-` or `_`): Required. Makes the post idempotent: a retry with the same id answers as the first did and is entered once. `crypto.randomUUID()` fits. The same rule holds for `requestId` in the page and in `postScore`.
- `meta` (Object, up to 1 KB as JSON): Optional. Returned with every read.

### Responses

Every answer is JSON. A refusal carries its code in `error`:

| Status | Body | Meaning |
| --- | --- | --- |
| 200 | `{ "ok": true, "board", "value", "total", "improved", "rank" }` | Entered. `rank`, `total` and `improved` mean what they do for [`submit`](https://velven.ai/docs/sdk/leaderboards#scores): the player's standing now, not this run's rank, except on a `run` board. |
| 400 | `{ "error": "bad_request" }` | The body was not `{ token, board, value, request_id }` with an optional `meta`. |
| 401 | `{ "error": "invalid_secret" }` | No space has that secret. Issue a new one and put it in the environment. |
| 401 | `expired_token` or `invalid_token` | The token is past its hour, or Velven did not sign it. Ask the page for a fresh one. |
| 403 | `wrong_space` | The token was made for another space. |
| 403 | `banned`, `server_only` or `client_only` | You blocked the player on the space's boards, or Velven banned their account, or the board's trust does not take this path. |
| 404 | `unavailable` or `no_board` | The space is not published with a verified owner, or no board has that key. |
| 422 | `{ "error": "out_of_range", "min", "max" }` or `invalid_value` | Outside the board's range, not a finite number, or `meta` over 1 KB. |
| 429 | `{ "error": "cooldown", "retry_after" }` or `rate_limited` | Too soon for this player, or more than 600 posts from this space in a minute. |
| 503 | `{ "error": "failed" }` | Velven could not store it just now. Retry with the same `request_id`. |

### Unlock achievements from your server

With the same secret and the player's token, your server unlocks an [achievement](https://velven.ai/docs/sdk/achievements) or writes a stat. An unlock from your server is marked trusted, and raises one the page made earlier.

```js
import { setStat, unlockAchievement } from "@velven/sdk/server";

await unlockAchievement({ secret: process.env.VELVEN_SECRET, token, achievement: "boss" });
const kills = await setStat({ secret: process.env.VELVEN_SECRET, token, stat: "kills", value: 1, mode: "add" });
// { ok: true, stat: "kills", value, unlocked: [{ key, title, description, icon, points }] }
```

Both post to `/api/v1/achievements` and `/api/v1/stats` with `x-velven-secret`, and answer `bad_request`, `invalid_secret`, `invalid_token`, `expired_token`, `wrong_space`, `unavailable`, `banned`, `no_achievement`, `no_stat`, `invalid_value`, `rate_limited` or `failed`, as `postScore` does.

### Tokens from a preview

A token made in a [preview or `velven dev`](https://velven.ai/docs/sdk/local#sandbox) carries `"sbx": true` and names the preview's own address as its audience. `verifyToken` answers `sandbox: true` for it. Velven writes what that player does to your space's sandbox, never the live space; keep it out of anything of your own that counts for real.

## Local testing

On `localhost` the SDK answers with fakes, chosen in the page's query string, so you can build and draw everything before the space is listed.

### How it works

The SDK runs in the `local` environment on `localhost`, `127.0.0.1` and `[::1]`, or on any host with `?velven_local=1`. At the top of a window it decides at once, so nothing waits. In a frame it first looks for Velven, so a game served by `velven dev` reaches the Velven page that frames it; with no answer it settles `local` after the probe's wait.

- Scores go to an in-memory board per key, ranked by your page's own block: `sort`, `mode`, `min`, `max` and `cooldown` all apply. 3 fake players are seeded so the board has rows to draw. Nothing survives a reload.
- Boards roll over as they do on Velven: `daily` at 00:00 UTC, `weekly` on Monday. [`bucket` and `season`](https://velven.ai/docs/sdk/leaderboards#history) read past periods and seasons. Only the current bucket is seeded.
- A score your page submits to a `server` board is taken, with one console note that Velven would answer `server_only`, so you can build a game that posts scores from its server before that server exists.
- `Velven.data` saves to `localStorage` under `velven:data:local:alice` for `?velven_user=alice`, or `velven:data:local:guest` without it, so changing the query string switches saves. It survives a reload; clear it from the browser's storage panel.
- With no block on the page, or one that does not parse, score calls answer `no_board` and the console says why, once. The same warning appears in every environment.
- `Velven.game.start()` and `Velven.game.stop()` log each change to the console. Without `?velven_muted=1`, an [`onMute`](https://velven.ai/docs/sdk#gameplay) handler is never called.
- Achievements and stats come from your block and live in memory: an unlock reaches `onAchievement`, and a stat reaching a trigger unlocks it. Content lives in memory until a reload.
- A room holds only the local player: `chat` and `setData` come back to you as they would from the relay, `send` reaches nobody. A friends read is your own row, ranked 1. `presence.set` and `rooms.invite` log to the console.

### Query parameters

Choose what the fakes answer by adding these to the page's address:

- `?velven_user=alice`: Signs in a fake player, `{ id: "local-alice", handle: "alice", avatar: null }`, with a fake token good for an hour. Without it, `signIn` answers `signed_out`, as for a guest.
- `?velven_token=expired`: Makes the token already expired, so your refresh path runs and your server refuses it.
- `?velven_seed=120000,40000,9000`: Sets the 3 seeded players' values, so the board looks like your game's. `?velven_seed=none` seeds nobody.
- `?velven_strict=1`: Refuses a score your page submits to a `server` board with `server_only`, as Velven does.
- `?velven_slow=800`: Waits that many milliseconds before every answer, so your loading states show.
- `?velven_muted=1`: Calls every `onMute` handler with `true` right after you register it, as for a player who has turned the sound off.
- `?velven_local=1`: Uses the local environment on any host.

### Previews and velven dev

A preview of a hosted space (`velven publish`, and its preview link) and `velven dev` (your folder, served on your machine and framed by `https://velven.ai/dev`) run inside Velven's page in sandbox mode: sign-in, boards, saves, achievements, stats, content and rooms all work, on the real API, but everything they write goes to your space's sandbox. The live space never sees it, and `Velven.sandbox` is `true`.

- Anyone signed in who holds a preview link can play there, so you can test a room with a friend.
- `velven reset` clears the sandbox by kind (`--scores`, `--saves`, `--achievements`, `--content`, `--rooms`) or for one `--player`. Sandbox data is deleted 30 days after its last write.
- The token a sandbox player gets carries `"sbx": true`; your server can tell it apart with `verifyToken`.

## Examples

2 examples for a game called Orbit Dodger, one for each [trust tier](https://velven.ai/docs/sdk/server#server), which sets who may post a score. The first is a complete single page that posts its own scores; the second is the scoring module of a bundled game that posts from its server. Both use one board, `main`.

### Client tier, one page

A single page that loads the script tag and posts from the page. It draws its board on the game-over screen.

index.html:

```html
<!doctype html>
<html>
<head>
  <meta name="velven" content="@mara">
  <script type="application/velven+json">
  {"boards": [{
    "key": "main", "trust": "client", "metric": "points", "sort": "desc",
    "min": 0, "max": 100000, "cooldown": 5
  }]}
  </script>
  <script src="https://velven.ai/sdk/v1.js"></script>
</head>
<body>
  <canvas id="game"></canvas>
  <ol id="board"></ol>
  <button id="sign-in" hidden>Sign in to be ranked</button>
  <script>
    const board = document.getElementById("board");
    const signIn = document.getElementById("sign-in");

    async function drawBoard() {
      const top = await Velven.scores.top({ limit: 10 });
      if (!top.ok) return;
      // Rows are other players' data: text only, never markup.
      board.replaceChildren();
      const line = (text) => {
        const li = document.createElement("li");
        li.textContent = text;
        board.append(li);
      };
      for (const r of top.rows) {
        line(`#${r.rank} @${r.user.handle} ${r.value}`);
      }
      if (Velven.user) {
        const mine = await Velven.scores.mine();
        if (mine.ok && mine.row) {
          line(`You: #${mine.row.rank} with ${mine.row.value}`);
        }
      }
    }

    // The game fires this when a run ends; the score is the run's points.
    addEventListener("game:over", async (event) => {
      const result = await Velven.scores.submit(event.detail.points);
      // A guest: offer sign-in, never force it.
      if (!result.ok && result.error === "signed_out") signIn.hidden = false;
      await drawBoard();
    });

    // Sign-in from a button, never on load.
    signIn.addEventListener("click", async () => {
      const auth = await Velven.signIn();
      if (auth.ok) signIn.hidden = true;
    });

    Velven.ready().then((environment) => {
      // "velven" inside Velven, "local" on your laptop.
      if (environment !== "site") drawBoard();
    });
  </script>
</body>
</html>
```

### Server tier, bundled

A bundled game that posts through its own function, the one in [Server scores](https://velven.ai/docs/sdk/server#server-verify). It asks for sign-in when a run ends rather than from a button, since the player chose to finish the run; the [card's rules](https://velven.ai/docs/sdk/sign-in#card-rules) allow both.

game.js:

```js
import { Velven } from "@velven/sdk";

const post = (path, body) =>
  fetch(path, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });

export async function onRunOver(points) {
  // The player finished the run, so the card may show.
  const auth = await Velven.signIn();
  if (auth.ok) {
    // Your function verifies the token, then posts to Velven with the secret.
    await post("/api/score", {
      token: auth.token,
      value: points,
      request_id: crypto.randomUUID(),
    });
  }
  const top = await Velven.scores.top({ limit: 10 });
  // trust is "server" here; say so on the board.
  if (top.ok) drawBoard(top.rows, top.trust);
}

// A later sign-in, through the card or another button.
Velven.onAuth((user) => showName(user.handle));
```

> Note: In a React app with a component of its own named `Velven`, import the SDK under another name: `import { Velven as sdk } from "@velven/sdk"`.

## SDK reference

Every call, property, result, timing and limit in the SDK, in one place.

### Calls

Every call resolves; `ok` tells the 2 shapes apart.

| Call | `ok: true` | `ok: false` |
| --- | --- | --- |
| `ready()` | The environment: `velven`, `site` or `local` | Never; resolves once and never rejects |
| `signIn(options?)` | `{ user, token, expiresAt }` | `{ error }`: [sign-in codes](https://velven.ai/docs/sdk/sign-in#ask) |
| `onAuth(listener)` | A function that removes the listener | Never |
| `scores.submit(value, options?)` | `{ board, rank, value, total, improved }` | `{ error, retryAfter? }`: [score codes](https://velven.ai/docs/sdk/leaderboards#score-errors) |
| `scores.top(options?)` | `{ board, trust, info, rows }` | `{ error }`: [score codes](https://velven.ai/docs/sdk/leaderboards#score-errors) |
| `scores.around(options?)` | `{ board, trust, info, rows }` | `{ error }`: [score codes](https://velven.ai/docs/sdk/leaderboards#score-errors) |
| `scores.mine(options?)` | `{ board, trust, info, row }`, `row` null before a first score | `{ error }`: [score codes](https://velven.ai/docs/sdk/leaderboards#score-errors) |
| `scores.read(requests)` | An array: each read's own answer, in order ([Read several boards](https://velven.ai/docs/sdk/leaderboards#batch)) | Per read, never for the whole call; throws a `TypeError` for a mistake in any read |
| `data.getItem(key)` | A string, or null | Never; throws before `ready()` |
| `data.setItem(key, value)` | Nothing | Never; throws before `ready()`, a `RangeError` past 1 MB, a `TypeError` for `__proto__` |
| `data.removeItem(key)`, `data.clear()` | Nothing | Never; throws before `ready()` |
| `game.start()`, `game.stop()` | Nothing | Never |
| `onMute(handler)` | A function that removes the handler | Never; throws a `TypeError` when `handler` is not a function |
| `achievements.unlock(id)` | `{ achievement, title, unlocked, points, total }` | `{ error }`: [achievement codes](https://velven.ai/docs/sdk/achievements#achievement-errors) |
| `achievements.list()` | `{ achievements }` | `{ error }`: [achievement codes](https://velven.ai/docs/sdk/achievements#achievement-errors) |
| `stats.set(id, n)`, `stats.add(id, n)` | `{ stat, value, unlocked }` | `{ error }`: [stat codes](https://velven.ai/docs/sdk/achievements#achievement-errors) |
| `stats.get(id)` | `{ stat, value }` | `{ error }`: [stat codes](https://velven.ai/docs/sdk/achievements#achievement-errors) |
| `onAchievement(handler)` | A function that removes the handler | Never |
| `content.upload(item)` | `{ item }` | `{ error }`: [content codes](https://velven.ai/docs/sdk/content#content-errors) |
| `content.list(options?)` | `{ items, cursor }` | `{ error }`: [content codes](https://velven.ai/docs/sdk/content#content-errors) |
| `content.download(id)` | `{ item, data }` | `{ error }`: [content codes](https://velven.ai/docs/sdk/content#content-errors) |
| `content.remove(id)`, `content.report(id, reason)` | `{}` | `{ error }`: [content codes](https://velven.ai/docs/sdk/content#content-errors) |
| `rooms.create(options?)`, `rooms.join(id)` | `{ room }` | `{ error }`: [room codes](https://velven.ai/docs/sdk/rooms#rooms-errors) |
| `rooms.list()` | `{ rooms }` | `{ error }`: [room codes](https://velven.ai/docs/sdk/rooms#rooms-errors) |
| `rooms.leave()`, `kick`, `setData`, `send`, `chat` | `{}`, at once | `{ error }`: `not_in_room`, `not_host`, `too_large`, `too_long` |
| `rooms.invite(handle)`, `rooms.report(chat, reason)` | `{}` | `{ error }`: [room codes](https://velven.ai/docs/sdk/rooms#rooms-errors) |
| `rooms.inviteLink()` | A link that joins the room, or null | Never |
| `rooms.on(event, handler)` | A function that removes the handler | Never |
| `presence.set({ status })` | `{}` | `{ error }`: [presence codes](https://velven.ai/docs/sdk/rooms#presence) |

Every call added in 0.7.0 answers `unavailable` on a site, on a space not published with a verified owner, and on an older Velven page that does not know it yet. [Achievements and stats](https://velven.ai/docs/sdk/achievements), [Player content](https://velven.ai/docs/sdk/content) and [Rooms and presence](https://velven.ai/docs/sdk/rooms) have the guides.

### Properties

These properties are on the `Velven` object:

- `Velven.environment` (`velven`, `site`, `local` or null): Where the page is running; null before `ready()` settles.
- `Velven.user` (`{ id, handle, avatar }` or null): The signed-in player, for display.
- `Velven.version` (String): The SDK's release, such as `0.3.0`. Absent before 0.3.0.
- `Velven.space` (`{ id, slug }` or null): The space the page runs in, inside Velven; null elsewhere. `id` is the `space` claim of the player's token.
- `Velven.sandbox` (`true` or `false`): `true` in a [preview or `velven dev`](https://velven.ai/docs/sdk/local#sandbox), where everything goes to the space's sandbox. From 0.7.0.
- `Velven.rooms.current` (A room, or null): The room the player is in. From 0.7.0.

### Options

Set on the script tag, or passed to `createVelven` from the npm package:

- `data-redirect` (`redirect: true`): Sends a page opened directly to its Velven page. See [Send visitors to your Velven page](https://velven.ai/docs/sdk#redirect). From 0.6.0.
- `data-probe-timeout="3000"` (`probeTimeoutMs: 3000`): How long a frame waits for Velven before it settles on `site`, in milliseconds: 3 to 60 seconds, 7 by default. From 0.3.0.
- `data-toasts="false"` (`toasts: false`): Velven draws no toast for an unlock: your space draws its own from `onAchievement`. From 0.7.0.

### Server helpers

From `@velven/sdk/server`, for Node 20 and later, Deno, Bun and Cloudflare Workers:

- `verifyToken(token, { audience, space? })`: Checks a player's token. Answers `{ ok: true, user, space, expiresAt }`, or `{ ok: false, error }` with `invalid_token`, `expired_token`, `wrong_space` or `unavailable`.
- `postScore({ secret, token, value, requestId, board?, meta?, contentId? })`: Posts a score to a server board. Answers as [the scores endpoint](https://velven.ai/docs/sdk/server#server-answers) does. Gives up after 10 seconds.
- `unlockAchievement({ secret, token, achievement })`: Unlocks an achievement, trusted. Answers `{ ok: true, achievement, unlocked, total }`. See [Unlock achievements from your server](https://velven.ai/docs/sdk/server#server-achievements). From 0.7.0.
- `setStat({ secret, token, stat, value, mode? })`: Sets (`mode` `set`, the default) or adds to (`add`) a stat. Answers `{ ok: true, stat, value, unlocked }`. From 0.7.0.

`verifyToken` also answers `sandbox`: `true` for a token made in a preview or `velven dev`.

### Timing

Most calls answer in well under a second. These are the longest waits:

- `ready()` in a top-level window resolves at once.
- In a frame, `ready()` first waits for the Velven page for up to 7 seconds, or your `data-probe-timeout`.
- On a claimed space, `ready()` then waits up to 7 more seconds for the first silent sign-in check, and up to 7 more for the save.
- With the default wait, `ready()` never takes longer than 21 seconds, and usually under 1.
- The token lives an hour. The SDK asks for a new one when under a minute is left.
- The Velven page closes a card left open for 10 minutes and answers `signed_out`. A score call waits 30 seconds.
- A signed-in player's save is sent to Velven at most every 5 seconds.

### Limits

Past these limits a call is refused, never queued:

- 10 boards per space. A board key is 1 to 32 lowercase letters, digits, `-` or `_`.
- `meta`: up to 1 KB of JSON per submission.
- A save: up to 1 MB as JSON per player per space.
- Per space page: 40 submissions, 60 reads (`top`, `around` and `mine` together, each read of a `scores.read` counting as 1) and 12 sign-in requests a minute, each counted apart. Past any of them, the call answers `rate_limited` without a request; a `scores.read` that does not fit is refused whole.
- Per space page: 30 changes of `game.start()` and `game.stop()` a minute. The Velven page ignores more until the minute clears.
- Per account, across every space open: 60 submissions and 60 token requests a minute. Per address: 120 reads a minute, each read of a `scores.read` counting as 1.
- Per space page: 120 achievement and stat calls, 60 content calls and 60 room and presence calls a minute. Per account: 60 unlocks, 120 stat writes, 30 presence lines and 30 room passes a minute, 60 uploads and 30 rooms opened an hour.
- Content: 5 MB an item, 50 MB per player per space. Rooms: 2 to 16 players, 16 KB a message, 30 messages a second, 500 characters a chat line, 64 KB of room data. A presence line: 80 characters.

## Changelog

What each release of `@velven/sdk` and the script tag added. Releases only add: nothing a page already calls changes or goes away. [Versions](https://velven.ai/docs/sdk#versions) explains which release a page runs.

### 0.7.0

Achievements and stats, player content, rooms, presence, friends boards and the sandbox.

- `Velven.achievements` (`unlock`, `list`), `Velven.stats` (`set`, `add`, `get`) and `Velven.onAchievement`: [achievements and stats](https://velven.ai/docs/sdk/achievements) declared in the block, `velven.json` or the API.
- `Velven.content` (`upload`, `list`, `download`, `remove`, `report`): [player content](https://velven.ai/docs/sdk/content), and `contentId` on `scores.submit` to [keep a replay with a score](https://velven.ai/docs/sdk/leaderboards#score-content).
- `Velven.rooms`: [multiplayer rooms](https://velven.ai/docs/sdk/rooms) on Velven's relay, invites and invite links. `Velven.presence.set`: [a line friends see](https://velven.ai/docs/sdk/rooms#presence).
- `friends: true` on `top` and `around`: [friends-only boards](https://velven.ai/docs/sdk/leaderboards#friends). `display` on a board shows it on the space's page.
- `Velven.sandbox`, and [previews and `velven dev`](https://velven.ai/docs/sdk/local#sandbox) write to the space's sandbox. A page on `localhost` inside a frame looks for Velven before it settles `local`.
- `data-toasts="false"` and `toasts: false`: your space draws its own unlocks.
- `@velven/sdk/server` adds [`unlockAchievement` and `setStat`](https://velven.ai/docs/sdk/server#server-achievements), `contentId` on `postScore`, and `sandbox` from `verifyToken`.

### 0.6.0

Send visitors to your Velven page.

- `data-redirect` on the script tag, or `createVelven({ redirect: true })`, [sends a page opened directly to its Velven page](https://velven.ai/docs/sdk#redirect), where sign-in, boards and saves work. Nothing changes for a page without it.

### 0.5.1

Batch reads on an older Velven page.

- `Velven.scores.read` on a Velven page opened before batch reads existed sends each read on its own, instead of waiting 30 seconds and answering `failed`.
- The SDK tells the Velven page its version when it starts, so a problem can be traced to the release.

If you bundle 0.5.0 from npm, update to 0.5.1: the script tag already serves it.

### 0.5.0

Batch reads.

- `Velven.scores.read([...])` [reads up to 10 boards in one call](https://velven.ai/docs/sdk/leaderboards#batch), each answer as its own call's.

### 0.4.0

Gameplay and sound.

- `Velven.game.start()` and `Velven.game.stop()` [tell Velven when play is on](https://velven.ai/docs/sdk#gameplay).
- `Velven.onMute(handler)` puts a sound button in the bar under your space and tells your handler the player's choice.
- Locally, [`?velven_muted=1`](https://velven.ai/docs/sdk/local#local-params) calls every mute handler with `true`, and `start` and `stop` log to the console.

### 0.3.0

History reads, retry keys, cancelling a sign-in and a server helper.

- `top`, `around` and `mine` take `bucket` (`current`, `previous` or a date) and `season`, to [read past days, weeks and seasons](https://velven.ai/docs/sdk/leaderboards#history).
- Every read carries `info`: the board's label, unit, metric, sort, mode, entries, period, bucket and season.
- `top({ after })` pages in the bucket and season the row was read from, so a board paged across midnight stays on its day.
- `scores.submit` takes `requestId`, so [a retried run](https://velven.ai/docs/sdk/leaderboards#retries) is entered once.
- `signIn` takes an `AbortSignal` as `signal`, and a [cancelled sign-in request](https://velven.ai/docs/sdk/sign-in#cancel) answers `cancelled`.
- `Velven.version` names the release, and `Velven.space` the space the page runs in.
- `probeTimeoutMs` on `createVelven({ window, playerOrigins, probeTimeoutMs })` and `data-probe-timeout` on the script tag set how long a frame waits for Velven, 3 to 60 seconds.
- A `signed_out` answer to a score call clears the cached token and `Velven.user`.
- Locally, boards roll over by `period` and `season`, and [3 new query parameters](https://velven.ai/docs/sdk/local#local-params) arrive: `?velven_seed=`, `?velven_strict=1` and `?velven_slow=`.
- `@velven/sdk/server` adds [`verifyToken` and `postScore`](https://velven.ai/docs/sdk/reference#reference-server), for Node 20 and later, Deno, Bun and Cloudflare Workers.

### 0.2.0

Saves, and the player's own row.

- `Velven.data` [keeps a player's progress](https://velven.ai/docs/sdk/saves) with `getItem`, `setItem`, `removeItem` and `clear`, up to 1 MB per player and space.
- `scores.mine({ board })` answers the player's own row, or `null` before their first score.

### 0.1.0

The first release, as the script tag and the npm package `@velven/sdk`.

- `ready()`, `Velven.environment` and `Velven.user`.
- `signIn()`, plain or `{ silent: true }`, and `onAuth`.
- `scores.submit`, `scores.top` and `scores.around`.
- The local environment, with `?velven_user=`, `?velven_token=expired` and `?velven_local=1`.
