# Pelit platform

Documentation for AI coding assistants that build and publish browser games on Pelit (library
`pelit-v1`). The developer is usually a child; you make every technical decision yourself, following
this file and the prompt you were given for the game.

Addresses used below (your prompt has the real values):

- Game: `https://<slug>.<games-domain>/` (the live version players see)
- Preview of version N: `https://<slug>--v<N>.<games-domain>/`
- Admin API base: `https://<admin-host>/api/v1` (publishing, messages, error reports, news; needs the publish key)
- Game-host API: `/_api/...` and the library `/_pelit/pelit-v1.js`, always same-origin on the game host
- This file: `<docs-url>/llms.txt` (also served at `<docs-url>/`)

Every API error has the same JSON shape:

```json
{ "ok": false, "code": "SAVE_TOO_LARGE", "message": "...", "fix": "...", "docs": "<docs-url>#save_too_large" }
```

`message` says what went wrong and `fix` says what to change. The `docs` link points at an anchor in
this file: deploy errors are under "Publishing", game-host errors under "Runtime error codes".
Some errors also carry `retryAfter` (seconds, plus a `Retry-After` header).

## Front page and cover image

- Listed games appear on the platform front page at [https://peliarkku.com/](https://peliarkku.com/). The platform owner decides which games are listed.
- Optional cover image: put a file named exactly `kansikuva.png` (or `kansikuva.webp`, `kansikuva.jpg`, `kansikuva.jpeg`; lowercase) next to `index.html`.
- Use a 16:9 image, at least 640x360, under 300 kB, showing the game itself (text in the image is not required).
- The front page picks the cover automatically on the next publish.

## Start message and per-game prompt URL

- The developer gives you a short start message in chat. It has:
  - one per-game prompt URL: `<docs-url>/p/<token>`
  - one publish key line: `Julkaisuavain ...`
- Save the full prompt verbatim to `.pelit-ohjeet.md` with a shell command:
  - Windows: `curl.exe -o .pelit-ohjeet.md <promptUrl>` or `Invoke-WebRequest -Uri <promptUrl> -OutFile .pelit-ohjeet.md`
  - macOS/Linux/Git Bash: `curl -fsSL <promptUrl> -o .pelit-ohjeet.md`
- Do not use summarizing web tools to fetch the prompt. If you cannot run commands, open the prompt URL
  and read the full page.
- Treat the prompt URL and the publish key as secrets. Keep them out of commits, screenshots and chats.
- If the developer presses "Tee uusi avain", they get a new start message with a new key and a new
  prompt URL. The old URL stops working immediately.

## Hard rules

The platform enforces these. Uploads that break them are rejected, and the browser blocks the rest.

1. **Static files only.** The game is HTML, JS, CSS and assets. No Node server, PHP, Python,
   serverless functions, database or `.env`. Everything server-side goes through the platform library.
   Only the file types in "Allowed files" are accepted; a `.php` or `.sh` file fails the upload with
   `FILE_TYPE_NOT_ALLOWED`.
2. **No secrets in the game.** No API keys or tokens of any service (OpenAI, Anthropic, OpenRouter,
   Google, Firebase, GitHub, AWS, Resend, Slack, private keys, and the game's own publish key). Everything
   in the zip is public. The upload scanner rejects them with `SECRET_FOUND`. If the developer wants a
   feature that needs a paid service (an AI character, text-to-speech from a cloud API), explain kindly
   that it is not possible on this platform and suggest an offline alternative.
3. **Everything loads from the game's own address.** No CDNs, Google Fonts, external images, scripts,
   styles, analytics, ads or iframes. Download the library (an ES module build such as
   `lib/three.module.js`), the font (`fonts/game.woff2`) and the images into the project and use relative
   paths. `<script src="https://cdn...">` fails the upload with `EXTERNAL_RESOURCE`; a `fetch()` to another
   origin is blocked by the Content Security Policy at runtime.
4. **No inline scripts.** No `<script>` without `src`, no `onclick="..."` or other `on*=` attributes,
   no `javascript:` URLs, no `eval`, no `new Function`, no `setTimeout("code")`. Put code in `.js` files,
   load them with `<script type="module" src="main.js"></script>` and attach events with
   `addEventListener`. JSON data blocks (`<script type="application/json">`) are allowed.
5. **Show player text safely.** Nicknames, run titles, city names and anything else a player typed are
   shown with `textContent` (or drawn on a canvas), never with `innerHTML`, `insertAdjacentHTML` or
   `document.write`. Never build HTML strings from player text.
6. **Use the platform for saves and scores.** Never build your own login or accounts, never ask players
   for email addresses, passwords or real names, never send emails. Do not keep game progress in
   `localStorage` yourself: `pelit.save()` already writes to the browser and to the server. (Game settings
   that are not progress, such as volume, may use `localStorage` or a named save slot.)
7. **No chat, messaging or public player-written content** between players. Leaderboards show only
   nicknames and numbers.
8. **Keep it small.** At most 50 MB per upload and 2000 files; the game's kept versions share a file
   quota (150 MB by default). Use webp/avif images, ogg/m4a audio, and leave source assets and
   `node_modules` out of the zip.
9. **Desktop browser first.** Keyboard and mouse. Mobile is not required.

## Allowed files

- Allowed extensions (case-insensitive): `html css js mjs json map txt png jpg jpeg gif webp avif svg
  ico mp3 ogg wav m4a webm mp4 glb gltf bin wasm woff woff2 ttf otf`.
- Everything else is rejected, including files without an extension and dotfiles (`.pelit-key`,
  `.pelit-ohjeet.md`, `.gitignore`, `.env`), `.md` files (`README.md`, `pelit-asetukset.md`), `.ps1`, `.ts`
  and server code.
  Leave them out of the zip; do not rename them.
- `index.html` (exactly that name, lowercase) must be at the root of the zip. If every file is inside one
  single top-level folder, that folder is removed automatically; two levels of folders are not.
- At most **50 MB** per zip and **2000 files**. `__MACOSX/`, `.DS_Store`, `Thumbs.db` and `desktop.ini`
  are ignored and do not count.
- The unzipped size must fit the game's file quota (default 150 MB, counting all kept versions; at most
  160 MB per upload). The platform keeps the 3 newest versions by default. All games of one developer
  share a 400 MB limit by default.
- Paths: relative, no `..`, no absolute paths or drive letters, no symlinks, no control characters, at
  most 1024 bytes. Paths are compared case-insensitively, so `Logo.png` and `logo.png` in one zip are
  duplicates.
- Game files are served with a strict type per extension, as-is. There are no server-side redirects or
  rewrites: link to real file names.

## Publishing

Publish after every working change, without asking the developer. Before the very first publish only,
ask the questions in "Before the first publish" (in your prompt) first. One request:

```
POST <api-base>/games/<slug>/deploys
Authorization: Bearer <contents of .pelit-key>
Content-Type: application/zip

<the zip file as the raw body>
```

Windows PowerShell (5.1 and 7) publish script `julkaise.ps1` for the project root. It zips the folder
the script is in, only the allowed file types, keeps folder paths, and skips every path with a part
starting with `.` (`.pelit-key`, `.git`, `.vscode`, `.cursor`, ...) as well as `pelit-asetukset.md`
and `node_modules`. Put your real API base and slug in `$url`:

```powershell
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$url = '<api-base>/games/<slug>/deploys'
$root = $PSScriptRoot
$key = (Get-Content -Raw (Join-Path $root '.pelit-key')).Trim()
$allowed = 'html css js mjs json map txt png jpg jpeg gif webp avif svg ico mp3 ogg wav m4a webm mp4 glb gltf bin wasm woff woff2 ttf otf'.Split(' ')
$zipPath = Join-Path ([System.IO.Path]::GetTempPath()) 'pelit-upload.zip'
if (Test-Path $zipPath) { Remove-Item $zipPath }
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::Open($zipPath, 'Create')
try {
  Get-ChildItem -LiteralPath $root -Recurse -File -Force | ForEach-Object {
    $rel = $_.FullName.Substring($root.Length).TrimStart('\', '/').Replace('\', '/')
    $parts = $rel.Split('/')
    $skip = ($parts | Where-Object { $_.StartsWith('.') -or $_ -eq 'node_modules' }) -or $rel -eq 'pelit-asetukset.md'
    if (-not $skip -and $allowed -contains $_.Extension.TrimStart('.').ToLower()) {
      [void][System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $_.FullName, $rel)
    }
  }
} finally { $zip.Dispose() }
try {
  $res = Invoke-RestMethod -Method Post -Uri $url -Headers @{ Authorization = "Bearer $key" } -ContentType 'application/zip' -InFile $zipPath
  $res | ConvertTo-Json -Depth 10
} catch {
  if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $_.ErrorDetails.Message; exit 1 }
  throw
}
```

The same with curl (macOS, Linux, Git Bash), from a folder that holds only the game files:

```sh
rm -f ../game.zip
zip -r ../game.zip . -x '.*' '*/.*' '*.md' 'node_modules/*'
curl -sS -X POST "<api-base>/games/<slug>/deploys" \
  -H "Authorization: Bearer $(cat .pelit-key)" -H "Content-Type: application/zip" \
  --data-binary @../game.zip
```

Successful response (read all of it every time):

```json
{
  "ok": true,
  "version": 13,
  "url": "https://<slug>.<games-domain>/",
  "previewUrl": "https://<slug>--v13.<games-domain>/",
  "messages": { "unread": 0, "pendingRequired": 0, "read": "GET https://<admin-host>/api/v1/games/<slug>/messages" },
  "warnings": ["sounds.js references external URL(s) (<external url>); ..."],
  "newErrors": 0,
  "news": [{ "id": 1, "title": "...", "text": "...", "docs": "<link to more docs>", "publishedAt": "2026-11-05T12:00:00.000Z" }]
}
```

- `version` goes live at `url` immediately. `previewUrl` keeps showing exactly this version while it is
  kept, so it is the link for "still being tested".
- `warnings`: problems that did not block the upload, mostly external URLs in JS/CSS/JSON files (they
  will fail at runtime because of the Content Security Policy). Fix them.
- `messages` and `newErrors`: admin messages and new error reports. For now always `0`.
- `news`: platform news this game has not marked as seen (often `[]`). See "Platform news".
- Preview saves are per preview version host: each `<slug>--v<N>` host has its own player cookie and its
  own browser storage, so saves made on `--v3` are not seen on `--v4`, and preview saves never touch live
  saves. The preview space is cleared whenever an
  old version leaves the kept set.
- After publishing, open the live URL and check that the game starts without console errors.

Checks run in this order, and the first failure is returned: publish key; developer account and game
status; `Content-Type`; server disk space; one publish at a time; zip size; the upload inspection (file
count, zip format, paths, unzipped size, `index.html`, file types, secrets, inline scripts, external
resources); quotas.

### Deploy error codes

<a id="deploy-errors-key_invalid"></a>
#### KEY_INVALID (401)
The `Authorization: Bearer <key>` header is missing, the key is wrong or revoked, or it belongs to
another game. Fix: read the key from `.pelit-key` (trim whitespace and newlines) and use the slug from
your prompt. If the key was renewed, ask the developer to paste you the new prompt. This code is also
returned by the messages, errors and news endpoints.

<a id="deploy-errors-developer_locked"></a>
#### DEVELOPER_LOCKED (403)
The developer account is locked, so publishing is blocked. Nothing to fix in the game: tell the
developer simply that the grown-up admin needs to help.

<a id="deploy-errors-game_closed"></a>
#### GAME_CLOSED (403)
The game is closed and cannot be published. Only the platform owner can reopen it.

<a id="deploy-errors-zip_required"></a>
#### ZIP_REQUIRED (415)
The request did not have `Content-Type: application/zip`. Send the zip as the raw body (not as a form
upload or base64) with that header.

<a id="deploy-errors-server_disk_low"></a>
#### SERVER_DISK_LOW (507)
The server is low on disk space, so publishing is paused. Nothing to fix; the admin has been told.
Try again later.

<a id="deploy-errors-server_busy"></a>
#### SERVER_BUSY (503)
Another publish of this game is still running, or the server is checking too many uploads. The body
has `retryAfter: 30`. Wait 30 seconds and publish again. Send one publish at a time.

<a id="deploy-errors-client_gone"></a>
#### CLIENT_GONE (400)
The connection closed before the upload was checked, so nothing was published. Publish again and wait
for the response.

<a id="deploy-errors-too_large"></a>
#### TOO_LARGE (413)
The zip is over 50 MB, or the unzipped files are over the game's limit. Compress images (webp/avif)
and audio (ogg/m4a), remove unused assets, source files and `node_modules`.

<a id="deploy-errors-too_many_files"></a>
#### TOO_MANY_FILES (413)
More than 2000 files. Bundle scripts, merge small images into sprite sheets, and leave source assets
out of the zip.

<a id="deploy-errors-zip_invalid"></a>
#### ZIP_INVALID (400)
The body is not a readable zip, or it is encrypted. Create a standard unencrypted `.zip` (deflate or
stored), for example with the script above, `Compress-Archive` or `zip -r`.

<a id="deploy-errors-unsafe_path"></a>
#### UNSAFE_PATH (400)
A zip entry is a symlink or special file, uses `..`, an absolute path or drive letter, contains control
characters, is a duplicate (case-insensitive), or is both a file and a folder. Re-create the zip from
the game folder itself.

<a id="deploy-errors-index_missing"></a>
#### INDEX_MISSING (400)
No `index.html` at the root. The message lists the top-level entries. Zip the contents of the game
folder (one wrapping folder is removed automatically, but only one), and name the file exactly
`index.html` in lowercase.

<a id="deploy-errors-file_type_not_allowed"></a>
#### FILE_TYPE_NOT_ALLOWED (400)
The message lists files whose extension is not in "Allowed files" (files without an extension count
too). Remove them from the zip, or convert them to an allowed type (for example `.md` notes stay out,
a `.ts` source is shipped as its built `.js`).

<a id="deploy-errors-secret_found"></a>
#### SECRET_FOUND (400)
A key, token, private key or the publish key itself was found in the listed files (the value is not
shown). Remove it, and treat it as leaked: if it is the publish key, tell the developer to press
"Tee uusi avain" and paste you the new prompt. Never embed keys; use the platform API for saves and
scores.

<a id="deploy-errors-html_too_complex"></a>
#### HTML_TOO_COMPLEX (400)
An HTML or SVG file nests elements more than 512 levels deep, has more than 20000 elements, or a tag
with more than 256 attributes. Simplify it; build big structures from JavaScript or draw on a canvas.

<a id="deploy-errors-inline_script"></a>
#### INLINE_SCRIPT (400)
An HTML or SVG file has an inline `<script>`, an `on*=` event attribute, or (SVG) a DOCTYPE with
entity declarations. Move the code into a `.js` file loaded with
`<script type="module" src="main.js"></script>` and use `addEventListener`. JSON data blocks
(`type="application/json"`) are allowed.

<a id="deploy-errors-external_resource"></a>
#### EXTERNAL_RESOURCE (400)
An HTML or SVG file loads a script, stylesheet or media from `http://`, `https://` or `//`. Download
the file into the project and use a relative path, such as `<script src="lib/phaser.min.js"></script>`.

<a id="deploy-errors-inspection_failed"></a>
#### INSPECTION_FAILED (400)
Checking the upload crashed (usually a pathological file). Make the files smaller or simpler and
publish again.

<a id="deploy-errors-inspection_timeout"></a>
#### INSPECTION_TIMEOUT (400)
Checking the upload took longer than 15 seconds. Make the files smaller or simpler (split huge HTML or
SVG files) and publish again.

<a id="deploy-errors-quota_exceeded"></a>
#### QUOTA_EXCEEDED (413)
This version plus the other kept versions would exceed the game's file quota. Make the game smaller.
If it really needs more room, message the admin.

<a id="deploy-errors-developer_quota_exceeded"></a>
#### DEVELOPER_QUOTA_EXCEEDED (413)
All games of this developer together would exceed the developer's limit. Make this game smaller, or
tell the developer that the admin can raise the limit or that their other games take the space.

<a id="deploy-errors-game_moved"></a>
#### GAME_MOVED (409)
The game changed owner while publishing. Publish again.

## Saves and game runs

Players never log in. This is how saving works:

- **Anonymous player.** The first write from a browser (`save()`, `submitScore()` or `setNickname()`)
  creates an anonymous player on the server and sets the cookie `__Host-pelit_s` (HttpOnly, Secure,
  SameSite=Lax, host-only, 400 days, renewed when the player comes back). JavaScript cannot see it and
  does not need to. Opening the game or reading does not create a player. Every game, and every preview
  host, has its own cookie and its own player, so games never see each other's players.
- **Same browser only, for now.** Saves follow the cookie. Another computer or browser, a private
  window, or clearing site data means a new, empty player; the old server saves are unreachable. The
  menu says "Pelit tallentuvat tähän selaimeen." If the developer asks about other computers, say it is
  coming later (see "Coming later").
- **Game runs.** A run is a save slot named `run-<random id>` with a `title` ("Peli 1", "Peli 2", ...)
  and an optional `summary` ("Vuosi 12, 340 asukasta"). A player can have several runs and switch
  between them. The library remembers the current run in the browser. `pelit.menu()` shows the
  platform's own Finnish menu (continue, new game, load, rename, delete, nickname) and tells the game
  which run to play. Games may also use their own named slots (for example `settings`); the menu only
  lists `run-` slots.
- **Local mirror and offline queue.** `save()` first writes the save to `localStorage`
  (`pelit:<slug>:<live|preview>:<slot>`, synchronously, before it returns its promise) and then sends it
  to the server. If the network is down, the server has a hiccup (5xx) or the player is rate limited
  (429), `save()` resolves `{ synced: false }` and the save stays queued. The queue is retried oldest
  first by `init()`, after every successful save, after `load()`, `listRuns()` and `submitScore()`, and on
  the browser's `online` event. Do not show an error for `{ synced: false }`. If `localStorage` is
  unavailable (privacy mode, full), `save()` still saves to the server.
- **Loading.** `load()` compares the local and the server copy by `savedAt` and returns the newer one
  (a tie goes to the server). Offline, it returns the local copy.
- **Limits.** 10 slots per player (live and preview counted separately), 2 MB per save (UTF-8 bytes of
  the JSON, so `ä` is 2 bytes), and a save quota per game shared by all players (100 MB by default).
  `title` is 1–40 characters, `summary` 0–80, without control characters (`save()` cuts longer values
  and drops control characters before sending). At most 60 API requests per minute per player, and at
  most 20 MB of new save data per network (IPv6: per /64) per day. Errors: `TOO_MANY_SLOTS`, `SAVE_TOO_LARGE`, `QUOTA_EXCEEDED`, `SAVE_META_INVALID`,
  `RATE_LIMITED`.
- **When to save.** After important moments, at least every 30 seconds while playing, and when the page
  is hidden (`visibilitychange`). Because the local copy is written synchronously, a save in
  `visibilitychange` survives closing the tab; it is uploaded on the next visit.
- **Deleting.** `pelit.deleteSave(run)` deletes one run (the menu's "Poista" does the same).
  `pelit.forgetMe()` deletes this browser's player and everything it saved, on the server and in the
  browser (saves, scores, nickname). The menu already has it ("Unohda minut", with a confirmation, then
  the page reloads). If the game has its own settings screen, you may offer it there too, with a
  confirmation; afterwards stop autosaving and call `location.reload()` (see `pelit.forgetMe()`).

### Save versioning

Every save has a `version` (a non-negative integer you choose). Keep `const SAVE_VERSION = <n>` in the
code. Whenever the shape of the saved data changes, increase it and add an upgrade step, so a player's
old save keeps working after every publish:

```js
const SAVE_VERSION = 3

// v1: { gold }            v2: { gold, houses: [] }            v3: { coins, houses: [{ x, y }] }
function upgradeSave(save) {
  let { data, version } = save
  if (version < 2) { data = { ...data, houses: [] }; version = 2 }
  if (version < 3) {
    data = { coins: data.gold, houses: data.houses.map((h, i) => ({ x: i, y: 0 })) }
    version = 3
  }
  return data // now in the SAVE_VERSION shape
}

const loaded = await pelit.load(run)
const state = loaded ? upgradeSave(loaded) : newGame()
```

Never delete a player's save because it is old. Before publishing a change to the save shape, load a
save made by the previous version and check that it still works.

### Minimal complete example

`index.html`:

```html
<!doctype html>
<html lang="fi">
<head>
  <meta charset="utf-8">
  <title>Kylä</title>
  <script src="/_pelit/pelit-v1.js"></script>
  <script type="module" src="main.js"></script>
</head>
<body>
  <h1>Kylä</h1>
  <p id="info"></p>
  <button id="build" type="button">Rakenna talo</button>
  <button id="games" type="button">Pelit</button>
  <p id="status"></p>
</body>
</html>
```

`main.js`:

```js
const SAVE_VERSION = 2

const info = document.getElementById('info')
const status = document.getElementById('status')
let run = null // current game run id
let state = null // the game state that is saved

const newGame = () => ({ year: 1, people: 10 })

function upgradeSave(save) {
  let { data, version } = save
  if (version < 2) { data = { year: data.year, people: data.population }; version = 2 } // v1 called it population
  return data
}

const summary = () => 'Vuosi ' + state.year + ', ' + state.people + ' asukasta'

function draw() {
  info.textContent = summary() // player-visible text always via textContent
}

function play(runId, save) {
  run = runId
  state = save ? upgradeSave(save) : newGame()
  status.textContent = ''
  draw()
}

async function saveNow() {
  if (!run) return
  try {
    await pelit.save(run, state, { version: SAVE_VERSION, summary: summary() }) // { synced: false } is fine too
  } catch (e) {
    if (e.code === 'FORGOTTEN') return // the player chose "Unohda minut"; the page reloads
    status.textContent = 'Tallennus ei onnistunut, mutta peli jatkuu.'
  }
}

async function openMenu() {
  try {
    const choice = await pelit.menu() // { action: 'new' | 'load', run, title, save }
    play(choice.run, choice.save)
  } catch (e) {
    if (e.code !== 'CANCELLED') status.textContent = 'Valikko ei auennut.'
    // Closed without choosing: keep playing the current run, unless there is none yet or the
    // player deleted it in the menu (it must not come back as "Nimetön peli" on the next save).
    const runs = await pelit.listRuns().catch(() => [])
    if (!run || !runs.some((r) => r.run === run)) {
      const last = await pelit.continueOrNew().catch(() => null)
      if (last) play(last.run, last.save)
      else play((await pelit.newRun()).run, null)
    }
  }
}

async function start() {
  try {
    await pelit.init()
  } catch (e) {
    status.textContent = 'Yhteys peliin ei toimi. Lataa sivu uudelleen.'
    return
  }
  await openMenu()
  setInterval(saveNow, 30000)
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') saveNow()
  })
  document.getElementById('build').addEventListener('click', () => {
    state.people += 5
    state.year += 1
    draw()
    saveNow() // an important moment
  })
  document.getElementById('games').addEventListener('click', async () => {
    await saveNow()
    await openMenu()
  })
}

start()
```

A short game without progress skips the menu: call `pelit.init()` and use only scores.

### Starter template

There is no template to download: create these files in the project root yourself.

- `index.html`: as in the example above (library script first, then `<script type="module" src="main.js"></script>`).
- `main.js`: the game, starting from the example above (`init`, `menu`, autosave, `summary`).
- `julkaise.ps1`: the publish script from "Publishing"; run it with
  `powershell -ExecutionPolicy Bypass -File julkaise.ps1`.
- `.gitignore`: contains the line `.pelit-key` (the key file itself is created from your prompt).
- `pelit-asetukset.md`: the developer's answers to the first-publish questions. It stays out of the
  zip (the publish script skips it).

There is no julkaise.mjs; Node is not needed, always use julkaise.ps1.

## Library reference

Load the library once in `index.html`, before your own scripts:
`<script src="/_pelit/pelit-v1.js"></script>`. It defines two globals, `pelit` and `PelitError`. Call
`await pelit.init()` before anything else. Every function returns a promise; failures reject with a
`PelitError`:

```js
try {
  await pelit.save(run, state, { version: SAVE_VERSION })
} catch (e) {
  if (e instanceof PelitError) console.warn(e.code, e.message, e.fix) // also e.status, e.retryAfter
}
```

`e.code` is the server code (see "Runtime error codes") or one of the library's own codes:
`NETWORK` (the request could not be sent), `BAD_RESPONSE` (the server answered something that is not
JSON), `HTTP_<status>` (an error without a JSON body), `NOT_INITIALIZED` (`init()` has not finished),
`CANCELLED` (the player closed a platform dialog), `FORGOTTEN` (writes after `forgetMe()`, until the
page reloads; see `pelit.forgetMe()`), plus `SLOT_INVALID`, `VERSION_REQUIRED`, `SAVE_META_INVALID`,
`SAVE_NOT_FOUND` and `FEATURE_DISABLED` thrown before any request in the cases listed below. A request
that gets no answer in 15 seconds is aborted and fails with `NETWORK` (`save()` then queues the save), so
a hung connection never blocks later writes.

Writes to the server (`save`, `deleteSave`, `renameRun`, `setNickname`, `submitScore`, `forgetMe`) run one
at a time in call order, so two first writes never create two players.

### pelit.init()
Loads the game info and the current player. Returns
`{ player: { nickname: string | null, kind: 'anon' | 'email' } | null, game: { name, slug }, features: { emailLogin: boolean } }`.
`player` is `null` until this browser's first write. `features.emailLogin` is `false` for now. Starts
uploading queued saves. When the server cannot be reached (offline, 429 or 5xx), `init()` still resolves
if this browser has opened the game before: with the game info cached in the browser, `player: null` and
`features: { emailLogin: false }`, so the game starts offline with its local saves (they upload later).
Only without a cached copy does it reject (for example `NETWORK`). Saves, runs and the menu need a
finished `init()` (`NOT_INITIALIZED` otherwise).

### pelit.menu(opts?)
Shows the platform's run menu over the game and resolves when the player chooses:

- `{ action: 'load', run, title, save }`: continue or load an existing run. `save` is the loaded save
  `{ data, version, savedAt, title, summary }`, already set as the current run.
- `{ action: 'new', run, title, save: null }`: a new run from `newRun()` ("Peli N"). Nothing is saved
  until your first `save(run, ...)`.

Rejects with `CANCELLED` when the player presses "Sulje" or Escape (then keep the current run, or call
`continueOrNew()`). `opts.allowNew: false` hides the "Uusi peli" button. Calling `menu()` again while it
is open returns the same promise. The Finnish UI: the heading is "Jatka" (or "Pelit" with no runs); a
big "Jatka: Peli 2 · Vuosi 12 · tallennettu 5 min sitten" button for the current (else newest) run; a
list of all runs with title (or "Nimetön peli"), summary and date, each with "Nimeä uudelleen" and
"Poista" (asks "Poistetaanko ...? Tätä ei voi perua."); the note "Pelit tallentuvat tähän selaimeen.";
buttons "Sulje" and "Uusi peli" ("Uusi peli" is disabled with "Tallennuspaikat ovat täynnä. Poista vanha
peli ensin." when the player has 10 runs); the player's nickname with "Vaihda", or "Ei nimimerkkiä."
with "Valitse nimimerkki"; and "Unohda minut", which asks for confirmation inside the menu, then calls
`forgetMe()` and reloads the page (the menu promise never settles then). Errors inside the menu are
shown in Finnish in the menu itself.

### pelit.continueOrNew()
Returns `{ run, save }` for the current run, or, if that has no save, for the newest run. Returns `null`
when the browser has no runs: then call `newRun()` and start a new game. It never creates a run itself.

### pelit.newRun(title?)
Creates a run id (`run-` + 12 random characters) and makes it the current run. The title is `title`
(trimmed; it must then be 1–40 characters without control characters, else `SAVE_META_INVALID`) or, when
`title` is omitted, "Peli N", one higher than the highest "Peli N" title in this browser's runs. Returns `{ run, title }`. Nothing is saved yet; the title is used
by the first `save()` of that run.

### pelit.listRuns()
Returns `[{ run, title, summary, savedAt, synced }]`, newest first: the local and the server runs merged
(the newer copy of each wins). Offline it returns the local runs. Only `run-` slots are listed.

### pelit.save(slot, data, { version, title?, summary? })
Saves `data` (any JSON value) to `slot` (a run id or your own name: 1–32 characters `a-z A-Z 0-9 _ -`).
`version` is required (non-negative integer, else `VERSION_REQUIRED`); a slot that is empty or starts
with `@` gives `SLOT_INVALID`. `title` and `summary` are optional: when omitted, the previous values are
kept; `summary: ''` clears the summary. Control characters are dropped and a longer `title` is cut to 40,
a longer `summary` to 80 characters. Saving a `run-` slot makes it the current run. Resolves `{ synced: true }`, or `{ synced: false }` when the
upload failed for a reason that may heal (offline, 5xx, 429) and was queued. Rejects for errors that
retrying will not fix (`SAVE_TOO_LARGE`, `TOO_MANY_SLOTS`, `QUOTA_EXCEEDED`, `SAVE_META_INVALID`,
`DATA_INVALID`, `SLOT_INVALID`, ...); such a save stays in the browser but is not uploaded again until
the page reloads.

### pelit.load(slot)
Returns `{ data, version, savedAt, title, summary }` (the newer of the local and server copy) or `null`.
Offline it returns the local copy, and rejects only when there is no local copy either.

### pelit.renameRun(run, title)
Changes a run's title (1–40 characters, no control characters, else `SAVE_META_INVALID`; `SAVE_NOT_FOUND`
if the run has no save). Re-saves the run with the same data, version and summary; does not change the
current run. Resolves like `save()`.

### pelit.deleteSave(slot)
Deletes the slot in the browser and on the server (a slot that does not exist on the server is fine). If
it was the current run, there is no current run afterwards. A browser that never saved to the server
has nothing there, so it simply resolves after deleting the local copy.

### pelit.listSaves()
Server slots only: `[{ slot, version, bytes, title, summary, savedAt }]`, newest first, including
non-run slots. `[]` before the first write.

### pelit.player()
Reloads the player from the server: `{ nickname, kind }` or `null`.

### pelit.setNickname(name)
Sets the player's nickname (2–20 characters, no control characters, unique in the game, case-insensitive).
Creates the anonymous player if needed. Returns `{ nickname, kind }`. Errors: `NICKNAME_INVALID`,
`NICKNAME_TAKEN`, `RATE_LIMITED`.

### pelit.submitScore(board, score, { meta }?)
See "Scores and nicknames". Returns the player's best score on that board.

### pelit.topScores(board, { limit }?)
Returns `[{ nickname, score }]`, best first. `limit` defaults to 10 and is clamped to 1–100. Anyone can
read; no player needed.

### pelit.forgetMe()
Deletes this browser's player on the server (saves, scores, nickname, session) and every local
`pelit:<slug>:...` entry, then forgets the player. Rejects with `NOT_LOGGED_IN` when the browser never
wrote to the server (the local data is deleted anyway). `pelit.deleteAccount()` is an alias.
After `forgetMe()`, `save()`, `renameRun()`, `setNickname()`, `submitScore()` and `newRun()` reject with
`FORGOTTEN` ("Tiedot poistettiin. Lataa sivu uudelleen aloittaaksesi alusta.") until the page reloads;
`init()` does not reset this. So stop autosaving and reload, as the menu's "Unohda minut" does:

```js
async function forgetEverything() {
  clearInterval(autosaveTimer) // the id from setInterval(saveNow, ...)
  try {
    await pelit.forgetMe()
  } catch (e) {
    if (e.code !== 'NOT_LOGGED_IN') return // for example offline: try again later
  }
  location.reload()
}
```

### pelit.login(), pelit.logout(), pelit.requestCode(email), pelit.verifyCode(email, code, opts?)
Email login. It is not available yet: while `features.emailLogin` is `false`, all four reject with
`FEATURE_DISABLED` without contacting the server (and the server answers `FEATURE_DISABLED` on its
`/_api/v1/auth/*` endpoints). Do not call them. Platform news will announce email accounts.

## Scores and nicknames

- `await pelit.submitScore('highscore', 1234)` keeps each player's **best** score per board (higher is
  better; send negative numbers if lower should win). `score` is any finite number; `meta` is optional
  JSON up to 1 KB, kept with the best score. Returns the best score.
- Board names: 1–32 characters `a-z 0-9 _ -` (lowercase). At most 5 boards per game; live and preview
  have separate boards.
- Scores need a nickname. If the player has none, `submitScore()` opens the platform's Finnish nickname
  dialog ("Valitse nimimerkki", with the hint not to use one's real name) and retries once. If the player
  closes it, `submitScore()` rejects with `NICKNAME_REQUIRED`: keep the game running and offer the
  leaderboard later. You can also ask earlier with `pelit.setNickname(name)` in your own UI, or let the
  player choose from the menu.
- `await pelit.topScores('highscore', { limit: 10 })` returns `[{ nickname, score }]`. Show nicknames
  with `textContent`.

## Runtime error codes

Errors the game host (`/_api/...`) can return. The library turns them into `PelitError` with the same
`code`. Catch them, show a short friendly message in the game's language, and keep the game running.

<a id="not_logged_in"></a>
### NOT_LOGGED_IN (401)
No player exists for this browser on this host yet (only `DELETE` endpoints need one). Nothing to delete;
ignore it. `deleteSave()` already treats it as success; `forgetMe()` rejects with it.

<a id="rate_limited"></a>
### RATE_LIMITED (429)
Too many requests (60 per minute per player), too many new anonymous players from one network or for
the game today, or more than 20 MB of new save data from one network today. `retryAfter` says how many seconds to wait. `save()` queues the save instead of
rejecting. Do not save in a tight loop; every 30 seconds plus important moments is enough.

<a id="feature_disabled"></a>
### FEATURE_DISABLED (403)
Email login is not enabled for this game. Do not use `login()`, `requestCode()`, `verifyCode()` or
`logout()`; saves work without them.

<a id="slot_invalid"></a>
### SLOT_INVALID (400)
The slot name is not 1–32 characters from `a-z A-Z 0-9 _ -`. Use run ids from the library or simple
names like `settings`.

<a id="save_not_found"></a>
### SAVE_NOT_FOUND (404)
No save in this slot. `load()` returns `null` instead of rejecting.

<a id="version_required"></a>
### VERSION_REQUIRED (400)
`version` is missing or not a non-negative integer. Pass `{ version: SAVE_VERSION }`.

<a id="data_required"></a>
### DATA_REQUIRED (400)
The save has no `data`. Pass the game state as the second argument of `save()` (not `undefined`).

<a id="data_invalid"></a>
### DATA_INVALID (400)
The save data has a NUL character (`\u0000`) or invalid Unicode (a lone surrogate) in a string or key.
Clean those strings before saving.

<a id="save_too_large"></a>
### SAVE_TOO_LARGE (413)
The save is over 2 MB of UTF-8 JSON. Save less: drop caches, history and anything you can recompute.

<a id="save_meta_invalid"></a>
### SAVE_META_INVALID (400)
`title` must be 1–40 characters and `summary` 0–80, without control characters. Shorten them.

<a id="too_many_slots"></a>
### TOO_MANY_SLOTS (409)
The player already has 10 slots. Reuse a slot, or let the player delete an old run in `pelit.menu()`
(its "Uusi peli" button is disabled at 10 runs).

<a id="quota_exceeded"></a>
### QUOTA_EXCEEDED (413)
The game's total save storage (all players, live and preview) is full. Write smaller saves and tell the
admin.

<a id="board_invalid"></a>
### BOARD_INVALID (400)
The board name is not 1–32 characters from `a-z 0-9 _ -` (lowercase).

<a id="score_invalid"></a>
### SCORE_INVALID (400)
`score` is not a finite number.

<a id="meta_too_large"></a>
### META_TOO_LARGE (400)
Score `meta` is over 1 KB of JSON. Send less or drop it.

<a id="meta_invalid"></a>
### META_INVALID (400)
Score `meta` has a NUL character or invalid Unicode in a string or key.

<a id="too_many_boards"></a>
### TOO_MANY_BOARDS (409)
The game already has 5 boards. Reuse an existing board name.

<a id="nickname_required"></a>
### NICKNAME_REQUIRED (409)
The player needs a nickname before submitting a score (or closed the nickname dialog). Ask again later or
call `pelit.setNickname()`.

<a id="nickname_invalid"></a>
### NICKNAME_INVALID (400)
The nickname must be 2–20 characters without control characters, and must not contain a swear word
(a coarse built-in Finnish and English list). Ask for another one.

<a id="nickname_taken"></a>
### NICKNAME_TAKEN (409)
Another player of this game has that nickname (case-insensitive). Ask for another one.

<a id="origin_mismatch"></a>
### ORIGIN_MISMATCH (403)
A write came from another origin. Call the API only from the game's own page (the library does this).

<a id="json_required"></a>
### JSON_REQUIRED (415)
A POST/PUT/PATCH without `Content-Type: application/json`. Use the library instead of calling the API
directly.

<a id="game_not_found"></a>
### GAME_NOT_FOUND (404)
No game is served on this host. Use the game URL from your prompt.

<a id="game_closed"></a>
### GAME_CLOSED (503)
The game has been closed by the platform owner and cannot be used until reopened.

<a id="not_found"></a>
### NOT_FOUND (404)
No such route. Check the URL and method (also returned for unknown admin API paths).

<a id="bad_request"></a>
### BAD_REQUEST (400)
The request was malformed (for example invalid JSON). Use the library functions.

<a id="internal"></a>
### INTERNAL (500)
A server error. Retry later; the library queues saves automatically. If it persists, message the admin.

### Email login codes (only when email login is enabled)

These belong to the email login, which is off for now (`FEATURE_DISABLED`). They are listed so every
code has documentation.

<a id="email_invalid"></a>
#### EMAIL_INVALID (400)
The email address is not valid.

<a id="registration_closed"></a>
#### REGISTRATION_CLOSED (403)
The game does not accept new email players.

<a id="not_invited"></a>
#### NOT_INVITED (403)
The game is invite-only and the address is not invited.

<a id="player_blocked"></a>
#### PLAYER_BLOCKED (403)
This player has been blocked from the game.

<a id="game_full"></a>
#### GAME_FULL (403)
The game has its maximum number of email players.

<a id="mail_failed"></a>
#### MAIL_FAILED (502)
The login code email could not be sent. Try again later.

<a id="code_expired"></a>
#### CODE_EXPIRED (400)
The login code expired (10 minutes) or was used up (5 attempts). Request a new one.

<a id="code_invalid"></a>
#### CODE_INVALID (400)
The login code is wrong or not 6 digits.

<a id="age_confirmation_required"></a>
#### AGE_CONFIRMATION_REQUIRED (400)
A new email player must confirm being 13+ or having a guardian's permission.

## Owner panel sign-in (platform owner only)

These come from the owner's admin panel on the admin host, never from a game. A game's code cannot
cause or fix them. They are listed so every code has documentation.

<a id="login_link_invalid"></a>
#### LOGIN_LINK_INVALID (401)
The one-time owner login link is wrong, expired (10 minutes), already used, or had 5 wrong codes.
The owner creates a new link with the admin CLI.

<a id="totp_invalid"></a>
#### TOTP_INVALID (401)
The authenticator code was wrong, too old, or already used. Enter the current 6-digit code.

<a id="csrf_invalid"></a>
#### CSRF_INVALID (403)
A panel form was sent without its CSRF token. Reload the panel page and submit again.

## Platform administration (platform owner only)

These come from the owner's admin panel or CLI on the admin host, never from a game. A game's code cannot
cause or fix them. They are listed so every code has documentation.

<a id="bad_arguments"></a>
#### BAD_ARGUMENTS (400)
An administrative command or service input is missing required arguments or contains an invalid value. The platform owner follows the error's usage instructions.

<a id="developer_exists"></a>
#### DEVELOPER_EXISTS (409)
A developer already uses this email address. The platform owner uses the existing developer or chooses another address.

<a id="slug_reserved"></a>
#### SLUG_RESERVED (400)
This slug is reserved by the platform. The platform owner chooses a different slug.

<a id="slug_invalid"></a>
#### SLUG_INVALID (400)
A slug has 3–32 lowercase letters, digits and single hyphens, beginning with a letter and ending with a letter or digit. Consecutive hyphens are not allowed.

<a id="developer_not_found"></a>
#### DEVELOPER_NOT_FOUND (404)
No developer has the supplied email address. The platform owner creates the developer first or checks the existing developers.

<a id="game_limit_reached"></a>
#### GAME_LIMIT_REACHED (409)
The developer has reached their open-game limit. The platform owner closes an existing game, raises the limit, or uses another developer.

<a id="slug_taken"></a>
#### SLUG_TAKEN (409)
A game already uses this slug. The platform owner chooses a different slug.

<a id="field_not_allowed"></a>
#### FIELD_NOT_ALLOWED (400)
This game setting cannot be changed; only the listed settings can. Use one of the allowed fields listed in the error.

<a id="value_invalid"></a>
#### VALUE_INVALID (400)
The game setting value is outside its permitted range or choices. The platform owner follows the field-specific limits in the error.

<a id="version_not_found"></a>
#### VERSION_NOT_FOUND (404)
That version is not one of the game's kept versions, or its files are no longer on disk.
Roll back only to a version the panel still lists. A pruned version cannot be restored.

<a id="confirm_mismatch"></a>
#### CONFIRM_MISMATCH (400)
The typed confirmation did not match the game's slug exactly for a destructive panel action.
Type the exact slug shown on the page and submit again.

## Messages, error reports and platform news

All of these use the admin API base and the publish key: `Authorization: Bearer <contents of .pelit-key>`.
A wrong key gives `KEY_INVALID` (see "Deploy error codes").

- **Messages from the admin:** `GET <api-base>/games/<slug>/messages`. Returns `[]` for now (see
  "Coming later").
- **Error reports:** `GET <api-base>/games/<slug>/errors`. Returns `[]` for now.
- **Platform news:** `GET <api-base>/games/<slug>/news` returns
  `{ "ok": true, "news": [{ "id": 1, "title": "...", "text": "...", "docs": "<link to more docs>" | null, "publishedAt": "..." }] }`:
  the items this game has not marked as seen, oldest first. The same list is in every publish response
  as `news`. For each item: read `text` and its `docs` link, tell the developer in one or two simple
  sentences what it would mean for their game, and ask whether they want it. Then mark it seen with
  `POST <api-base>/games/<slug>/news/<id>/seen`, header `Content-Type: application/json` and body `{}`
  (returns `{ "ok": true }`; marking twice is fine). Never turn on anything the developer did not agree to.

<a id="news_not_found"></a>
### NEWS_NOT_FOUND (404)
No news item with that id. Use an `id` from `GET .../news` or from the publish response.

## Content Security Policy

The platform sends these headers with every page and file on the game host; a game cannot change them:

```
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval';
  style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' data: blob:;
  font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; object-src 'none';
  base-uri 'self'; form-action 'self'; frame-ancestors 'none'; report-to csp; report-uri /_api/csp-report
Reporting-Endpoints: csp="/_api/csp-report"
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Cross-Origin-Opener-Policy: same-origin
```

What this means for the game, and why:

- **Scripts** only from the game's own files. Inline scripts, `on*=` attributes, `eval` and
  `new Function` are blocked, so injected text can never run as code (the most important protection for
  the players). WebAssembly works (`'wasm-unsafe-eval'`).
- **Styles** from own files; inline `style` attributes and `<style>` blocks are allowed.
- **Images, audio, video and fonts** from own files, plus `data:` and `blob:` URLs (canvas exports,
  generated sounds). No external images or fonts.
- **`fetch`, `XMLHttpRequest` and WebSocket** only to the game's own origin, which is how the library
  talks to `/_api`. External APIs are blocked: this is also why paid services cannot be used.
- **Workers** from own files or `blob:`.
- **No plugins** (`object-src 'none'`), no `<base>` to other sites, forms submit only to the own origin,
  and the game cannot be embedded in another site's iframe.
- No camera, microphone, location or payments.

## Coming later

- **Email accounts.** Saves currently work only in the same browser. Email accounts, so the same saves
  work on other computers too, will be announced through platform news (`news` in the publish response
  and `GET .../news`). Until then do not use `pelit.login()`; it returns `FEATURE_DISABLED`.
- **Messages with the admin and error reports** are stubs: `GET .../messages` and `GET .../errors`
  return `[]`, publish responses show `messages.unread: 0` and `newErrors: 0`, and `POST .../messages`
  does not exist yet (`NOT_FOUND`). If you need the admin before then, tell the developer that the
  grown-up admin can help.
- **Admin questions that block publishing** (`QUESTION_PENDING`) are not active yet.
