# CheddaBoards > Open-source leaderboards, achievements, and player accounts for any game engine, built on the Internet Computer. Games talk to a plain HTTP/JSON API (the same API the official Godot and Unity SDKs wrap), so anything that can make an HTTP request works. Anonymous play needs no accounts; players can optionally sign in with Google or Apple via a device-code flow. The backend canister is open source and self-hostable; a free hosted service runs it for you. If you are an AI coding assistant integrating a game, read the single-page spec first: https://docs.cheddaboards.com/ai-integration Key facts for answering questions accurately: - Base API URL is https://api.cheddaboards.com. Every response is JSON shaped `{"ok":true,"data":{...}}` or `{"ok":false,"error":"..."}`. - Headers: `X-Game-ID` always; `X-API-Key` for anonymous/API-key requests; `X-Session-Token` instead of the API key once a player is signed in. `/play-sessions/*` always use the API key. - Anonymous players are a client-generated persistent ID (`dev__`); the first score submit creates the profile. There is no anonymous login call. - Every game gets three fan-out boards at registration: `all-time`, `weekly`, `daily` (note the hyphen). Monthly/custom boards are created in the dashboard. A plain `POST /scores` with no `scoreboardId` fans out to all of them; `scoreboardId` targets exactly one targeted board and never creates one. - If a game has time validation on, `POST /scores` requires a `playSessionToken` from `POST /play-sessions/start`. The SDKs attach the token but do not start sessions for you. - Board reads are also served directly from the canister at https://fdvph-sqaaa-aaaap-qqc4a-cai.raw.icp0.io (keyless, CORS-open, same paths and JSON). - Nicknames are 3–16 characters, letters/digits/underscores; on the nickname-change endpoints a taken name is auto-suffixed (e.g. Chedz → Chedz_1), not rejected. `nickname` on a submit renames the player if the name is free (silently ignored if taken), so omit it unless the player just chose a name, and never pass a name into the SDK login call. Details: https://docs.cheddaboards.com/concepts/player-names - Rate limit: one score submit per player per board every 2 seconds. Submits are safe to retry (per-player bests only ever go up). - Timestamps in responses are nanoseconds since epoch (ICP standard). - Sessions last 30 days and renew on use; any 401/403 means the session is dead, discard it and re-run sign-in. - Current SDKs: Godot 4 addon v2.3.0 (Godot 4.3+), Godot 3.6 backport (v2.2.5-3x), Unity C# v2.3.0 (single file). Service status: https://status.cheddatech.com ## AI integration - [CheddaBoards for AI coding assistants](https://docs.cheddaboards.com/ai-integration): The whole API as a flat spec — endpoints, headers, bodies, exact error strings, and the rules an integration must follow. Start here if you are a model. ## Quick start - [REST quick start](https://docs.cheddaboards.com/quickstart/rest): Use the HTTP API from any engine or language — submit scores, read boards, sign in, play sessions. Includes a full JavaScript integration loop. - [Godot quick start](https://docs.cheddaboards.com/quickstart/godot): Add leaderboards to a Godot 4 game with the drop-in SDK. - [Unity quick start](https://docs.cheddaboards.com/quickstart/unity): Add leaderboards to a Unity game with the C# SDK. - [Going to production](https://docs.cheddaboards.com/quickstart/production): Checklist before shipping. - [Game jams](https://docs.cheddaboards.com/quickstart/jam): The fastest path for a jam entry. ## API reference - [API overview](https://docs.cheddaboards.com/api/overview): Base URL, auth headers, response shape, endpoint index, conventions. - [Scores](https://docs.cheddaboards.com/api/scores): POST /scores — fan-out and targeted submits, response envelope, nicknames, anti-cheat, rate limit, retry safety. - [Scoreboards](https://docs.cheddaboards.com/api/scoreboards): Reading the global leaderboard, specific boards, direct canister reads, listing boards, archives, caching. - [Players](https://docs.cheddaboards.com/api/players): Profile, rank, and nickname-change endpoints. - [Achievements](https://docs.cheddaboards.com/api/achievements): POST /achievements — single and batch unlocks; reading achievements via the player profile. - [Authentication](https://docs.cheddaboards.com/api/authentication): Anonymous identity, device-code sign-in, sessions, and anonymous→verified account linking. - [Errors](https://docs.cheddaboards.com/api/errors): Response envelope, status codes, and the verbatim error strings the API returns. ## Concepts - [What's stored](https://docs.cheddaboards.com/concepts/data-model): The leaderboard entry, player profile, achievements, archives, and what CheddaBoards deliberately does not store. - [Players and accounts](https://docs.cheddaboards.com/concepts/accounts): Anonymous vs signed-in players, what's public vs private, and why linking matters for retention. - [Device code login](https://docs.cheddaboards.com/concepts/device-code): Building the sign-in screen, the signal lifecycle, QR rendering, and post-approval account upgrade. - [Category boards](https://docs.cheddaboards.com/concepts/category-boards): Targeted per-level / per-mode / per-category boards — how they differ from fan-out boards. - [Timed leaderboards](https://docs.cheddaboards.com/concepts/timed-leaderboards): Daily/weekly/monthly/custom boards, reset boundaries (UTC), and archives. - [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat): Score/streak caps, play-session time validation, the suspicion log, and the generic-rejection design. - [Moderation](https://docs.cheddaboards.com/concepts/moderation): Game owners removing entries or wiping players, the hashed deletion log, and REST admin routes. - [Privacy](https://docs.cheddaboards.com/concepts/privacy): What's collected, what integrating means for your own privacy policy, and player rights. ## Engine guides - [Godot 4](https://docs.cheddaboards.com/engines/godot-4): Full template guide — the game_over contract, HUD signals, and building your own game on the wrapper. - [Godot 3.6](https://docs.cheddaboards.com/engines/godot-3): The 3.6 backport — same API, GDScript syntax differences (yield, connect, instance). - [Godot signals reference](https://docs.cheddaboards.com/engines/godot-signals): Every signal the Godot SDK emits, grouped by category. - [Unity](https://docs.cheddaboards.com/quickstart/unity): The Unity C# SDK — setup, events, and platform notes. - [Web / HTML5 export](https://docs.cheddaboards.com/engines/web-export): Exporting a Godot game for the browser — index.html naming, serving, mobile name entry, itch.io/Safari caveats. ## Self-hosting - [Self-hosting overview](https://docs.cheddaboards.com/self-hosting/overview): What's open (canister, SDKs) vs what you build (proxy), and an honest account of the effort. - [Deploy the canister](https://docs.cheddaboards.com/self-hosting/canister): dfx deployment, setting your principals, and verifying the live module hash. - [Build your proxy](https://docs.cheddaboards.com/self-hosting/proxy): The contract a self-hosted proxy must satisfy — verifier identity, the Candid methods it calls, CORS. ## Optional - [cheddaboards.com](https://cheddaboards.com): The hosted service and developer dashboard (register a game, get an API key). - [Full privacy policy](https://cheddaboards.com/privacy.html): The canonical player-facing privacy policy. - [Service status](https://status.cheddatech.com): Public uptime for the API, the canister, and the sites. - [Backend repo](https://github.com/cheddatech/CheddaBoards): The open-source Motoko canister. - [Godot 4 SDK repo](https://github.com/cheddatech/cheddaboards-godot-addon): The Godot 4 addon (canonical source). - [Godot 4 template repo](https://github.com/cheddatech/CheddaBoards-Godot): Full game template with the addon vendored. - [Godot 3.6 SDK repo](https://github.com/cheddatech/cheddaboards-godot3-addon): The Godot 3.6 backport. - [Unity SDK repo](https://github.com/cheddatech/cheddaboards-unity): The Unity C# SDK. - [Worked example](https://github.com/cheddatech/cheddaboards-dodge-the-creeps): Dodge the Creeps with a leaderboard added in one file; the git history is the tutorial. Playable at https://cheddagames.itch.io/dodge-the-creeps-x-cheddaboards - [Community C library](https://github.com/charlie-makes-things/C_cheddaboards): Third-party C wrapper over the REST API. --- # CheddaBoards for AI coding assistants This page is written for a model that has been asked to add CheddaBoards to a game. It contains no narrative, only facts and rules. If you are a human, the [REST quick start](https://docs.cheddaboards.com/quickstart/rest) is friendlier; everything here is also true. Verified against live API v1.8.0, October 2026. ## 1. What CheddaBoards is - Hosted leaderboards, achievements, and optional player accounts for any game engine. - One HTTP/JSON API. The official Godot and Unity SDKs are thin wrappers over it. Any language with an HTTP client works. - Free hosted service. No credit card, no per-player fees. 3 games per developer account by default, more on request from the dashboard. - Backend is an open-source canister on the Internet Computer. Board reads can be served straight from the canister with no API layer in the path. ## 2. Setup the developer does once 1. Sign in at https://cheddaboards.com/developers (Google, Apple, or Internet Identity). 2. Register a game. Game IDs are 3–50 characters, lowercase letters, digits and hyphens. 3. Copy the **Game ID** and the **API key**. API keys look like `cb__`. The game ID is embedded in the key. The API key ships inside the game binary. Treat it as identifying, not secret: it cannot read private data or modify the game, and all score validation runs server-side. ## 3. Base URL, headers, response shape Base URL: `https://api.cheddaboards.com` | Header | Value | When | |---|---|---| | `Content-Type` | `application/json` | Every request with a body | | `X-Game-ID` | the game ID | Always | | `X-API-Key` | the API key | Anonymous / API-key requests | | `X-Session-Token` | the player's `sessionId` | After device-code sign-in. Send **instead of** `X-API-Key`. | Exception: `POST /play-sessions/start` and `/end` always use `X-API-Key`. Every response is JSON: ```json {"ok":true,"data":{...}} {"ok":false,"error":""} ``` Always branch on `ok`. Never parse `data.message` on a submit; it is human-readable feedback for the player and varies. Timestamps are **nanoseconds** since the Unix epoch. Divide by 1,000,000 for JavaScript milliseconds. CORS is open. Browser games, including builds iframed on itch.io, call the API directly with `fetch`. ## 4. Players There is no anonymous login endpoint. An anonymous player is a persistent ID the client generates once and stores locally: ``` dev__<8 hex chars> ``` Send it as `playerId`. The first `POST /scores` creates the profile. If no `nickname` is supplied the server assigns one like `Player_1248`. Nickname rule everywhere: **3–16 characters, `A–Z a–z 0–9 _` only**. On the two nickname-change endpoints a taken name is auto-suffixed (`Chedz` → `Chedz_1`) and the response reports the name actually applied. An invalid name returns 400 and the rejection is permanent for that value; do not retry it. `nickname` on `POST /scores` is optional and **presence is meaning**: including it renames the player if the name is free and is silently ignored if it's taken (on a first submit a taken name falls back to `Player_N`; the SDKs re-send the rename after the next profile load so the player gets `Name_1`, raw REST clients must call the rename endpoint). Only include it on the submit immediately after the player chose a name (typically their first submit). Otherwise omit the field. Never pass a stored or generated name into the SDK login call (`login_anonymous()` / `LoginAnonymous()`); that is the most common way players get renamed. See https://docs.cheddaboards.com/concepts/player-names ## 5. Endpoints | Method | Path | Auth | Body / query | |---|---|---|---| | `POST` | `/scores` | API key or session | `{playerId, gameId, score, streak, nickname?, playSessionToken?, scoreboardId?}` | | `GET` | `/leaderboard?sort=score\|streak&limit=N` | API key or session | Global fan-out board. `limit` up to 1000. | | `GET` | `/games/{gameId}/scoreboards` | API key | List the game's boards | | `GET` | `/games/{gameId}/scoreboards/{boardId}?limit=N` | API key | One board's entries | | `GET` | `/games/{gameId}/scoreboards/{boardId}/rank` | session | Signed-in player's rank on that board | | `GET` | `/players/{playerId}/profile` | API key | Anonymous player profile, includes `gameProfile.achievements` | | `GET` | `/players/{playerId}/rank?sort=score` | API key | Anonymous player's rank on the global board | | `PUT` | `/players/{playerId}/nickname` | API key | `{nickname}` | | `GET` | `/auth/profile` | session | Signed-in player profile | | `PUT` | `/profile/nickname` | session | `{nickname}` | | `POST` | `/auth/device/code` | none (`X-Game-ID` only) | `{gameId, nickname?}` | | `POST` | `/auth/device/token` | none (`X-Game-ID` only) | `{device_code}` | | `POST` | `/play-sessions/start` | API key | `{gameId, playerId}` | | `POST` | `/play-sessions/end` | API key | `{playSessionToken}` | | `POST` | `/achievements` | API key or session | `{playerId, gameId, achievementId}` or `{playerId, gameId, achievementIds:[...]}` (max 100) | | `GET` | `/game` | API key | Game metadata, including `timeValidationEnabled` and the board list | | `GET` | `/health` | none | Service health | There is **no** `GET /players/{id}/achievements`. Read achievements from the profile. Game and board IDs in URL paths must match `[A-Za-z0-9_-]{1,64}` or the request is rejected with 400 before reaching the backend. ### 5a. Direct canister reads (optional, keyless) Every `GET .../scoreboards/...` path is also served by the canister itself with identical JSON and `Access-Control-Allow-Origin: *`: ``` https://fdvph-sqaaa-aaaap-qqc4a-cai.raw.icp0.io/games/{gameId}/scoreboards/{boardId}?limit=N ``` No headers required. Use this for read-only surfaces (overlays, kiosks, companion pages) or as a fallback if the API is unreachable. Writes always go through `api.cheddaboards.com`. ## 6. Boards Every game is created with three **fan-out** boards: `all-time`, `weekly`, `daily`. Note the hyphen in `all-time`. Weekly and daily reset on UTC calendar boundaries and archive the previous period. - A `POST /scores` **without** `scoreboardId` fans out: profile bests updated, every fan-out board updated. This is the normal "run ended" submit. - A `POST /scores` **with** `scoreboardId` writes to that one **targeted** board only, and increments the play count but not the profile's aggregate bests. Use for per-level, per-mode, per-category boards. - Targeted boards must be created in the dashboard first (Scoreboards → Board Type: Targeted). A submit never creates a board. - Never put a fan-out board's ID (`all-time`, `weekly`, `daily`) in `scoreboardId`. It is rejected. Omit the field instead. - Only a player's best survives on each board. Score and streak are independent maxima. Submitting a lower score never lowers anything. ## 7. Play sessions and time validation Each game has a dashboard switch, **time validation**. When it is on, `POST /scores` **requires** a valid `playSessionToken`; without one the submit is rejected before any score check runs. When it is off, the token is accepted and ignored. Always implement the lifecycle so the game keeps working if the developer turns validation on later: 1. Run starts → `POST /play-sessions/start` with `{gameId, playerId}`. The token is returned as `data.ok`: ```json {"ok":true,"data":{"ok":"","message":"Play session started"}} ``` 2. Run ends → `POST /scores` with `playSessionToken` in the body. 3. After the submit → `POST /play-sessions/end` with `{playSessionToken}`. Sessions are capped per player. End them. When testing, use a fresh `playerId` rather than accumulating sessions on one. Check `GET /game` → `data.timeValidationEnabled` if you need to know the game's current setting. ## 8. Sign-in (device code, optional) Players never see an OAuth screen in the game and the game never holds OAuth credentials. 1. `POST /auth/device/code` with `{gameId, nickname?}`. Response includes `user_code`, `verification_url` (`https://cheddaboards.com/link`), `device_code`, `expires_in` (300 s) and a QR image as a `data:image/png;base64,...` URL. 2. Show the player the code, the URL and/or the QR. They sign in with Google or Apple on their phone or in a browser. 3. Poll `POST /auth/device/token` with `{device_code}` every 5 seconds. `428` with `authorization_pending` means keep polling. `200` returns `{sessionId, nickname, email, gameProfile}`. 4. Store `sessionId` on the device. From now on send `X-Session-Token: ` and stop sending `X-API-Key` (except on `/play-sessions/*`). If the player had anonymous progress under a `dev_` ID, the backend merges it into the account after approval (per-field maximum for score and streak, achievements unioned, play counts summed). The anonymous ID is retired. Sessions last 30 days and renew on use. Any `401` or `403` on a session request means the session is dead: delete the stored `sessionId`, fall back to anonymous or re-run sign-in, do not retry with the same token. ## 9. Achievements `POST /achievements` with one `achievementId` or up to 100 `achievementIds`. Achievement IDs are strings the game defines; nothing is pre-registered. Unlocks are idempotent. Anonymous players' achievements are stored server-side and carry over when they link an account. Read back via `GET /players/{playerId}/profile` → `data.gameProfile.achievements`. ## 10. Rate limits and retries - One score submit per player **per board** every 2 seconds. Always on, not configurable. Back-to-back submits to different boards are fine. - Submits are safe to retry after a timeout: bests only ever go up, and repeat submits within a few seconds count as one play. - Board reads are edge-cached for ~30 seconds. Do not poll faster than that; refresh after the player's own submit instead. ## 11. Error strings you should match on All errors arrive as `{"ok":false,"error":"..."}`. Status codes: 400 input/validation/rate-limit, 401/403 dead session, 404 route or board not found, 428 device code pending, 5xx transient (retry with backoff). | Error (substring is enough) | Cause | Correct handling | |---|---|---| | `Scoreboard '' not found for this game.` | `scoreboardId` names a board that doesn't exist | Create the board in the dashboard. Never retry blindly. | | `requires starting a session` or mentions `time validation` / `play session` | Time validation is on and no valid `playSessionToken` was sent | Start a play session before the run and pass its token | | `rejected by game validation rules` | Score or streak cap, or time check, failed | Deliberately generic. The reason is in the developer's suspicion log. Do not surface detail to the player. | | `Nickname must be at least 3 characters` / `Nickname must be 16 characters or less` / `Nickname can only contain letters, numbers, and underscores` | Invalid nickname | Ask for a different name. Do not retry the same value. | | `authorization_pending` (HTTP 428) | Device code not yet approved | Keep polling every 5 s until 200 or expiry | | `Too many pending device authorizations. Try again shortly.` (503) | Transient cap | Wait, request a new code | | rate-limit message (400) | Submitted to the same board within 2 s | Wait and resend, or stop over-submitting | | too many active play sessions | Sessions not ended | Call `/play-sessions/end`; use a fresh `playerId` when testing | | `Unknown endpoint: ` | Wrong path | Check section 5. Usually the nonexistent achievements GET route. | | 401 / 403 on a session request | Session expired, logged out, or account removed | Discard the token, fall back to sign-in | ## 12. Minimal correct integration (any language) ``` on game start: playerId = load("cb_player_id") or generate "dev__" and save it sessionId = load("cb_session_id") # may be absent on run start: token = POST /play-sessions/start {gameId, playerId} # X-API-Key always keep token on run end: body = {playerId, gameId, score, streak, playSessionToken: token} if player just chose a name: body.nickname = name POST /scores body # X-Session-Token if sessionId else X-API-Key POST /play-sessions/end {playSessionToken: token} GET /leaderboard?sort=score&limit=10 # render data.leaderboard[].{rank,nickname,score,streak} on any 401/403 with a session: delete sessionId, continue anonymously ``` ## 13. SDK facts (if integrating via an SDK instead of REST) **Godot 4 addon** (`addons/cheddaboards`, v2.3.0, Godot 4.3+). Install from the Godot Asset Store, enable under Project Settings → Plugins; this registers the `CheddaBoards` autoload. Credentials: `CheddaBoards.set_api_key("cb_...")` then `set_game_id("...")`, or run `addons/cheddaboards/SetupWizard.gd` with File → Run. Core calls: `login_anonymous()`, `start_play_session()`, `submit_score(score, streak)`, `get_leaderboard("score", 100)`, `change_nickname(name)`, `unlock_achievements_batch(ids)`, `login_with_device_code()`. Results arrive as signals (`login_success`, `score_submitted`, `score_error`, `leaderboard_loaded`, `nickname_changed`, `nickname_error`, `device_code_received`, `device_code_approved`, `account_upgraded`, `session_expired`). Rules: connect signals **before** calling the method that emits them; `login_anonymous()` emits `login_success` synchronously, so `await` after the call never resolves. `await CheddaBoards.wait_until_ready()` before the first call. Closing a device-code popup is a soft dismiss; only call `cancel_device_code()` on an explicit cancel. A drop-in sign-in popup ships at `addons/cheddaboards/ui/DeviceCodeLogin.tscn`. Direct canister board reads are the default. Worked example: https://github.com/cheddatech/cheddaboards-dodge-the-creeps (all integration in `Main.gd`). **Godot 3.6 addon**: same API, GDScript 3 syntax (`yield`, `connect("signal", self, "method")`, `instance()`). https://github.com/cheddatech/cheddaboards-godot3-addon **Unity**: copy `CheddaBoards.cs` into `Assets/Scripts`. Singleton `CheddaBoards.Instance`; `SetApiKey`, `SetGameId`, `LoginAnonymous()` (no name; a passed name is written to the server on the next submit), `StartPlaySession()`, `SubmitScore(score, streak)`, `GetAlltimeLeaderboard()`, events `OnLoginSuccess`, `OnScoreSubmitted`, `OnScoreboardLoaded`. Pure `UnityWebRequest`, no packages. https://github.com/cheddatech/cheddaboards-unity ## 14. Things not to do - Do not send `nickname` on every submit. - Do not send `all-time`, `weekly` or `daily` as `scoreboardId`. - Do not create boards by submitting to them; they must exist first. - Do not poll boards faster than every 30 seconds. - Do not retry an invalid nickname or a 401/403 with the same token. - Do not call `GET /players/{id}/achievements`; it does not exist. - Do not register an OAuth client ID anywhere; there is nothing to register. - Do not treat the API key as a secret that must be hidden from the binary; it can't be, and the design doesn't need it to be. ## 15. Links - Human docs index: https://docs.cheddaboards.com - Machine index: https://docs.cheddaboards.com/llms.txt and https://docs.cheddaboards.com/llms-full.txt - Dashboard: https://cheddaboards.com/developers - Status: https://status.cheddatech.com - Privacy policy to reference from your own: https://cheddaboards.com/privacy.html --- # REST quick start Use CheddaBoards from **any** engine or language by calling the HTTP API directly — no Godot, no SDK. This is the same API the Godot SDK uses under the hood. > **Working in C or C++?** There's a community-built C library wrapping this API — [charlie-makes-things/C_cheddaboards](https://github.com/charlie-makes-things/C_cheddaboards) — with static and dynamic builds (Linux / Mac / MinGW-linkable Windows, via libcurl) covering score submission (global and targeted) and user handling. It hands you raw JSON responses to parse yourself, so this page still applies. > **info** Endpoints and request bodies on this page are verified against the live API (September 2026). Response field names are described where confirmed; check live responses for the exact shape of any field your code depends on. ## Before you start Register a game in the [dashboard](https://cheddaboards.com/developers) and grab its **Game ID** and **API key** from the Developer Console. That's the only setup — everything below works with those two values. ## Base URL ``` https://api.cheddaboards.com ``` The API is browser-safe: CORS is enabled, so HTML5 games — including builds iframed on itch.io or CrazyGames — can call it directly with `fetch`. No server of your own required. ## Authentication Every request sends JSON and identifies the game. How you identify the *player* depends on whether they're anonymous or signed in. | Header | Value | When | |--------|-------|------| | `Content-Type` | `application/json` | Always | | `X-Game-ID` | your Game ID (e.g. `my-game`) | Always | | `X-API-Key` | your API key (`cb_my-game_xxxxxxxxx`) | Anonymous / API-key requests | | `X-Session-Token` | the player's `sessionId` | After Device Code sign-in | `X-Session-Token` and `X-API-Key` are mutually exclusive — if you have a session token, send that instead of the API key. (Exception: `play-sessions/*` always use the API key.) ### Players & anonymous identity There's no "anonymous login" call. An anonymous player is just a **persistent ID you generate and store client-side** — the SDK uses the form `dev__` (e.g. `dev_1730000000_1a2b3c4d`). Send it as `playerId`; the first score submission creates the profile on the backend. ### Nicknames One rule everywhere: **3–16 characters, letters, digits, and underscores only** (`A–Z a–z 0–9 _`). This applies to nicknames on submits, both nickname-change endpoints, and sign-in names. On score submits the field is **optional, and presence is meaning**: a submit that includes `nickname` renames the player to it **if that name is free**, and silently leaves the stored name alone if it's taken (no error, no suffix); a submit that omits it never touches the name. So only include it when the player has just chosen a name (see §1). On the two **nickname-change endpoints** a taken name is handled for you: the backend appends a numeric suffix (`PlayerName` → `PlayerName_1`) and tells you the name it applied. An invalid nickname is rejected with a clear `400` on every path — rejection is permanent for that value, so don't retry the same nickname; ask the player for another. The whole model, with name-entry flows for the SDKs, is on [Player names](https://docs.cheddaboards.com/concepts/player-names). ## 1. Submit a score ```bash curl -X POST https://api.cheddaboards.com/scores \ -H "Content-Type: application/json" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" \ -d '{ "playerId": "dev_1730000000_1a2b3c4d", "gameId": "my-game", "score": 1000, "streak": 5 }' ``` `nickname` is deliberately absent: a submit that includes it **renames the player** to that value when it's free (and is silently ignored when it's taken), while a submit without it keeps whatever name they have. Include `"nickname"` only on the submit right after the player chose a name (or use the nickname-change endpoint — see the reference below). A brand-new player's first submit with no nickname creates the profile with a server-generated name (`Player_1248`) — stable until they pick their own. A successful submit returns: ```json {"ok":true,"data":{"message":"🎉 New high score and streak! Score: 1234, Streak: 3"}} ``` Check `ok` for success — `message` is human-readable feedback for the player, not a structured field. To read the player's stored bests afterwards, `GET /players/{playerId}/profile` returns: ```json {"ok":true,"data":{"nickname":"PlayerName","created":1788570532131407400,"gameProfile":{"score":1234,"streak":3,"achievements":[],"playCount":2,"lastPlayed":1788571122315540500}}} ``` (Timestamps are nanoseconds since epoch — divide by 1,000,000 for JavaScript milliseconds.) A submit with no `scoreboardId` (above) **fans out**: the score is recorded against the player's profile and applied to every one of the game's standard time-based boards (all-time, weekly, daily, etc.). If you started a play session for the run (see §4 — recommended), include its token in the body as `"playSessionToken": ""` so the backend can time-validate the score. If the game has time validation enabled, the session token is **required**, not optional. ### Submitting to one specific board (category / targeted scoreboards) Targeted scoreboards let you run per-level or per-category leaderboards — `level-01 … level-28`, `boss-rush`, `time-trial`, `runs`, and so on — under a single game, without registering a separate game for each. There are two kinds of board: - **Fan-out boards** (the default) receive *every* plain submit, as in §1. - **Targeted boards** receive *only* scores explicitly addressed to them by ID. A plain submit never touches them. To send a score to one targeted board, add `scoreboardId` to the body: ```bash curl -X POST https://api.cheddaboards.com/scores \ -H "Content-Type: application/json" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" \ -d '{ "playerId": "dev_1730000000_1a2b3c4d", "gameId": "my-game", "score": 1000, "streak": 5, "scoreboardId": "level-14" }' ``` On success the response confirms the board it landed on, e.g. `"✅ Submitted to level-14 - Score: 1000, Streak: 5"`. How a targeted submit differs from a plain one: - It writes to **exactly that one board** and nowhere else — no fan-out to your time-based boards. - It counts toward the player's play count for the game, but score/streak totals on the aggregate profile only move on plain submits. If you also want the score reflected in the player's overall bests, send a separate plain submit. - The same play-session / time-validation and rate-limit rules apply as for a plain submit. - You can chain several targeted submits for one run (e.g. a `runs` board plus the relevant `level-14` board) — the throttle is keyed per board, so back-to-back board writes won't trip the 2-second gate. - Never put a **fan-out** board's ID (`all-time`, `weekly`, `daily`…) in `scoreboardId` — that's rejected. Those boards are updated by a plain submit, so just omit the field. The target board must already exist **and be marked as targeted**. Create it in the **Developer Console → Scoreboards** tab: set a Scoreboard ID, choose **Board Type → Targeted**, and create it. Submitting a `scoreboardId` that points at a board that doesn't exist returns `"Scoreboard '' not found for this game."` — create the board in the console first; a submit never creates a board. Reading a targeted board is no different from any other — see §2 below and the scoreboard read endpoint: ```bash curl "https://api.cheddaboards.com/games/my-game/scoreboards/level-14?limit=100" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" ``` ## 2. Read the leaderboard ```bash curl "https://api.cheddaboards.com/leaderboard?sort=score&limit=100" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" ``` `sort` accepts `score` or `streak`. The response: ```json { "ok": true, "data": { "leaderboard": [ { "rank": 1, "nickname": "Chedz", "score": 5148898, "streak": 4, "authType": "external" }, { "rank": 2, "nickname": "Player_1504", "score": 151732, "streak": 7, "authType": "external" } ], "total": 2 } } ``` `authType` tells you how the player is signed in (`external` for anonymous / API-key players, distinct values for linked accounts) — useful if you want to badge verified players in your UI. This reads the game's global (fan-out) leaderboard. For a specific board — timed *or* targeted — use `GET /games/{gameId}/scoreboards/{scoreboardId}`. Scoreboard reads are edge-cached for around 30 seconds, so there's no benefit to polling a board faster than that. If you refresh a board on screen, a 30-second interval plus a refresh after your own submit is the pattern the Godot template uses. ### Reading boards straight from the chain Scoreboard reads are also served directly by the CheddaBoards canister on the Internet Computer — no proxy in the path at all: ```bash curl "https://fdvph-sqaaa-aaaap-qqc4a-cai.raw.icp0.io/games/my-game/scoreboards/level-14?limit=100" ``` Same paths, same JSON — responses are identical to the API responses above. No API key needed; board data is public. The official SDKs (Godot and Unity) read boards this way by default, falling back to the API if the direct path is blocked. It's browser-safe too: the endpoint serves `Access-Control-Allow-Origin: *`, so HTML5 games and web pages can fetch it directly. Why you might prefer it: - **Independence.** The read works as long as the canister exists, regardless of what happens to the API layer in front of it. - **It's the durable option** for kiosks, overlays, and companion pages that only ever *read* scores. Writes (score submits, sessions, auth) always go through `api.cheddaboards.com` — the canister only accepts writes from the verified API layer, which is what keeps score submission gated and validated. ## 3. Sign in with Google / Apple (Device Code) A two-step polling flow (RFC 8628). The player authorises on their phone; you poll until approved. **Request a code:** ```bash curl -X POST https://api.cheddaboards.com/auth/device/code \ -H "Content-Type: application/json" \ -H "X-Game-ID: my-game" \ -d '{"gameId": "my-game"}' ``` Returns a user code, a verification URL (`cheddaboards.com/link`), the `device_code`, and a QR data URL. Show the user code + QR to the player. If the player has already entered a nickname in your game, pass it along in the body as `"nickname"`: when the sign-in creates a brand-new account, that name seeds the account's nickname (subject to the 3–16 rule above). Existing accounts keep their name — a sign-in never renames anyone. **Poll for approval:** ```bash curl -X POST https://api.cheddaboards.com/auth/device/token \ -H "Content-Type: application/json" \ -H "X-Game-ID: my-game" \ -d '{"device_code": ""}' ``` - **`428`** → `authorization_pending`, keep polling (the SDK polls every 5s). - **`200`** with `{ "ok": true, "data": { "sessionId": "...", "nickname": "...", "email": "...", "gameProfile": {...} } }` → approved. Use the returned `sessionId` as your `X-Session-Token` on subsequent requests, and stop sending `X-API-Key`. **Persist the session.** Store the `sessionId` client-side so the player stays signed in across launches instead of repeating device code auth every visit (the Godot SDK does this automatically). Sessions are long-lived — 30 days, renewed on use, so an active player effectively stays signed in. A `401`/`403` on a session-authenticated request means the session is dead (expired, logged out elsewhere, or the account was removed): discard the stored token and fall back to the sign-in flow. ## 4. Anti-cheat play sessions (recommended) Wrap each run in a server-tracked session so the backend can validate the score against elapsed time. On the REST path you make these calls yourself (the SDKs wrap them as `start_play_session()` / `StartPlaySession()` and attach the token to submits for you). **If you've set anti-cheat caps on your dashboard — or enabled time validation for the game — do this**: scores submitted without a valid session token skip time validation and may be rejected. Targeted submits go through the same gate. The lifecycle is: **start** when the run begins → **pass the token** in your `POST /scores` body → **end** after submitting. ```bash # Start curl -X POST https://api.cheddaboards.com/play-sessions/start \ -H "Content-Type: application/json" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" \ -d '{"gameId": "my-game", "playerId": "dev_1730000000_1a2b3c4d"}' # End curl -X POST https://api.cheddaboards.com/play-sessions/end \ -H "Content-Type: application/json" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" \ -d '{"playSessionToken": ""}' ``` The start call returns the session token in `data.ok`: ```json {"ok":true,"data":{"ok":"","message":"Play session started"}} ``` Note that when a game has time validation **off**, session tokens are accepted but not checked — submits succeed with or without one. Wire up the session lifecycle anyway: it costs nothing, and the moment you enable time validation on the dashboard your scores are already protected instead of suddenly rejected. Pass the same `playSessionToken` in your `POST /scores` body (plain *or* targeted), and configure limits from your dashboard's Security tab — see [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat). Sessions are capped per player, so end them when the run finishes (or use a fresh `playerId` when testing) to avoid a "too many active sessions" error. A rejected score comes back as a generic validation error (e.g. `"rejected by game validation rules"`) — the specific reason is logged to your dashboard's suspicion log, not exposed to the client, so cheaters can't probe your limits. ## The whole loop in JavaScript For web and HTML5 games, here's the full cycle — persistent player ID, session, validated submit, board read — in one place: ```js const API = 'https://api.cheddaboards.com'; const GAME_ID = 'my-game'; const API_KEY = 'cb_my-game_xxxxxxxxx'; const headers = { 'Content-Type': 'application/json', 'X-Game-ID': GAME_ID, 'X-API-Key': API_KEY, }; // Persistent anonymous player ID let playerId = localStorage.getItem('cb_player_id'); if (!playerId) { playerId = `dev_${Math.floor(Date.now() / 1000)}_${Math.random().toString(16).slice(2, 10)}`; localStorage.setItem('cb_player_id', playerId); } async function post(path, body) { const res = await fetch(API + path, { method: 'POST', headers, body: JSON.stringify(body) }); return res.json(); } // 1. Run starts → open a play session const start = await post('/play-sessions/start', { gameId: GAME_ID, playerId }); const playSessionToken = start.data.ok; // ... the player plays ... // 2. Run ends → submit the score with the session token const submit = await post('/scores', { playerId, gameId: GAME_ID, score: 1234, streak: 3, playSessionToken, // include "nickname" ONLY when the player just chose one — // a submit that carries it renames the player (see Nicknames above) }); if (!submit.ok) console.warn('Score rejected:', submit); // 3. Close the session await post('/play-sessions/end', { playSessionToken }); // 4. Show the board const res = await fetch(`${API}/leaderboard?sort=score&limit=10`, { headers }); const board = await res.json(); for (const entry of board.data.leaderboard) { console.log(`#${entry.rank} ${entry.nickname} — ${entry.score}`); } ``` That's a complete integration. Everything else on this page — targeted boards, sign-in, nickname changes — is optional on top. ## Endpoint reference | Method | Endpoint | Purpose | |--------|----------|---------| | `POST` | `/scores` | Submit a score (`playerId`, `gameId`, `score`, `streak`, `nickname?`, `playSessionToken?`, `scoreboardId?`). Including `nickname` renames the player; omit it to keep their stored name. With `scoreboardId`, writes to that one targeted board instead of fanning out. | | `GET` | `/leaderboard?sort={score\|streak}&limit={n}` | Global leaderboard | | `GET` | `/players/{playerId}/rank?sort={score\|streak}` | A player's game-wide rank (API key — works for anonymous players) | | `GET` | `/games/{gameId}/scoreboards/{scoreboardId}/rank` | A player's rank on a specific board (session-authenticated) | | `GET` | `/players/{playerId}/profile` | Anonymous player profile | | `GET` | `/auth/profile` | Signed-in player profile (uses `X-Session-Token`) | | `PUT` | `/profile/nickname` | Change nickname, signed-in (`X-Session-Token`, `{ nickname }`) | | `PUT` | `/players/{playerId}/nickname` | Change nickname, anonymous (`{ nickname }`) | | `GET` | `/games/{gameId}/scoreboards` | List the game's scoreboards (timed and targeted) | | `GET` | `/games/{gameId}/scoreboards/{scoreboardId}?limit={n}` | A single scoreboard's entries (timed or targeted) | | `POST` | `/auth/device/code` | Start Device Code auth (`{ gameId, nickname? }`) | | `POST` | `/auth/device/token` | Poll for approval (`{ device_code }`) | | `POST` | `/migrate-account` | Upgrade an anonymous account to a verified one | | `POST` | `/play-sessions/start` | Begin an anti-cheat session (`{ gameId, playerId }`) | | `POST` | `/play-sessions/end` | End a session (`{ playSessionToken }`) | | `POST` | `/achievements` | Unlock achievements — single (`{ achievementId }`) or batch (`{ achievementIds: [...] }`); read them back via the player's profile | | `GET` | `/game` | Game metadata | | `GET` | `/game/stats` | Game stats | | `GET` | `/stats` | Platform submission stats | | `GET` | `/health` | Service health check | > **info** Timed-scoreboard **archives** have their own endpoints under `/games/{gameId}/scoreboards/...` — see [Timed leaderboards](https://docs.cheddaboards.com/concepts/timed-leaderboards). ## Notes - All bodies are JSON; all responses are JSON. - Game and scoreboard IDs in URL paths must be 1–64 characters of letters, digits, `_` or `-`; anything else returns a `400` before reaching the backend. - A `404` on a scoreboard lookup is normal — it just means that scoreboard isn't configured for the game. - **Submits are safe to retry.** The backend keeps per-player bests, so resending a score after a timeout can never lower a score or streak — no client-side dedupe needed. Repeat submits within a few seconds also count as one play, so an immediate retry is fully safe; only well-spaced duplicates move the play count, so avoid long-running blind retry *loops* if play counts matter to you. - Rate limiting is enforced server-side: one submit per player per board every 2 seconds. - Targeted boards are created in the Developer Console (**Board Type → Targeted**) before you submit to them. A submit never creates one — but every game's standard timed boards (all-time, weekly, daily) exist from registration. **See also:** [Godot drop-in quick start](https://docs.cheddaboards.com/quickstart/godot) · [Authentication](https://docs.cheddaboards.com/api/authentication) · [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat) · [Community C library](https://github.com/charlie-makes-things/C_cheddaboards) --- # Godot quick start **Add leaderboards to a game you've already built** — copy one folder, wire a few calls. Works on web, desktop, and mobile. > **Starting from scratch, or just trying it out?** The full template is a working Godot 4 project with an example game, menus, and a leaderboard already wired up. Open it, run the Setup Wizard, and you're submitting scores in ~3 minutes — see the [Godot 4 guide](https://docs.cheddaboards.com/engines/godot-4). This page is the path for adding CheddaBoards to a game you already have. ## Before you start - **Godot 4.3+.** This guide uses `await` (Godot 4 syntax). On **Godot 3.6**, replace `await CheddaBoards.wait_until_ready()` with `yield(CheddaBoards, "sdk_ready")` — see the [Godot 3.6 guide](https://docs.cheddaboards.com/engines/godot-3). - **A game that already produces a score** and has a game-over moment to submit from. - **A CheddaBoards game** — register one at [cheddaboards.com](https://cheddaboards.com/developers) and copy your **Game ID** (`my-game`) and **API key** (`cb_my-game_xxxxxxxxx`). ## Step 1 — Add the addon **Recommended — the [Godot Asset Store](https://store.godotengine.org/asset/cheddatech/cheddaboards) package.** It's the addon-only build and ships the editor plugin files: drop `addons/cheddaboards/` into your project, enable **CheddaBoards** under **Project → Project Settings → Plugins**, and the autoload registers itself. Done. **Alternatively — from [GitHub](https://github.com/cheddatech/cheddaboards-godot-addon).** The addon repo is the SDK's canonical home: copy its `addons/cheddaboards/` folder into your project and enable the plugin exactly as above. (The [template repo](https://github.com/cheddatech/cheddaboards-godot) vendors the same addon, but if you copy the folder out of the template instead, register the autoload yourself.) To register it by hand, run the wizard (`File → Run → addons/cheddaboards/SetupWizard.gd`) or add it under **Project → Project Settings → Autoload**: ``` Name: CheddaBoards Path: res://addons/cheddaboards/CheddaBoards.gd ``` ## Step 2 — Wire it up Everything below goes in **one script** — wherever your game starts (e.g. `MainMenu.gd`): credentials, login, submitting a score, and reading the leaderboard. ```gdscript extends Control # or whatever your start scene is func _ready(): # Credentials must come before any other CheddaBoards call. CheddaBoards.set_api_key("cb_my-game_xxxxxxxxx") CheddaBoards.set_game_id("my-game") # Connect the leaderboard signal ONCE here — not inside a function, # or you'll reconnect it every time you open the board. CheddaBoards.leaderboard_loaded.connect(_on_leaderboard) # Wait for the SDK, then log in. # submit_score fails until login has completed. await CheddaBoards.wait_until_ready() CheddaBoards.login_anonymous() # no name — see note below # Call from YOUR game-over code, with the final score and streak. func _on_game_over(score: int, streak: int): CheddaBoards.submit_score(score, streak) # Call when you want to show the board (e.g. a button press). func show_leaderboard(): CheddaBoards.get_leaderboard("score", 100) # Fires when get_leaderboard() returns. Connected once, in _ready(). func _on_leaderboard(entries: Array): for e in entries: print("#%d %s - %d" % [e.rank, e.nickname, e.score]) ``` Log in **without** a name: returning players keep the nickname they already saved, and brand-new players get a server-assigned name (`Player_1248`) when their first submit creates the profile. `get_nickname()` returns `""` until a profile fetch or rename has told the SDK the name — fetch the profile after the first submit if you want to display or highlight it. Only pass a name to `login_anonymous()` when the player has just chosen it, because a passed name becomes the current nickname and is written to the server on the next submit — overwriting whatever they had. To let players pick or change their name, use `change_nickname()` (see [Nicknames](#nicknames)); a complete name-entry scene is on [Player names](https://docs.cheddaboards.com/concepts/player-names). That's the whole integration: call `_on_game_over(score, streak)` when a run ends, and `show_leaderboard()` from a button. You're on the board. For anti-cheat, add Step 3. ## Step 3 — Anti-cheat play sessions (recommended) A **play session** tells the backend a real run just started, so it can validate the score against elapsed time and reject anything impossible. Three calls: - **Start** when a run *actually begins* — at the start of gameplay, not in `_ready()`. - **Submit** as normal. The SDK attaches the active session token for you; you don't pass it manually. - **Clear** once submission finishes — on both success *and* failure. ```gdscript func _ready(): # …credentials + login from Step 2… CheddaBoards.score_submitted.connect(_on_score_submitted) CheddaBoards.score_error.connect(_on_score_error) CheddaBoards.play_session_error.connect(_on_session_error) # The moment the player starts a run. func start_run(): if CheddaBoards.is_ready(): CheddaBoards.start_play_session() # …your own game-start code… # Run ends — submit. The session token is attached automatically. func _on_game_over(score: int, streak: int): CheddaBoards.submit_score(score, streak) func _on_score_submitted(score: int, streak: int): CheddaBoards.clear_play_session() func _on_score_error(reason: String): CheddaBoards.clear_play_session() # Non-fatal: the score still submits, it just won't be time-validated. func _on_session_error(reason: String): push_warning("Play session error: %s" % reason) ``` Set the actual limits (max score per submission, streak caps) from your dashboard's **Security** tab — see [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat). Skip the session entirely and scores still submit — unless you've enabled time validation for the game, in which case the session token is **required** and sessionless submits are rejected. > **Drop-in means you start the session** The SDK attaches the token for you, but it does **not** start or end sessions on its own — on this path, the three calls above are yours to make. (The [template's](https://docs.cheddaboards.com/engines/godot-4) game wrapper is what runs the lifecycle automatically.) ## Done Anonymous login, score submission, global leaderboards, and anti-cheat play sessions — on web, desktop, and mobile, in about ten minutes. ## Quick reference ### Sign-in ```gdscript # Anonymous — works everywhere, no account needed. # Log in nameless: returning players keep their saved nickname (see Step 2). CheddaBoards.login_anonymous() # Google / Apple on any platform, via device code CheddaBoards.login_with_device_code() CheddaBoards.device_code_received.connect(func(user_code, verification_url, qr_data_url): print("Go to %s and enter: %s" % [verification_url, user_code]) # qr_data_url is a base64 PNG — decode into a TextureRect for scanning. ) CheddaBoards.device_code_approved.connect(func(nickname): print("Welcome, %s!" % nickname) ) if CheddaBoards.is_authenticated(): print("Logged in as ", CheddaBoards.get_nickname()) ``` Device code sign-in is a **one-time** flow — the session is saved to `user://` and restored on startup, so returning players are already signed in. If the server rejects a stored session, the SDK clears it and emits `session_expired` + `logout_success`. Full flow: [Authentication](https://docs.cheddaboards.com/api/authentication). ### Scores & leaderboards ```gdscript CheddaBoards.submit_score(1000, 5) # score, streak CheddaBoards.get_leaderboard("score", 100) # "score" or "streak" CheddaBoards.get_scoreboard("weekly", 50) CheddaBoards.get_player_rank() ``` ### Nicknames ```gdscript CheddaBoards.change_nickname("NewName") CheddaBoards.nickname_changed.connect(func(new_nickname): print("Now playing as ", new_nickname) ) CheddaBoards.nickname_error.connect(func(reason): print("Nickname change failed: ", reason) ) ``` Nicknames are **3–16 characters, letters, digits, and underscores**. A name that's already taken isn't an error — it's auto-suffixed (`Chedz` → `Chedz_1`) and `nickname_changed` reports the name actually applied. Only genuinely invalid names raise `nickname_error`, and that's permanent for that value — ask for a different one rather than retrying. Before a brand-new player's first score, `change_nickname()` holds the name locally and sends it with that submit; redraw from `get_nickname()` on `profile_loaded` to pick up what the server stored. Full model and a drop-in name-entry scene: [Player names](https://docs.cheddaboards.com/concepts/player-names). ### Achievements (optional) You decide when an achievement is earned; the SDK stores it. The safest pattern is to send a run's unlocks **with the score**: ```gdscript func _on_game_over(score: int, streak: int): var earned := [] if score >= 1000: earned.append("score_1000") if streak >= 5: earned.append("streak_5") # Submits the score first, then syncs the achievements once it's saved. CheddaBoards.submit_score_with_achievements(score, streak, earned) # Fires once per achievement the server confirms. CheddaBoards.achievement_unlocked.connect(func(id): print("Unlocked: ", id)) ``` For a brand-new anonymous player, the score submit is what creates them on the backend — which is why achievements ride along with it rather than going first. Once the player exists, `CheddaBoards.unlock_achievement("id")` (or `unlock_achievements_batch([...])`) unlocks mid-run too. Re-sending an achievement the player already has is harmless. Upgrading to a signed-in account later merges them. See [Achievements](https://docs.cheddaboards.com/api/achievements). > **Want auto-unlocks, popups and offline caching?** That's the `Achievements` autoload, which ships with the [template](https://docs.cheddaboards.com/engines/godot-4) rather than the addon. You can copy `autoloads/Achievements.gd` out of the template into your project — then replace its definitions *and* its `check_*` conditions with your own game's. ## Common issues | Issue | Fix | |-------|-----| | "API key not set" / "Game ID not set" | Call `set_api_key(...)` and `set_game_id(...)` in `_ready()` before any other call (the SDK ships with empty defaults) | | "Not authenticated" | Submit ran before login. `await wait_until_ready()` then `login_anonymous()` **before** any `submit_score()` | | `await` won't parse | You're on Godot 3.6 — use `yield(CheddaBoards, "sdk_ready")`. See the [Godot 3.6 guide](https://docs.cheddaboards.com/engines/godot-3) | | 4-arg `profile_loaded` errors | `play_count` is now the 5th arg — add a trailing `play_count: int` | | Score rejected as too fast / impossible | Start a play session before the run so the backend can time-validate it | | Leaderboard fires twice / duplicates | You connected `leaderboard_loaded` inside a function — connect it once in `_ready()` | | Leaderboard empty | Verify `game_id` matches the one in your dashboard | | "CheddaBoards not found" | Enable the plugin (Asset Store install), add it to Autoloads, or run the Setup Wizard | | Blank screen (web) | Serve it — `python3 -m http.server`, then the `localhost` URL, not `file://` | | "Engine not defined" (web) | The web export must be named `index.html`, not `MyGame.html` | Full list: [Errors](https://docs.cheddaboards.com/api/errors). **See also:** [Godot 4 guide](https://docs.cheddaboards.com/engines/godot-4) (full template walkthrough) · [Signals reference](https://docs.cheddaboards.com/engines/godot-signals) · [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat) · [REST API](https://docs.cheddaboards.com/quickstart/rest) --- # Unity quick start **Add leaderboards to a Unity game.** One C# file, no dependencies, works on every platform Unity builds to — desktop, mobile, WebGL, console, VR. ## Before you start - **Unity 2022.3 LTS or newer** — the SDK is pure `UnityWebRequest`, no packages. - **A CheddaBoards game** — register at [cheddaboards.com](https://cheddaboards.com/developers) for a Game ID and API key. Want it all at once? Skip to [the one-file example](#the-whole-thing-in-one-file). ## Step 1 — Add the SDK Copy `CheddaBoards.cs` from the [Unity SDK repo](https://github.com/cheddatech/cheddaboards-unity) into your project, e.g. `Assets/Scripts/CheddaBoards.cs`. That's the whole install — the SDK auto-creates its own singleton `GameObject` with `DontDestroyOnLoad`, so there's no scene setup. Prefer to start from a working example? The repo's [`Demo/`](https://github.com/cheddatech/cheddaboards-unity/tree/main/Demo) folder contains **CheddaClick**, a complete one-script game showing login, guest flow, play sessions, score submit, leaderboard render, and delta-synced achievements. ## Step 2 — Configure and log in ```csharp using UnityEngine; using System.Collections.Generic; // for Dictionary when reading boards (Step 4) using CheddaTech; // the SDK lives in the CheddaTech namespace public class Leaderboards : MonoBehaviour { void Start() { var cb = CheddaBoards.Instance; // auto-creates the singleton cb.SetApiKey("cb_my-game_xxxxxxxxx"); cb.SetGameId("my-game"); cb.OnLoginSuccess += (nickname) => Debug.Log($"Welcome {(string.IsNullOrEmpty(nickname) ? "Guest" : nickname)}!"); cb.OnScoreSubmitted += (score, streak) => Debug.Log($"Saved: {score}"); cb.LoginAnonymous(); // no name — see below } } ``` `LoginAnonymous` gets the player onto the board instantly with a persistent device ID — no account needed. It completes immediately: `OnLoginSuccess` fires during the call, so subscribe to events *before* calling it, as above. If no API key has been set, it fires `OnLoginFailed` instead. A brand-new player logged in without a name receives an empty string, so show "Guest" until they have one. Log in **without** a name: returning players keep the nickname they already saved, and brand-new players get a server-assigned name (`Player_1248`) when their first submit creates the profile. `GetNickname()` returns `""` until a profile fetch or rename has told the SDK the name — fetch the profile after the first submit if you want to display or highlight it. Only pass a name to `LoginAnonymous` when the player has just chosen it, because a passed name becomes the current nickname and is written to the server on the next submit — overwriting whatever they had. To let players pick or change their name, use `ChangeNickname()` (see [Nicknames](#nicknames)); a complete name-entry panel is on [Player names](https://docs.cheddaboards.com/concepts/player-names). ## Step 3 — Submit a score Call this from your own game-over logic, with the run's score and streak: ```csharp void OnGameOver(int score, int streak) { CheddaBoards.Instance.SubmitScore(score, streak); } ``` `SubmitScore` fans out to every standard board on your game (all-time, weekly, daily). Only the player's best survives on each board. ## Step 4 — Read the leaderboard ```csharp var cb = CheddaBoards.Instance; cb.OnScoreboardLoaded += (id, config, entries) => { foreach (Dictionary entry in entries) Debug.Log($"#{entry["rank"]} {entry["nickname"]}: {entry["score"]}"); }; cb.GetAlltimeLeaderboard(); // or GetWeeklyLeaderboard(), GetDailyLeaderboard() cb.GetScoreboard("weekly", 100); // or any board by ID ``` Board reads come straight from the CheddaBoards canister for speed, with an automatic fallback to the proxy if the direct path can't get through — you don't have to do anything to get either. ## Step 5 — Anti-cheat play sessions (recommended) Wrap each run in a play session so the backend can validate the score against elapsed time. Start when gameplay begins, end after submitting — the SDK attaches the active session token to your submit automatically. ```csharp void StartRun() { CheddaBoards.Instance.StartPlaySession(); // …your game-start code… } void OnGameOver(int score, int streak) { var cb = CheddaBoards.Instance; cb.SubmitScore(score, streak); // session token attached automatically cb.EndPlaySession(); } ``` `StartPlaySession()` is asynchronous: the token arrives a moment later via `OnPlaySessionStarted` (or `OnPlaySessionError` if it fails). A submit sent before then goes without a token. That's only a risk for very short runs, but if your game can end within a second or two of starting, check `CheddaBoards.Instance.HasPlaySession()` before submitting, or wait for `OnPlaySessionStarted` before letting the run begin. Set the actual limits (score caps, time validation) from your dashboard's Security tab — see [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat). Without a session, scores still submit — unless the game has time validation enabled, in which case the session token is **required** and sessionless submits are rejected. ## The whole thing in one file Everything from Steps 2–5 in a single `MonoBehaviour`. Drop it on any GameObject, fill in your key and Game ID, and wire `StartRun()` / `GameOver()` to your own game logic. ```csharp using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using CheddaTech; // the SDK namespace (CheddaBoards.cs) /// One-file CheddaBoards integration: login, play session, submit, leaderboard. public class CheddaBoardsExample : MonoBehaviour { [Header("From cheddaboards.com/developers")] public string apiKey = "cb_my-game_xxxxxxxxx"; public string gameId = "my-game"; [Header("Optional: any UI Text to render the board into")] public Text leaderboardText; CheddaBoards cb; void Start() { cb = CheddaBoards.Instance; // auto-creates the singleton (DontDestroyOnLoad) cb.SetApiKey(apiKey); cb.SetGameId(gameId); // Subscribe BEFORE LoginAnonymous(): OnLoginSuccess fires during the call. cb.OnLoginSuccess += nick => { Debug.Log($"Logged in as {(nick == "" ? "Guest" : nick)}"); cb.GetAlltimeLeaderboard(); }; cb.OnLoginFailed += err => Debug.LogError($"Login failed: {err}"); // usually a missing API key cb.OnPlaySessionStarted += tok => Debug.Log("Play session ready"); cb.OnScoreSubmitted += (score, streak) => { Debug.Log($"Saved {score}"); cb.GetAlltimeLeaderboard(); }; cb.OnScoreError += err => Debug.LogWarning($"Score rejected: {err}"); cb.OnScoreboardLoaded += RenderBoard; cb.OnScoreboardError += err => Debug.LogWarning($"Board error: {err}"); cb.LoginAnonymous(); // guest login on a persistent device ID, no account needed } /// Call when a run begins. Starts an anti-cheat play session (token arrives via OnPlaySessionStarted). public void StartRun() { cb.StartPlaySession(); } /// Call when a run ends. Submits to all-time, weekly and daily boards; only the player's best is kept. public void GameOver(int score, int streak = 0) { if (!cb.HasPlaySession()) Debug.LogWarning("Submitting without a play session (started too fast?)"); cb.SubmitScore(score, streak); // play-session token is attached automatically cb.EndPlaySession(); } /// Fires for every board read; entries are dictionaries with rank / nickname / score / streak. void RenderBoard(string boardId, Dictionary config, List entries) { var lines = new List { $"== {boardId} ==" }; foreach (Dictionary e in entries) lines.Add($"#{e["rank"]} {e["nickname"]} {e["score"]}"); string text = string.Join("\n", lines); if (leaderboardText != null) leaderboardText.text = text; Debug.Log(text); } } ``` That's the complete integration. `SubmitScore` fans out to the three default boards; swap in `SubmitScoreToBoard("level-14", score, streak)` for per-level boards, and `GetWeeklyLeaderboard()` / `GetDailyLeaderboard()` / `GetScoreboard("any-id")` for other reads — they all arrive through the same `OnScoreboardLoaded` event. ## Signing in with Google / Apple (optional) Device Code Auth — the player authorises on their phone, no in-game browser popups, works on every platform: ```csharp var cb = CheddaBoards.Instance; cb.OnDeviceCodeReceived += (code, url, qrDataUrl) => { // Show the code + URL, or render qrDataUrl (a base64 PNG) as a scannable QR. codeLabel.text = $"Go to {url}\nEnter code: {code}"; }; cb.OnDeviceCodeApproved += (nickname) => Debug.Log($"Signed in as {nickname}"); cb.LoginWithDeviceCode(); ``` Players sign in **once** — the session persists across restarts. If the server later rejects a stored session, `OnSessionExpired` fires (and `OnLogoutSuccess` with it, so a menu that handles logout falls back to its sign-in screen). Full flow: [Authentication](https://docs.cheddaboards.com/api/authentication). Two things the SDK (2.3.0+) does for you here. A pending code **survives an app restart or WebGL reload**: it's saved to `PlayerPrefs`, polling resumes on the same code, and a `LoginWithDeviceCode()` call on your login screen re-emits that code instead of minting a new one (pass `true` to force a fresh one; `HasPendingDeviceCode()`, `GetDeviceVerificationUrl()` and `GetDeviceCodeSecondsRemaining()` let you redraw a restored code with its real time left). And **closing the code popup is not cancelling**: hide the UI and leave polling running, and `OnDeviceCodeApproved` still fires when the player finishes on their phone. Only call `CancelDeviceCode()` on an explicit "Cancel", since an approval given after that call is never picked up. If the player was anonymous, `OnAccountUpgraded` (or `OnAccountUpgradeFailed`) follows the approval once their progress has merged — see [Device code login](https://docs.cheddaboards.com/concepts/device-code). ## Nicknames Nicknames are **3–16 characters, letters, digits, and underscores**. A taken name is auto-suffixed (`Chedz` → `Chedz_1`) rather than rejected; only genuinely invalid names raise `OnNicknameError`, and that's permanent for that value — ask for a different one. Before a brand-new player's first score, `ChangeNickname()` holds the name locally and sends it with that submit; redraw from `GetNickname()` on `OnProfileLoaded` to pick up what the server stored. Full model and a drop-in name-entry panel: [Player names](https://docs.cheddaboards.com/concepts/player-names). ```csharp cb.OnNicknameChanged += (newNick) => Debug.Log($"Now: {newNick}"); cb.ChangeNickname("NewName"); ``` ## Category & timed boards Submit to one specific board (per-level, per-mode) with `SubmitScoreToBoard`, and run daily/weekly/monthly competitions with automatic archiving. Both work the same as elsewhere — the concepts are engine-agnostic: ```csharp cb.SubmitScoreToBoard("level-14", score, streak); // targeted board only, no fan-out cb.GetLastWeekScoreboard(); // read an archived period ``` See [Category boards](https://docs.cheddaboards.com/concepts/category-boards) and [Timed leaderboards](https://docs.cheddaboards.com/concepts/timed-leaderboards). ## Events reference The events you'll connect to most: | Event | Parameters | |-------|-----------| | `OnSdkReady` | — | | `OnLoginSuccess` / `OnLoginFailed` | `nickname` / `error` | | `OnLogoutSuccess` | — | | `OnSessionExpired` | — (stored session rejected; `OnLogoutSuccess` also fires) | | `OnScoreSubmitted` | `score, streak` | | `OnScoreSubmittedToBoard` | `boardId, score, streak` | | `OnScoreError` | `error` | | `OnScoreboardLoaded` / `OnScoreboardError` | `id, config, entries` / `error` | | `OnScoreboardRankLoaded` | `id, rank, score, streak, total` | | `OnAchievementUnlocked` / `OnAchievementsLoaded` | `achievementId` / `achievements` | | `OnPlaySessionStarted` / `OnPlaySessionError` | `token` / `error` | | `OnDeviceCodeReceived` | `code, url, qrDataUrl` | | `OnDeviceCodeApproved` / `OnDeviceCodeExpired` | `nickname` / — | | `OnDeviceCodeError` | `error` | | `OnAccountUpgraded` / `OnAccountUpgradeFailed` | `profile, migration` (`migratedGames` / `migratedScoreboards`) / `error` | | `OnProfileLoaded` / `OnNoProfile` | `nickname, score, streak, achievements, playCount` / — (brand-new player, no profile yet) | | `OnNicknameChanged` / `OnNicknameError` | `nickname` / `error` | | `OnArchivedScoreboardLoaded` | `archiveId, config, entries` | Full method and event reference lives in the [SDK repo README](https://github.com/cheddatech/cheddaboards-unity). ## Common issues | Issue | Fix | |-------|-----| | "Not authenticated" on submit | `LoginAnonymous()` wasn't called, or failed because no API key was set (check `OnLoginFailed`). With device code, wait for `OnDeviceCodeApproved` before submitting | | Leaderboard fires twice | You subscribed to `OnScoreboardLoaded` inside a method that runs repeatedly — subscribe once, in `Start()` | | Empty leaderboard | Confirm your Game ID matches the dashboard, and that the board ID exists. `OnScoreboardError` reports the reason | | Score rejected | Start a play session before the run so the backend can time-validate it, and make sure it had started (`OnPlaySessionStarted`) before the submit. Check `OnScoreError` for the reason | | WebGL build can't reach CheddaBoards | If the page hosting your build sets a Content-Security-Policy, add `https://api.cheddaboards.com` and `https://fdvph-sqaaa-aaaap-qqc4a-cai.raw.icp0.io` (direct board reads) to `connect-src` | Full error reference: [Errors](https://docs.cheddaboards.com/api/errors). **See also:** [REST API](https://docs.cheddaboards.com/quickstart/rest) · [Authentication](https://docs.cheddaboards.com/api/authentication) · [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat) · [What's stored](https://docs.cheddaboards.com/concepts/data-model) · [Unity SDK repo](https://github.com/cheddatech/cheddaboards-unity) --- # Going to production A pre-release pass for games shipping with CheddaBoards. Everything on this list has bitten a real integration at least once. ## Keys and identity - **Real API key in the build.** Templates and examples ship with a placeholder — confirm your build carries your actual `cb_...` key. - **Game ID matches the dashboard exactly.** A mismatch usually shows up as an empty leaderboard, not an error. - **Debug logging off.** Turn off the SDK's debug output before shipping. - **Login call takes no name.** `login_anonymous()` / `LoginAnonymous()` with no argument; a name passed there is written to the server on the next submit and renames returning players. See [Player names](https://docs.cheddaboards.com/concepts/player-names). ## Anti-cheat - **Caps set from real data.** Per-round max just above your best legitimate run, all-time ceiling above anything a real player could accumulate. See [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat). - **Play sessions wired.** If time validation is on, the session token is required — and wiring the start → submit → end lifecycle costs nothing while it's off. The SDKs attach the token for you, but you start and end the session yourself (on the Godot template, the game wrapper does it). - **Watch the suspicion log for the first week.** Start loose, see where real submissions cluster, then tighten. ## Sessions and accounts - **Session persistence tested.** Sign in, fully restart the game, confirm the player is still signed in. The SDKs persist sessions automatically; REST integrations store the `sessionId` themselves — see [Authentication](https://docs.cheddaboards.com/api/authentication). - **`401`/`403` handled as sign-out.** A dead session means discard the stored token and fall back to sign-in — never retry with the same token. - **Account linking tested on a clean install.** Play anonymously, then link — anonymous progress should merge into the account, not vanish. ## Boards and traffic - **Every board ID your code references exists on the dashboard.** - **Submit frequency respects the throttle** — one submit per player per board every 2 seconds. If your game submits per event, remember the gate is keyed per board. - **Leaderboard reads sized sanely.** Fetch what you display, and prefer refreshing on submit and on screen-open over a fast polling timer. ## Privacy - **CheddaBoards named in your privacy policy.** Most stores require one, and the honest disclosure is short — [Privacy](https://docs.cheddaboards.com/concepts/privacy) has ready-made wording. ## Web / HTML5 - **Web export tested where it will actually live.** Run it inside the itch.io (or portal) iframe on a real phone — exit behaviour, touch scrolling and safe areas are covered in [Web export](https://docs.cheddaboards.com/engines/web-export). Then, before you hit publish: one full run on a completely wiped install — fresh anonymous player → play → submit → sign in → confirm the merge. ## After launch - **Bookmark the [status page](https://status.cheddatech.com).** If players report missing scores, check it before you start debugging your own code. It tracks the API and the on-chain leaderboards separately, so you can tell at a glance whether submits or reads are affected. --- # Game jams **A global leaderboard for your jam game, in the time it takes your coffee to brew.** Free, no player accounts, works in web builds on itch.io — and the board keeps running long after the jam ends. ## Why bother, mid-jam? Because during the rating period, a leaderboard is a retention machine. Raters play a jam game once, rate it, move on — unless there's a score to beat. A visible global board turns "played it" into "played it four times trying to knock #1 off", and players who replay leave better ratings and comments. It's the cheapest engagement feature you can ship in a jam. The parts that matter for jam conditions: - **Players need no account.** Anonymous login is a persistent device ID — raters land on the board on their first run, zero sign-up friction. (Nobody creates an account to rate a jam game. They don't have to.) - **Web builds work.** Board reads are CORS-simple, so an itch.io-embedded HTML5 export reads leaderboards with no proxy or server of yours. See [Web export](https://docs.cheddaboards.com/engines/web-export). - **It's free.** No card, no tier to pick mid-jam. Register a game, get an API key, go. - **Daily and weekly boards are automatic.** Every game gets all-time, weekly, and daily boards with automatic archiving — a "today's best" board resets itself while you sleep. ## The 3-minute path, by engine ### Godot 4 — the drop-in Install the [CheddaBoards addon](https://store.godotengine.org/asset/cheddatech/cheddaboards) from the Godot Asset Store (enable the plugin; the autoload registers itself), then: ```gdscript func _ready(): CheddaBoards.set_api_key("cb_my-jam-game_xxxxxxxxx") CheddaBoards.set_game_id("my-jam-game") CheddaBoards.leaderboard_loaded.connect(_on_leaderboard) await CheddaBoards.wait_until_ready() CheddaBoards.login_anonymous() # nameless — the server names new players (Player_1248) until they pick one func _on_game_over(score: int, streak: int): CheddaBoards.submit_score(score, streak) func show_leaderboard(): CheddaBoards.get_leaderboard("score", 100) func _on_leaderboard(entries: Array): for e in entries: print("#%d %s - %d" % [e.rank, e.nickname, e.score]) ``` That's the whole integration. Full walkthrough: [Godot quick start](https://docs.cheddaboards.com/quickstart/godot). Starting a game from nothing at hour zero? The [template](https://docs.cheddaboards.com/engines/godot-4) is a working project with menus, sign-in, and a leaderboard scene already wired — replace the example game with yours. ### Unity — one file Copy `CheddaBoards.cs` from the [Unity SDK repo](https://github.com/cheddatech/cheddaboards-unity) into your project — no packages, no scene setup: ```csharp var cb = CheddaBoards.Instance; cb.SetApiKey("cb_my-jam-game_xxxxxxxxx"); cb.SetGameId("my-jam-game"); cb.OnLoginSuccess += (nick) => canSubmit = true; cb.LoginAnonymous(); // nameless — see the Godot note above // at game over: CheddaBoards.Instance.SubmitScore(score, streak); ``` Full walkthrough (and a complete demo game to crib from): [Unity quick start](https://docs.cheddaboards.com/quickstart/unity). ### Anything else — two HTTP calls Bevy, Love2D, PICO-8 exports, hand-rolled JS — if it can POST JSON, it can have a leaderboard. Generate a persistent player ID client-side, then: ```bash # submit a score curl -X POST https://api.cheddaboards.com/scores \ -H "X-API-Key: cb_my-jam-game_xxxxxxxxx" \ -H "X-Game-ID: my-jam-game" \ -H "Content-Type: application/json" \ -d '{"playerId": "dev_1730000000_1a2b3c4d", "gameId": "my-jam-game", "score": 1500, "streak": 5}' # read the board curl "https://api.cheddaboards.com/leaderboard?sort=score&limit=10" \ -H "X-API-Key: cb_my-jam-game_xxxxxxxxx" \ -H "X-Game-ID: my-jam-game" ``` Generate `playerId` once per player (random, e.g. `dev__`), store it locally, reuse it — that's the whole identity model. Never hard-code one: every copy of your game would be the same player. Full surface: [REST quick start](https://docs.cheddaboards.com/quickstart/rest). ## Jam checklist - [ ] **Register the game before the jam starts** — [cheddaboards.com](https://cheddaboards.com/developers) takes a minute, but it's a minute you won't want at hour 47. (It's infrastructure, not gameplay — the same as making your itch page ahead of time, and fine under standard jam rules. If your jam is unusually strict about pre-work, check its rules page.) - [ ] Log in **nameless** (`login_anonymous()` with no argument) — players keep any name they set, and new players get a generated `Player_1248`-style name automatically - [ ] Submit only **after** login completes (from game-over code, not before `login_success` / `OnLoginSuccess`) - [ ] Web export: the file must be `index.html`, and test it served (`python3 -m http.server`), never from `file://` - [ ] Turn `debug_logging` off before you build - [ ] Put the leaderboard **on the game-over screen**, not behind a menu — raters should trip over it - [ ] Show the current **#1 score next to the player's** on that screen ("Best: 48,200 — beat it?") — that line is the replay trigger ## Jam-shaped details **Rate limits won't bite you.** Submits are throttled at one per player per board every 2 seconds — a normal game-over loop never notices. If you're doing something weirder (per-kill submits, presence heartbeats — [it's been done](https://docs.cheddaboards.com/concepts/category-boards)), targeted boards are throttled per board, so spreading writes is fine. **Rating-day traffic is nothing to plan for.** A front-page itch spike needs nothing from you — no quotas to raise, no config to change, no bill at the end. **Anti-cheat is optional and probably worth skipping at first.** Play sessions and time validation exist ([Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat)) and jam leaderboards do attract the occasional 999999999. For a 48-hour jam, ship without it and turn on score caps from the dashboard's Security tab if someone misbehaves — no code change needed for caps. Add play sessions if you keep the game alive after. **The board outlives the jam.** Scores live on the Internet Computer — the leaderboard keeps working after the rating period, after the jam page goes quiet, for as long as you care. If the jam game becomes a real game, everything carries over: same API key, same board, same players. ## Common jam-weekend issues | What you see | Fix | |--------------|-----| | "Not authenticated" on submit | Submit ran before login finished — submit from your game-over code | | Blank screen on itch.io | Export must be named `index.html`; test it served locally, not from `file://` | | Empty leaderboard | Game ID doesn't match the dashboard | | Score rejected | You enabled time validation but aren't starting play sessions — turn validation off, or start sessions | | Player's name reverted | You're passing a name to `login_anonymous()` every launch — log in nameless | Full list: [Errors](https://docs.cheddaboards.com/api/errors). **See also:** [Godot quick start](https://docs.cheddaboards.com/quickstart/godot) · [Unity quick start](https://docs.cheddaboards.com/quickstart/unity) · [REST quick start](https://docs.cheddaboards.com/quickstart/rest) · [Web export](https://docs.cheddaboards.com/engines/web-export) · [Timed leaderboards](https://docs.cheddaboards.com/concepts/timed-leaderboards) --- # API overview CheddaBoards is a plain HTTP/JSON API. Any engine or language that can make a request can use it — the official SDKs are convenience wrappers over these same endpoints. New here? Start with the [REST quick start](https://docs.cheddaboards.com/quickstart/rest) for a walkthrough. This section is the endpoint-by-endpoint reference. ## Base URL ``` https://api.cheddaboards.com ``` Browser-safe (CORS enabled), so HTML5 games can call it directly. Board *reads* are also served straight from the [Internet Computer canister](https://docs.cheddaboards.com/quickstart/rest#reading-boards-straight-from-the-chain) with no proxy in the path. ## Authentication Every request identifies the game; how it identifies the *player* depends on the call. | Header | When | |--------|------| | `X-Game-ID` | Always | | `X-API-Key` | Anonymous / API-key requests | | `X-Session-Token` | After a player signs in (send instead of the API key) | `X-API-Key` and `X-Session-Token` are mutually exclusive — full detail in [Authentication](https://docs.cheddaboards.com/api/authentication). ## Response shape Success: ```json {"ok":true,"data":{ ... }} ``` Failure: ```json {"ok":false,"error":""} ``` Always check `ok` before reading `data`. Status codes and error strings are in [Errors](https://docs.cheddaboards.com/api/errors). ## The endpoints | Area | Endpoints | Reference | |------|-----------|-----------| | Submit scores | `POST /scores` | [Scores](https://docs.cheddaboards.com/api/scores) | | Read boards | `GET /leaderboard`, `GET /games/{id}/scoreboards/...` | [Scoreboards](https://docs.cheddaboards.com/api/scoreboards) | | Players | `GET /players/{id}/profile`, `GET /players/{id}/rank`, board rank, nickname changes | [Players](https://docs.cheddaboards.com/api/players) | | Sign-in | `POST /auth/device/code`, `/auth/device/token`, `/migrate-account` | [Authentication](https://docs.cheddaboards.com/api/authentication) | | Play sessions | `POST /play-sessions/start`, `/end` | [Scores](https://docs.cheddaboards.com/api/scores#anti-cheat-time-validation) | | Achievements | `POST /achievements` | [Achievements](https://docs.cheddaboards.com/api/achievements) | | Moderation | admin & deletion routes (owner session) | [Moderation](https://docs.cheddaboards.com/concepts/moderation) | ## Conventions - All request and response bodies are JSON. - Game and scoreboard IDs in URL paths must be 1–64 characters of letters, digits, `_` or `-`; anything else is rejected with a `400` before reaching the backend. - Timestamps in responses are **nanoseconds** since epoch (ICP-standard) — divide by 1,000,000 for JavaScript milliseconds. - Rate limit: one score submit per player per board every 2 seconds. **See also:** [REST quick start](https://docs.cheddaboards.com/quickstart/rest) · [Scores](https://docs.cheddaboards.com/api/scores) · [Authentication](https://docs.cheddaboards.com/api/authentication) · [Errors](https://docs.cheddaboards.com/api/errors) --- # Authentication CheddaBoards supports three levels of identity, all cross-platform: **anonymous play**, **sign-in with Google or Apple** (Device Code, on any platform), and **account upgrade** (turn an anonymous player into a verified one without losing progress). No OAuth SDKs, no browser popups, no platform-specific branching — every platform uses the same flow. Players who never sign in still get full leaderboard participation. ## Anonymous identity There's no anonymous "login" call. An anonymous player is a **persistent ID you generate and store client-side** (the official SDKs use `dev__`). Send it as `playerId` with the API key; the first score submission creates the profile. A submit with no `nickname` field creates the profile with a **server-generated name** (`Player_1248`) — stable until the player picks their own; including `nickname` on a submit renames the player if that name is free (a taken name is silently ignored), so only send it when they've just chosen (see [Player names](https://docs.cheddaboards.com/concepts/player-names)). Anonymous progress is real progress — scores, streaks, plays, and achievements all live server-side and survive an upgrade to a verified account. ## Sign in with Google / Apple (Device Code) The game shows a short code, a URL, and a QR. The player signs in on their phone at [cheddaboards.com/link](https://cheddaboards.com/link); the game polls until approved. No OAuth configuration on your side. ``` ┌──────────────┐ ┌──────────────────────┐ │ Your Game │ │ Player's Phone │ │ │ │ │ │ "Scan QR or │ │ cheddaboards.com/ │ │ go to │ │ link │ │ cheddaboards │ │ │ .com/link" │ │ Enter: CHEDDA-7K3M │ │ │ │ [Google] [Apple] │ │ "Enter code:│ │ │ │ CHEDDA-7K3M"│ polls every 5s │ ✅ Signed in! │ │ ✅ Signed in!│◄──────────────────│ │ └──────────────┘ └──────────────────────┘ ``` **1. Request a code:** ```bash curl -X POST https://api.cheddaboards.com/auth/device/code \ -H "Content-Type: application/json" \ -H "X-Game-ID: my-game" \ -d '{"gameId": "my-game", "nickname": "PlayerName"}' ``` Returns the `device_code` (yours, for polling), a short `user_code` (the player's, to type in), the verification URL, and a QR data URL — a base64 PNG encoding the verification URL with the code pre-filled, so the player scans once and taps a single button instead of typing. Show the user code and QR. `nickname` is optional: if the sign-in ends up **creating** a brand-new account, that name seeds it (3–16 chars, letters/digits/underscores). Existing accounts always keep their name — a sign-in never renames anyone. **2. Poll for approval** (every 5 seconds): ```bash curl -X POST https://api.cheddaboards.com/auth/device/token \ -H "Content-Type: application/json" \ -H "X-Game-ID: my-game" \ -d '{"device_code": ""}' ``` The state machine: | Response | State | Your move | |----------|-------|-----------| | `428` `authorization_pending` | Player hasn't finished on their phone | Keep polling every 5s | | `200` with `sessionId` | Approved | Store the session, switch to `X-Session-Token` | | Code expired (after **5 minutes**) | Player took too long | Request a fresh code | On approval, the `200` payload includes `sessionId`, `nickname`, `email`, and the player's `gameProfile`. From here on, send `X-Session-Token: ` instead of `X-API-Key`. **A polish worth copying from the official SDKs:** when your app regains focus (the player switching back from their phone or another tab), fire an immediate poll instead of waiting for the next 5-second tick — sign-in then completes the instant they return. ## Sessions Sessions last **30 days and renew on use**, so an active player stays signed in indefinitely. Persist the `sessionId` client-side and restore it on startup — players should go through device code **once**, not every visit. Any `401`/`403` on a session-authenticated request means the session is dead (expired, logged out elsewhere, or the account was removed). Treat it as an automatic sign-out: discard the stored token, fall back to anonymous or your sign-in screen, don't retry with the same token. See [Errors](https://docs.cheddaboards.com/api/errors#session-expiry). ## Upgrading anonymous → verified (account linking) An anonymous player signs in via the same device code flow, and their anonymous progress migrates into the verified account. Everything is preserved: scores, streaks, achievements, play history. **Merging is safe across devices.** If a player has been anonymous on two devices and links both to the same Google/Apple account, the second link **merges** rather than erroring: best score and best streak are kept per field, achievements are combined and deduplicated, and play counts add together. The message for your players: *link on every device and your best progress carries over.* **Linking is one-way.** Migration absorbs the anonymous account and deletes it — there's nothing to unlink back to. When testing your own flow, use throwaway Google accounts and generate a fresh anonymous `playerId` each run. **"Anonymous account not found" is harmless.** If a player links before ever submitting a score, there's no server-side anonymous profile to migrate, and the migration step reports that reason. Safe to ignore in your UX. **Nicknames carry over.** When linking *creates* the account, it's born with the player's in-game name (seeded via the device-code request). If the anonymous profile still holds that name at creation time, the new account briefly gets a suffixed one (`Name_1`) and reclaims the exact name after the merge — so re-read the profile after migration completes rather than caching the name from the approval response. **How it works over REST.** Sign the player in first (device code) so you hold their session, then call `/migrate-account` with the session token in the header and the anonymous device ID in the body: ```bash curl -X POST https://api.cheddaboards.com/migrate-account \ -H "Content-Type: application/json" \ -H "X-Game-ID: my-game" \ -H "X-Session-Token: " \ -d '{"deviceId": "dev_1730000000_1a2b3c4d"}' ``` That's the anonymous `playerId` you'd been submitting under. On success the response reports what moved: ```json {"ok":true,"data":{"migratedGames":1,"migratedScoreboards":3}} ``` The merge is safe to retry — if it fails mid-way the call returns a `500` and you can call it again. ## Which method when? | You want | Use | |----------|-----| | Zero-friction play, no accounts | Anonymous `playerId` + API key | | Cross-device progress | Device Code sign-in | | Keep an existing player's progress when they sign up | Nothing special — linking handles it | **See also:** [REST quick start](https://docs.cheddaboards.com/quickstart/rest) · [Errors](https://docs.cheddaboards.com/api/errors) · [Players and accounts](https://docs.cheddaboards.com/concepts/accounts) · [Device code login](https://docs.cheddaboards.com/concepts/device-code) · Godot SDK: [signals reference](https://docs.cheddaboards.com/engines/godot-signals) --- # Scores `POST /scores` submits a score. One endpoint covers both a normal (fan-out) submit and a submit to a single targeted board — the difference is one field. This is the reference for the endpoint. For a walkthrough, see the [REST quick start](https://docs.cheddaboards.com/quickstart/rest#_1-submit-a-score). ## Request ``` POST https://api.cheddaboards.com/scores ``` | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `X-Game-ID` | your Game ID | | `X-API-Key` | your API key (anonymous / API-key submit) | | `X-Session-Token` | the player's session (signed-in submit — send this *instead of* the API key) | ### Body | Field | Type | Required | Notes | |-------|------|----------|-------| | `playerId` | string | yes | The player's persistent ID (`dev__` for anonymous) | | `gameId` | string | yes | Your Game ID (also sent as the header) | | `score` | number | yes | The run's score | | `streak` | number | yes | The run's streak (send `0` if your game has no streak concept) | | `nickname` | string | no | **Presence is meaning**: included → renames the player; omitted → stored name untouched. See [nicknames](#nicknames) | | `playSessionToken` | string | no | From `/play-sessions/start`; required only if the game has time validation on | | `scoreboardId` | string | no | Present → targeted submit to that one board; absent → fan-out. See [targeted submits](#targeted-submits) | ## Response Success: ```json {"ok":true,"data":{"message":"🎉 New high score and streak! Score: 1234, Streak: 3"}} ``` Check the `ok` field for success — `message` is human-readable feedback for the player (it varies: "New high score", "Score submitted", etc.), not a structured value to parse. On failure: ```json {"ok":false,"error":""} ``` ## Fan-out submits (the default) A submit with **no `scoreboardId`** fans out: the score is recorded against the player's profile and applied to every one of the game's standard time-based boards — all-time, weekly, daily, and any custom fan-out boards. One call, every standard board updated. This is what you want for a normal "player finished a run" submit. Only the player's **best** survives on each board — submitting a lower score never replaces a higher one, and score and streak are kept as independent maxima. See [what's stored](https://docs.cheddaboards.com/concepts/data-model#the-leaderboard-entry). ## Targeted submits Add a `scoreboardId` and the submit goes to **that one board only** — no fan-out. This is how you run per-level or per-category leaderboards: ```json { "playerId": "dev_1730000000_1a2b3c4d", "gameId": "my-game", "score": 1000, "streak": 5, "scoreboardId": "level-14" } ``` Behavior specific to targeted submits: - **One board, no fan-out.** Writes only to the named board. - **Counts plays, not aggregate bests.** A targeted submit increments the player's play count but doesn't move their aggregate profile score/streak — send a plain submit as well if you want both. - **Per-board throttle.** The 2-second rate limit is keyed per board, so chaining several targeted submits in one run (e.g. a level board plus a shared `runs` board) is fine. - **The board must exist and be targeted.** Submitting a `scoreboardId` for a board that doesn't exist returns `"Scoreboard '' not found for this game."` — the API never auto-creates a board. Create it in the console first (Board Type → Targeted). - **Never send a fan-out board's ID.** `all-time`, `weekly`, `daily` and custom fan-out boards are updated by a plain submit; naming one in `scoreboardId` is rejected. Omit the field instead. Full treatment: [Category boards](https://docs.cheddaboards.com/concepts/category-boards). ## Nicknames `nickname` on a submit is optional, and **presence is meaning**: a submit that includes it renames the player to that value, while a submit that omits it leaves the stored name untouched. So don't send it on every submit — send it only when the player has just chosen a name. The one place it's the natural tool: a brand-new player's **first** submit, which creates their profile — include `nickname` there and the profile is born named; omit it and the profile is created with a server-generated name (`Player_1248`) that stays until renamed via the [nickname endpoints](https://docs.cheddaboards.com/api/players#change-a-nickname). When present, it's applied subject to the rule **3–16 characters, letters, digits, and underscores** (`A–Z a–z 0–9 _`). A **taken** name isn't an error — it's auto-suffixed (`Chedz` → `Chedz_1`). A genuinely **invalid** name is rejected and the player keeps their existing name. See [Errors → nickname](https://docs.cheddaboards.com/api/errors#nickname-rejected). ## Anti-cheat & time validation If the game has time validation enabled, include a `playSessionToken` from [`/play-sessions/start`](https://docs.cheddaboards.com/quickstart/rest#_4-anti-cheat-play-sessions-recommended) so the backend can check the score against elapsed play time. Without validation enabled, the token is accepted but not checked. A score that trips a cap or time check is rejected with a generic `"rejected by game validation rules"` — the specific reason goes to your dashboard's suspicion log, not the client, so cheaters can't probe your limits. See [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat). ## Rate limit **One submit per player per board every 2 seconds**, enforced server-side. It's always on and not configurable — it only blocks bot-speed submission, never legitimate play. Because it's keyed per board, back-to-back submits to *different* boards don't trip it. ## Retry safety Submits are safe to retry. The backend keeps per-player bests, so resending a score after a timeout can never lower a score or streak — no client-side dedupe needed. Play counts are also protected against quick duplicates: repeat submits from the same player within a few seconds count as one play, so an immediate retry is fully safe. Only well-spaced duplicates move the play count — avoid long-running blind retry *loops* if play counts matter to you. **See also:** [REST quick start](https://docs.cheddaboards.com/quickstart/rest) · [Scoreboards](https://docs.cheddaboards.com/api/scoreboards) · [Category boards](https://docs.cheddaboards.com/concepts/category-boards) · [Errors](https://docs.cheddaboards.com/api/errors) · [What's stored](https://docs.cheddaboards.com/concepts/data-model) --- # Scoreboards Reading boards. Every read is a `GET`, board data is public (no player auth needed beyond your game credentials), and responses are edge-cached for ~30 seconds. ## The global leaderboard ``` GET /leaderboard?sort={score|streak}&limit={n} ``` ```bash curl "https://api.cheddaboards.com/leaderboard?sort=score&limit=100" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" ``` ```json { "ok": true, "data": { "leaderboard": [ { "rank": 1, "nickname": "Chedz", "score": 5148898, "streak": 4, "authType": "external" }, { "rank": 2, "nickname": "Player_1504", "score": 151732, "streak": 7, "authType": "external" } ], "total": 2 } } ``` `sort` is `score` (default) or `streak`. Each entry carries `rank`, `nickname`, `score`, `streak`, and `authType` (how the player signed in — `external` for anonymous / API-key play). This reads the game's global fan-out board; for a specific board use the endpoint below. ## A specific board ``` GET /games/{gameId}/scoreboards/{scoreboardId}?limit={n} ``` Works for any board — all-time, a timed board, or a targeted category board — they read identically: ```bash curl "https://api.cheddaboards.com/games/my-game/scoreboards/weekly?limit=100" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" ``` ```json { "ok": true, "data": { "scoreboardId": "weekly", "config": { "name": "weekly", "description": "", "period": "weekly", "sortBy": "score", "lastReset": 1789948800000000000 }, "entries": [ { "rank": 1, "nickname": "Player_2519", "score": 500, "streak": 0, "authType": "external", "submittedAt": 1790157891387042954 } ], "totalEntries": 1 } } ``` `config` describes the board (`period` is its reset cadence, `lastReset` when the current period began), `entries` are ranked, and `totalEntries` is the board's full size, handy when you've asked for fewer with `limit`. Each entry adds `submittedAt` to the fields above: when that best was set, in nanoseconds. Field-by-field, including the archive variant: [Timed leaderboards → the config dictionary](https://docs.cheddaboards.com/concepts/timed-leaderboards#the-config-dictionary). ### Reading straight from the chain The same board read is served directly by the canister, no proxy involved and no API key needed: ```bash curl "https://fdvph-sqaaa-aaaap-qqc4a-cai.raw.icp0.io/games/my-game/scoreboards/weekly?limit=100" ``` Byte-identical JSON, browser-safe (`Access-Control-Allow-Origin: *`). Use it for read-only surfaces — kiosks, overlays, companion pages — that should keep working regardless of the API layer. See [the quick start](https://docs.cheddaboards.com/quickstart/rest#reading-boards-straight-from-the-chain). ## List a game's boards ``` GET /games/{gameId}/scoreboards ``` Returns every board configured for the game — fan-out and targeted, timed and all-time — with each board's config. Use it to discover board IDs rather than hard-coding them. ## Archives When a timed board resets, its final standings are archived. Read them back: | Endpoint | Returns | |----------|---------| | `GET /games/{gameId}/scoreboards/{id}/archives` | List of archived periods for a board (add `?after=&before=` nanosecond timestamps to filter a date range) | | `GET /games/{gameId}/scoreboards/{id}/archives/latest` | The most recent archive | | `GET /archives/{archiveId}` | One specific archive (`gameId:scoreboardId:timestamp`) | | `GET /games/{gameId}/archives/stats` | Archive statistics for the game | Full treatment of resets, retention, and archive display: [Timed leaderboards](https://docs.cheddaboards.com/concepts/timed-leaderboards). ## Caching & polling Board reads are edge-cached for about 30 seconds, so polling faster than that returns the same data. If you refresh a board on screen, a 30-second interval plus a refresh after the player's own submit is the pattern the official SDKs use — it keeps you well clear of any rate concern and off the proxy for reads that haven't changed. ## Notes - A `404` on a board lookup is normal — it means the board isn't configured for the game. - `limit` caps how many entries come back; omit it for the default. Through the API the maximum is **1,000**; the direct canister read isn't capped. - Board and game IDs in the path must be 1–64 chars of `[A-Za-z0-9_-]` or the request is rejected with a `400`. **See also:** [Scores](https://docs.cheddaboards.com/api/scores) · [Category boards](https://docs.cheddaboards.com/concepts/category-boards) · [Timed leaderboards](https://docs.cheddaboards.com/concepts/timed-leaderboards) · [REST quick start](https://docs.cheddaboards.com/quickstart/rest) --- # Players Reading a player's profile and rank, and changing nicknames. ## Get a player's profile ``` GET /players/{playerId}/profile ``` ```bash curl "https://api.cheddaboards.com/players/dev_1730000000_1a2b3c4d/profile" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" ``` ```json { "ok": true, "data": { "nickname": "PlayerName", "created": 1788570532131407400, "gameProfile": { "score": 1234, "streak": 3, "achievements": ["first_win", "combo_10"], "playCount": 2, "lastPlayed": 1788571122315540500 } } } ``` The `gameProfile` holds the player's bests and totals for this game: `score` and `streak` (independent maxima), `playCount`, and the `achievements` they've unlocked — so this one call also gives you their achievements, no separate request needed. `created` and `lastPlayed` are **nanosecond** timestamps (divide by 1,000,000 for JavaScript milliseconds). For a **signed-in** player, the equivalent is `GET /auth/profile` using the session token instead of the API key. ## Get a player's rank Two endpoints, depending on how the player is identified. **Any player, game-wide** (works with the API key, so anonymous players too): ``` GET /players/{playerId}/rank?sort={score|streak} ``` ```bash curl "https://api.cheddaboards.com/players/dev_1730000000_1a2b3c4d/rank?sort=score" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" ``` ```json {"ok":true,"data":{"rank":40,"score":1234,"streak":3,"totalPlayers":68}} ``` This is what the SDKs' `get_player_rank()` / `GetPlayerRank()` call. `sort` is `score` (default) or `streak`. **Signed-in player, on a specific board:** ``` GET /games/{gameId}/scoreboards/{scoreboardId}/rank ``` Returns the player's position on one board — all-time, a timed board, or a category board. This call is keyed on the player's session (send `X-Session-Token`), and it ranks against the actual board the player sees, so the rank and total match the visible leaderboard. Use it to show "you're #40 of 68" on a given board without pulling the whole thing. (SDK: `get_scoreboard_rank()` / `GetScoreboardRank()`.) ## Change a nickname Two endpoints, depending on how the player is identified: ``` PUT /players/{playerId}/nickname # anonymous — body: { "nickname": "..." } PUT /profile/nickname # signed-in — X-Session-Token, body: { "nickname": "..." } ``` The rule is the same on both: **3–16 characters, letters, digits, and underscores** (`A–Z a–z 0–9 _`). - A **taken** name isn't an error on these two endpoints — it's auto-suffixed (`Chedz` → `Chedz_1`) and the response reports the name actually applied. (A `nickname` carried on a *score submit* behaves differently: see [Player names](https://docs.cheddaboards.com/concepts/player-names).) - A genuinely **invalid** name is rejected with one of: `Nickname must be at least 3 characters`, `Nickname must be 16 characters or less`, `Nickname can only contain letters, numbers, and underscores`. That rejection is permanent for that value — ask for a different name rather than retrying. > **An anonymous player must exist server-side before a rename sticks** An anonymous player isn't created on the backend until their **first score submit** — before that there's no server record for the nickname endpoint to update. Two clean ways to name a brand-new player: include `nickname` in their **first score submit** (if that name is free the profile is created with it; if it's taken the profile is created as `Player_1248` instead, with no suffixing on this path), or let the server assign the generated name and offer a rename once the first score has landed. The official SDKs (Godot 2.2.7+, Unity 2.3.0+) hold a pre-profile rename locally, send it with the first submit, and if the profile that loads afterwards shows a different name (the chosen one was taken), re-send the rename so the player ends up with the suffixed version rather than `Player_N`. The full picture, with copy-paste name-entry flows, is on [Player names](https://docs.cheddaboards.com/concepts/player-names). ## Identity, briefly `playerId` is the player's private identifier — the `dev__` ID your game generates for anonymous players, or the account behind a signed-in one. It's never shown to other players; only the nickname is public. The conceptual picture is in [Players and accounts](https://docs.cheddaboards.com/concepts/accounts). **See also:** [Scores](https://docs.cheddaboards.com/api/scores) · [Authentication](https://docs.cheddaboards.com/api/authentication) · [What's stored](https://docs.cheddaboards.com/concepts/data-model) · [Errors](https://docs.cheddaboards.com/api/errors) --- # Achievements Achievements are stored server-side, work for anonymous and signed-in players alike, and sync independently of score submission — so a slow or failed achievement call never blocks a score from landing. Anonymous players' achievements are stored server-side too, against their anonymous profile — the only wrinkle is timing (see the tip below: the profile has to exist first). When an anonymous player [upgrades to a verified sign-in](https://docs.cheddaboards.com/api/authentication#upgrading-anonymous-verified-account-linking), their achievements merge into the account (combined and deduplicated) and follow them across devices from then on. The official SDKs additionally keep a local copy so unlocks earned before the first submit aren't lost — they ride along with it. > **Unlock after the first score submit** An anonymous player isn't created on the backend until their first score submit, so unlocks fired before that have no player to attach to. Unlock achievements once a score has landed for that player — or queue them during play and flush them (as a batch) right after the submit succeeds. ## Unlock achievements `POST /achievements` unlocks one or more achievements for a player. It takes either a single ID or an array — send the array whenever you're unlocking more than one at once, since a batch is a single call instead of one per achievement. **Single:** ```bash curl -X POST https://api.cheddaboards.com/achievements \ -H "Content-Type: application/json" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" \ -d '{ "playerId": "dev_1730000000_1a2b3c4d", "gameId": "my-game", "achievementId": "first_win" }' ``` **Batch** — pass `achievementIds` as an array: ```bash curl -X POST https://api.cheddaboards.com/achievements \ -H "Content-Type: application/json" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" \ -d '{ "playerId": "dev_1730000000_1a2b3c4d", "gameId": "my-game", "achievementIds": ["first_win", "combo_10", "level_5"] }' ``` Prefer the batch form for anything more than a single unlock. Under the hood a batch is one backend call; firing many single unlocks back-to-back is much slower and, in a long burst, can time out. Unlocking is idempotent — re-sending an achievement the player already has is harmless, so you don't need to track locally which ones have been sent. The response reports the batch outcome and a per-achievement breakdown: ```json { "ok": true, "data": { "message": "2/2 achievements unlocked", "unlocked": 2, "total": 2, "results": [ { "achievementId": "first_win", "success": true, "message": "unlocked" }, { "achievementId": "combo_10", "success": true, "message": "unlocked" } ] } } ``` For a signed-in player, send the session token instead of the API key: ```bash curl -X POST https://api.cheddaboards.com/achievements \ -H "Content-Type: application/json" \ -H "X-Session-Token: " \ -H "X-Game-ID: my-game" \ -d '{"gameId": "my-game", "achievementIds": ["first_win", "combo_10"]}' ``` ## Reading a player's achievements There's no separate achievements-read endpoint — a player's unlocked achievement IDs come back inside their `gameProfile`, on both the [profile endpoint](https://docs.cheddaboards.com/api/players) and every sign-in response: ```json { "ok": true, "data": { "nickname": "PlayerName", "gameProfile": { "score": 1234, "streak": 3, "achievements": ["first_win", "combo_10"], "playCount": 2 } } } ``` So fetching the profile is how you read achievements; you rarely need a dedicated call. ## Defining achievements Achievements are identified by a string ID you choose (`first_win`, `combo_10`, `level_5`). There's no separate "register the achievement" step on the API — you unlock IDs and the backend records them; the human-readable name and description live in your game, not the server. Keep the IDs stable once players start earning them, since the ID is what's stored. ## Sync model The design is **score-first**: your game submits the score immediately and syncs achievements separately, so achievement traffic never delays a score landing on the board. If an unlock call fails (network blip, transient `5xx`), it's safe to retry — because unlocking is idempotent, re-sending the whole set the player has earned this session is a clean way to recover, no per-ID bookkeeping needed. The official SDKs do the batching for you: `submit_score_with_achievements` / `SubmitScoreWithAchievements` submits the score, then sends the run's unlocks as one batch once it lands. (The Godot template's `Achievements` autoload goes further and re-sends anything that didn't sync.) On the REST path, gather the run's newly-earned IDs and send them as one `achievementIds` batch after the score submits. **See also:** [REST quick start](https://docs.cheddaboards.com/quickstart/rest) · [Players](https://docs.cheddaboards.com/api/players) · [Authentication](https://docs.cheddaboards.com/api/authentication) · Godot SDK: [signals reference](https://docs.cheddaboards.com/engines/godot-signals) --- # Errors Every response is JSON. Success is `{"ok":true,"data":{...}}`; every error is: ```json {"ok":false,"error":""} ``` Check `ok` before reading `data`, and surface `error` when it's false. ## Status codes at a glance | Status | Meaning | What to do | |--------|---------|------------| | `200` | Success | Read `data` | | `400` | Bad request — invalid input, rate limit, or validation failure | Read the error message; see below | | `401` / `403` | Session token is dead (expired, logged out, or account removed) | Discard the stored session and fall back to sign-in — see [Authentication](https://docs.cheddaboards.com/api/authentication) | | `404` | Route or resource not found | For scoreboard lookups this is normal — the board just isn't configured | | `428` | Device-code auth still pending | Keep polling (every 5s) | | `5xx` | Transient upstream problem | Retry with backoff — [submits are safe to retry](https://docs.cheddaboards.com/quickstart/rest#notes). Persistent? Check [status.cheddatech.com](https://status.cheddatech.com) | ## Common errors ### `Scoreboard '' not found for this game.` You submitted a score with a `scoreboardId` that doesn't exist on this game. Every game gets its standard time-based boards (all-time, weekly, daily) automatically — but **custom boards are never created by a submit**. Create the board first in the Developer Console (**Scoreboards** tab), and for targeted submits make sure its Board Type is **Targeted**. Retrying the same submit without creating the board will fail forever. ### `This game requires starting a session before submitting.` The game has **time validation** enabled, which makes the play-session token required — a submit without a valid `playSessionToken` is rejected before any score checks run. Start a session when the run begins (`POST /play-sessions/start`), pass its token in the submit body, and end the session after submitting (`POST /play-sessions/end`). The SDKs attach the token for you but don't start sessions on their own — call `start_play_session()` / `StartPlaySession()` when the run begins. (The Godot template's game wrapper is the one place the whole lifecycle runs automatically.) If you're seeing this from an SDK integration, the session start didn't happen before the submit. (The `startGameSession` the message mentions is the backend's internal name — on REST the call is `/play-sessions/start`.) See [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat). ### Submitting to a fan-out board by ID A `scoreboardId` on a submit means "this one targeted board only", so sending the ID of a **fan-out** board (`all-time`, `weekly`, `daily`, or a custom fan-out board) is rejected. To update those boards, **omit `scoreboardId`** — a plain submit already fans out to every one of them. See [Scores → targeted submits](https://docs.cheddaboards.com/api/scores#targeted-submits). ### `rejected by game validation rules` The score tripped one of the game's anti-cheat limits (score cap, streak cap, or time validation). The message is deliberately generic — the specific reason is logged to your dashboard's suspicion log, visible only to the game owner, so players can't probe your limits. If you're the developer and this surprises you, check the Security tab's caps and whether your submits carry a valid `playSessionToken`. ### Nickname rejected Nicknames must be **3–16 characters, letters, digits, and underscores only**. The nickname endpoints return a `400` with one of: - `Nickname must be at least 3 characters` - `Nickname must be 16 characters or less` - `Nickname can only contain letters, numbers, and underscores` A rejection is **permanent for that value** — don't retry the same nickname, ask the player for a different one. A nickname that's merely **taken** is not an error: the backend appends a numeric suffix automatically (`PlayerName` → `PlayerName_1`), and the response tells you the name that was actually applied. ### `authorization_pending` (HTTP 428) Not an error — the player just hasn't approved the device-code sign-in yet. Keep polling `/auth/device/token` every 5 seconds until you get `200` or the code expires. ### Rate limited One submit per player per board every 2 seconds, enforced server-side — the error tells you to wait. Back-to-back submits to *different* boards are fine (the throttle is keyed per board). If you hit this in normal play, you're submitting more often than you need to. ### Too many active play sessions Play sessions are capped per player (enforced by the backend). End sessions when runs finish (`POST /play-sessions/end`); when testing, use a fresh `playerId` rather than accumulating sessions on one. ### `Too many pending device authorizations. Try again shortly.` (HTTP 503) The device-code sign-in flow has a cap on outstanding unapproved codes. Transient — wait a moment and request a new code. ### OAuth / client ID errors Games don't configure OAuth — there's nothing to register, and no client ID or bundle ID field in the Developer Console. Player sign-in goes through the device-code flow: request a code, show the player the link URL, and they sign in with Google or Apple on the CheddaBoards link page using CheddaBoards' own credentials. See [Authentication](https://docs.cheddaboards.com/api/authentication). If you're seeing an error that mentions client IDs or bundle IDs, you're calling a legacy direct-OAuth path that isn't supported for games. Switch to the device-code flow instead. ### `Unknown endpoint: ` The route doesn't exist — check the [endpoint reference](https://docs.cheddaboards.com/quickstart/rest#endpoint-reference) for the exact path. The one people actually hit: there is **no standalone achievements read route** (`GET /players/{id}/achievements` doesn't exist). Achievements are only exposed on the profile — `GET /players/{playerId}/profile` returns them in `gameProfile.achievements`. SDK versions before 2.2.7 called the nonexistent route from `GetAchievements()` / `get_achievements()`; update the SDK or read achievements from the profile. ### Invalid path segment (HTTP 400) Game and scoreboard IDs in URL paths must be 1–64 characters of letters, digits, `_` or `-`. Anything else is rejected before reaching the backend. Usually this means a bug in how you build the URL — an unescaped string, an uninitialized buffer, or a stray null byte. ## Session expiry Sessions last 30 days and renew on use, so active players stay signed in indefinitely. Any `401`/`403` on a session-authenticated request means the session is dead: clear the stored token, fall back to your sign-in flow, and don't retry the request with the same token. The official SDKs treat this as an automatic sign-out. ## When it's not you First stop: **[status.cheddatech.com](https://status.cheddatech.com)**. It checks the API, the on-chain leaderboards, the website and these docs every few minutes from outside CheddaBoards' own infrastructure, and any outage shows there with its history. Occasionally the chain itself has a transient wobble (a subnet replica upgrade, for example) and you'll see a `5xx` for a few seconds. Submits are [safe to retry](https://docs.cheddaboards.com/quickstart/rest#notes), so a single retry with a short backoff covers it. If the API is unreachable entirely, board *reads* still work [directly from the canister](https://docs.cheddaboards.com/quickstart/rest#reading-boards-straight-from-the-chain). **See also:** [REST quick start](https://docs.cheddaboards.com/quickstart/rest) · [Authentication](https://docs.cheddaboards.com/api/authentication) · [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat) · [Service status](https://status.cheddatech.com) --- # Godot 4 The complete guide to building on the CheddaBoards template — from a first run to your own game on a live leaderboard. If you just want to add leaderboards to a game you already have, the [Godot quick start](https://docs.cheddaboards.com/quickstart/godot) is the shorter path; this page is for building *on* the template and its wrapper. ## What the template gives you The template is a working Godot 4 project. Out of the box it has a main menu, anonymous and Google/Apple sign-in, a leaderboard scene with all-time / weekly / daily tabs, achievements, and an example game (**CheddaClick**) already wired up. You replace the example game with your own and keep everything else. The key idea: a **wrapper** (`scenes/Game.tscn`) hosts your game. It loads your game scene as a child, draws the HUD, shows the game-over screen, and talks to CheddaBoards — login, submit, achievements, the anti-cheat session. Your side of the deal is emitting **one signal** when a run ends. That's the whole integration. > **New to Godot entirely?** The [zero-experience path](#zero-to-leaderboard) below walks the whole thing from installing Godot to a real score on a live board, in about 20 minutes, assuming nothing. ## Requirements - **Godot 4.6 or newer** — free from [godotengine.org](https://godotengine.org), a single download, no installer. (On Godot 3.6, see the [3.6 guide](https://docs.cheddaboards.com/engines/godot-3).) - **A CheddaBoards game** — register at [cheddaboards.com](https://cheddaboards.com/developers) for a Game ID and API key. - **The template** — from the [Godot Asset Store](https://store.godotengine.org/asset/cheddatech/cheddaboards-template) or [GitHub](https://github.com/cheddatech/cheddaboards-godot). ## Zero to leaderboard If you're new to Godot, this is the whole path. Comfortable already? Skip to [the game_over contract](#the-game-over-contract). ### A 60-second vocabulary - **Scene** — a reusable piece of your game: a screen, a whole minigame, an object. The template is built from several. - **Node** — a building block inside a scene: a button, an image, a timer. - **Autoload** — a script Godot keeps loaded all the time, reachable from anywhere by name. That's why any script can just call `CheddaBoards.something`. - **Signal** — a "this just happened" message a node sends out for other code to listen for. The template listens for your game's `game_over` signal. - **Inspector** — the panel showing the settings of whatever node you've clicked. - **F5** plays the *whole project* (starts at the menu). **F6** plays *only the scene you're editing*. This difference matters — see the login trap below. ### 1. Run the template Before changing anything, confirm it works. Download and unzip the template, open Godot, click **Import**, find the template's `project.godot`, and open it. Press **F5**. You should land on the CheddaBoards main menu — start a game as an anonymous guest, and CheddaClick loads. That's the whole thing running before you've touched code. ### 2. Connect your account with the Setup Wizard Right now scores have nowhere of *yours* to go. Register a game at [cheddaboards.com](https://cheddaboards.com/developers) and copy your **API key** (`cb_my-game_xxxxxxxxx`) — your Game ID is baked into it, so that's all you need. In Godot: **File → Run**, then choose `addons/cheddaboards/SetupWizard.gd`. Paste your API key when prompted. The wizard registers the three autoloads (`CheddaBoards`, `Achievements`, `MobileUI`) and writes your key and Game ID into `MainMenu.gd`. Check it worked: **Project → Project Settings → Autoload** should list `CheddaBoards`, `Achievements`, and `MobileUI`. ### 3. Prove the pipeline with a one-button "game" Build the smallest thing that scores and ends, just to watch a score travel from a click to the board. 1. **Scene → New Scene → User Interface** (a `Control` root). Save as `your_game/TestGame.tscn`. 2. Add a **Button** child. 3. Attach a script to the root, `TestGame.gd`: ```gdscript extends Control signal game_over(final_score: int, stats: Dictionary) func _ready(): $Button.pressed.connect(_on_button_pressed) func _on_button_pressed(): game_over.emit(500, {}) # pretend the player finished a run worth 500 ``` 4. Open `scenes/Game.tscn`, select the **Game** node, set **Game Scene Path** to `res://your_game/TestGame.tscn`. 5. Press **F5**, log in at the menu, and click the button in your test game. You should see the game-over screen with **Final Score: 500**, then "Saving score…" → "Score saved!", and your name and 500 on the Leaderboard. If so, the full pipeline works: **your game → wrapper → CheddaBoards → leaderboard.** > **The login trap** Run with **F5** (whole project), not **F6** (this scene alone). Login happens at the main menu — launch the `Game` scene by itself and you're never logged in, so you'll see "Offline — score not saved" instead of a saved score. ## The game_over contract Your game is its own scene. The wrapper loads it and waits for one required signal: ```gdscript signal game_over(final_score: int, stats: Dictionary) func _end_run(): game_over.emit(score, { "hits": hits, "misses": misses, "max_combo": max_combo, "level": level, "accuracy": accuracy, # 0–100 }) ``` When this fires, the wrapper shows the game-over screen, submits the score, checks achievements, and closes the anti-cheat session. **You don't call `submit_score` yourself.** ### Where each value goes This is the part that trips people up: the dict *looks* like it all gets saved, but a leaderboard entry is only ever **two numbers — score and streak.** Here's what the wrapper does with what you emit: | Value | Where it goes | |-------|---------------| | `final_score` (1st arg) | **Saved** as the player's **score** | | `max_combo` | **Saved** as the player's **streak** — and checked for combo achievements | | `hits` | Fed into the game-over achievement check; **not saved** | | `level` | Shown on the game-over screen (`Level: N`); **not saved** | | `accuracy` | Shown on the game-over screen (`Accuracy: N%`); **not saved** | | `misses` | Drives the live HUD via `stats_changed`; not read at game-over | | any other key | **Ignored** — the wrapper reads only the five above | Two takeaways: - **"Streak" is whatever you put in `max_combo`.** If your streak isn't a combo — days in a row, kills in a row, anything — put that number in `max_combo` and it ranks as the streak. (Or use the [drop-in path](https://docs.cheddaboards.com/quickstart/godot) and call `submit_score(score, your_streak)` directly.) - **Custom keys do nothing.** The score API has no free-form field, so a custom stat isn't saved unless you map it onto score/streak. See [what's stored](https://docs.cheddaboards.com/concepts/data-model). The game-over screen shows only the fields you send — omit `accuracy` and the Accuracy line simply doesn't appear (no fallback to `0%`). A game that tracks none of them gets a clean screen: title, final score, buttons. ## Feeding the built-in HUD (optional) Add any of these three signals; each panel appears **only if your scene declares its signal**, so unused panels stay hidden rather than showing empty: ```gdscript signal score_changed(score: int, combo: int) # live score + combo signal stats_changed(hits: int, misses: int, level: int) # two stat slots signal time_changed(time_remaining: float, max_time: float) # countdown timer ``` | Signal | HUD result | |--------|-----------| | `score_changed` | Updates **Score** and **Combo**, colours the combo by tier, runs live achievement checks | | `stats_changed` | Fills two slots, hard-labelled **Level** and **Misses** | | `time_changed` | Updates the **timer** (yellow ≤30s, red ≤10s) | The two stat slots are hard-labelled Level and Misses — if your game's concepts don't map onto those, pass your nearest equivalent or skip `stats_changed` and the panel doesn't render. Skipping `score_changed` doesn't cost you achievements; score/combo checks just run once at game-over instead of live. ## Pointing the wrapper at your scene Two ways: - **Inspector (recommended):** open `scenes/Game.tscn`, select the **Game** node, set **Game Scene Path** to `res://your_game/YourGame.tscn`. - **In code:** `@export var game_scene_path: String = "res://your_game/YourGame.tscn"`. > **Moved MainMenu or Leaderboard?** The game-over **Main Menu** and **Leaderboard** buttons default to `res://scenes/MainMenu.tscn` and `res://scenes/Leaderboard.tscn`. If your project keeps them elsewhere, set the **Main Menu Scene** and **Leaderboard Scene** export vars on the **Game** node, or those buttons fail silently. Stock layout works as-is. ## Play Again, titles, and cleanup (optional) **Play Again** reloads the scene by default. If your game can reset in place, add a `restart()` method and the wrapper calls that instead: ```gdscript func restart(): # reset state to the start of a run pass ``` **Game-over titles** come from score thresholds, both export vars on the **Game** node: ```gdscript @export var title_thresholds: Array[int] = [10000, 5000, 2500, 1000] @export var game_over_titles: Dictionary = { "amazing": "AMAZING!", "excellent": "Excellent!", "great": "Great Game!", "good": "Good Effort!", "default": "Game Over", } ``` A score ≥ the first threshold gets "amazing", on down; below the last gets "default". Once your game runs cleanly, you can delete `example_game/` — just make sure `game_scene_path` no longer points into it. Many people keep it as a reference. ## Complete minimal example A compilable game scene that satisfies the contract end to end. Drop it on a `Node2D`, wire your gameplay into `register_hit` / `register_miss`, and point the wrapper at it. ```gdscript extends Node2D ## Minimal game that works with the CheddaBoards template. ## Replace the body with real gameplay — keep the signals. signal game_over(final_score: int, stats: Dictionary) # required signal score_changed(score: int, combo: int) # optional HUD signal stats_changed(hits: int, misses: int, level: int) # optional HUD signal time_changed(time_remaining: float, max_time: float) # optional HUD var score := 0 var combo := 1 var max_combo := 1 var hits := 0 var misses := 0 var level := 1 var time_left := 60.0 const ROUND_LENGTH := 60.0 func _ready(): time_left = ROUND_LENGTH time_changed.emit(time_left, ROUND_LENGTH) score_changed.emit(score, combo) stats_changed.emit(hits, misses, level) func _process(delta): time_left -= delta time_changed.emit(time_left, ROUND_LENGTH) if time_left <= 0.0: _end_run() func register_hit(points: int): hits += 1 combo += 1 max_combo = max(max_combo, combo) score += points * combo score_changed.emit(score, combo) stats_changed.emit(hits, misses, level) func register_miss(): misses += 1 combo = 1 score_changed.emit(score, combo) stats_changed.emit(hits, misses, level) func _end_run(): set_process(false) var accuracy := 0 if hits + misses > 0: accuracy = int(round(100.0 * hits / float(hits + misses))) game_over.emit(score, { "hits": hits, "misses": misses, "max_combo": max_combo, "level": level, "accuracy": accuracy, }) func restart(): score = 0; combo = 1; max_combo = 1 hits = 0; misses = 0; level = 1 time_left = ROUND_LENGTH set_process(true) score_changed.emit(score, combo) stats_changed.emit(hits, misses, level) time_changed.emit(time_left, ROUND_LENGTH) ``` ## Checklist - [ ] Game built as its own scene (any root node type) - [ ] Emits `game_over(final_score, stats)` when a run ends - [ ] (Optional) emits `score_changed` / `stats_changed` / `time_changed` for the HUD - [ ] (Optional) has a `restart()` method for Play Again - [ ] `game_scene_path` points at your scene - [ ] Ran it with **F5**, logged in, and the score reached the leaderboard ## Troubleshooting | What you see | Fix | |--------------|-----| | Still see CheddaClick, not my game | Set **Game Scene Path** on the Game node in `scenes/Game.tscn` | | "Offline — score not saved" | Not logged in — run the whole project with **F5** and log in at the menu | | Game-over screen never appears | Your game never emits `game_over` — check the Output panel for `Game missing 'game_over' signal` | | Errors mentioning CheddaBoards / "not found" | Autoloads aren't registered — re-run the Setup Wizard or add them under Project Settings → Autoload | | Score saved but not on the board | Your Game ID doesn't match your dashboard | | Blank screen in a web build | Serve it: `python3 -m http.server`, then the `localhost` URL — not `file://` | Full list: [Errors](https://docs.cheddaboards.com/api/errors). **See also:** [Godot quick start](https://docs.cheddaboards.com/quickstart/godot) (drop-in path) · [Signals reference](https://docs.cheddaboards.com/engines/godot-signals) · [Achievements](https://docs.cheddaboards.com/api/achievements) · [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat) · [What's stored](https://docs.cheddaboards.com/concepts/data-model) --- # Godot 3.6 There's a community-supported backport of the CheddaBoards SDK for **Godot 3.6**, in a separate repo: [cheddaboards-godot3-addon](https://github.com/cheddatech/cheddaboards-godot3-addon). It tracks **v2.2.5** of the Godot 4 SDK with the same signals, public methods, and response handling. The [Godot quick start](https://docs.cheddaboards.com/quickstart/godot) applies — **only the GDScript syntax differs**, plus a few fixes that haven't been backported yet (see [Known differences from 2.2.7](#known-differences-from-2-2-7)). This page covers both. > **On Godot 4?** Use [cheddaboards-godot-addon](https://github.com/cheddatech/cheddaboards-godot-addon) (or the [full template](https://github.com/cheddatech/cheddaboards-godot)) — that's the primary, actively-developed SDK. New features land there first and may not be backported. > **No template for 3.6** The [Godot 4 guide](https://docs.cheddaboards.com/engines/godot-4) is about the template — its menus, game wrapper and `Achievements` autoload — and the template is **Godot 4 only**. On 3.6 you're on the drop-in path: the SDK plus your own UI. ## Install Copy `addons/cheddaboards/CheddaBoards.gd` into your project (from the [3.x repo](https://github.com/cheddatech/cheddaboards-godot3-addon)), then add it in **Project Settings → AutoLoad** with the name `CheddaBoards`. Set your credentials: ```gdscript CheddaBoards.set_api_key("cb_your-game_xxxxxxxxx") CheddaBoards.set_game_id("your-game") ``` ## The differences ### 1. `yield` instead of `await` Godot 3.x has no `await`. Where the Godot 4 docs wait for the SDK, use `yield` — guarded, because `yield` waits for the *next* `sdk_ready`, and if the SDK is already ready it would wait forever: ```gdscript # Godot 4 await CheddaBoards.wait_until_ready() # Godot 3.6 if not CheddaBoards.is_ready(): yield(CheddaBoards, "sdk_ready") ``` ### 2. `connect()` instead of typed signal callables Godot 3.x connects signals with the string-and-target form, not 4.x's callable form: ```gdscript # Godot 4 CheddaBoards.login_success.connect(_on_login) CheddaBoards.leaderboard_loaded.connect(_on_leaderboard) # Godot 3.6 CheddaBoards.connect("login_success", self, "_on_login") CheddaBoards.connect("leaderboard_loaded", self, "_on_leaderboard") ``` The signal names and their arguments are identical to Godot 4 — see the [signals reference](https://docs.cheddaboards.com/engines/godot-signals). Only the connect call changes. ### 3. `.instance()` instead of `.instantiate()` If you instance a scene (e.g. your own device-code login popup), Godot 3.x uses the older method name: ```gdscript # Godot 4 var popup = preload("res://DeviceCodeLogin.tscn").instantiate() # Godot 3.6 var popup = preload("res://DeviceCodeLogin.tscn").instance() ``` ## A minimal example, 3.6 style The Godot 4 drop-in, translated to 3.x syntax: ```gdscript extends Control func _ready(): CheddaBoards.set_api_key("cb_my-game_xxxxxxxxx") CheddaBoards.set_game_id("my-game") CheddaBoards.connect("leaderboard_loaded", self, "_on_leaderboard") if not CheddaBoards.is_ready(): yield(CheddaBoards, "sdk_ready") CheddaBoards.login_anonymous() # no name: returning players keep theirs func _on_game_over(score, streak): CheddaBoards.submit_score(score, streak) func show_leaderboard(): CheddaBoards.get_leaderboard("score", 100) func _on_leaderboard(entries): for e in entries: print("#%d %s - %d" % [e.rank, e.nickname, e.score]) ``` Log in **without** a name, exactly as on Godot 4: a name passed to `login_anonymous()` becomes the player's nickname and is written on the next submit. Brand-new players get a server-assigned name (`Player_1248`) on their first submit; to let players choose, use `change_nickname()`. Submitting scores, play sessions, device-code sign-in and category boards work as documented in the [Godot quick start](https://docs.cheddaboards.com/quickstart/godot) — translate `await` → `yield`, `.connect(callable)` → `connect("name", self, "method")`, and `.instantiate()` → `.instance()` as you go. Achievements use the SDK's own calls (`submit_score_with_achievements`, `unlock_achievement`), since the `Achievements` autoload is part of the Godot 4 template. ## Known differences from 2.2.7 The Godot 4 SDK fixed three bugs in 2.2.7 that are still present in the 3.x SDK: - **A submit can overwrite a returning player's name.** 2.2.5 always sends a nickname with each submit, generating a `Player_XXXXXX` fallback when it doesn't know the name yet. If a returning anonymous player submits before their profile has loaded, their saved name is replaced. Workaround: after login, call `refresh_profile()` and wait for `profile_loaded` (or `no_profile` for a brand-new player) before the first submit. - **`get_achievements()` calls a route that doesn't exist** and fails with `Unknown endpoint`. Read achievements from the `profile_loaded` signal instead (its 4th argument). - **Batch achievement syncs can report "0 synced"** even though the server stored them. Don't rely on the reported count; the unlocks are saved. ## What's not backported The 2.3.0 device-code additions (a pending code surviving a restart, `login_with_device_code(force_new)`, `has_pending_device_code()`) are also not in the 3.x SDK yet. Because the 3.x SDK tracks v2.2.5, anything added to the Godot 4 SDK after that (see its [changelog](https://github.com/cheddatech/cheddaboards-godot-addon)) may not be present. Breaking bugs get fixed; new features land in the Godot 4 SDK first. If you need something that isn't there, a PR to the [3.x repo](https://github.com/cheddatech/cheddaboards-godot3-addon) is welcome. **See also:** [Godot quick start](https://docs.cheddaboards.com/quickstart/godot) · [Signals reference](https://docs.cheddaboards.com/engines/godot-signals) · [3.x SDK repo](https://github.com/cheddatech/cheddaboards-godot3-addon) --- # Signals reference (Godot) Every signal the Godot SDK emits, grouped by category. All are typed for Godot 4.x. Connect the ones you need in `_ready()`. The [Godot quick start](https://docs.cheddaboards.com/quickstart/godot) shows the common ones in context; this page is the complete list. ## CheddaBoards.gd The SDK exposes 36 signals, grouped into the categories below. ### Initialization ```gdscript signal sdk_ready() signal init_error(reason: String) ``` ### Authentication ```gdscript signal login_success(nickname: String) signal login_failed(reason: String) signal logout_success() signal session_expired() # since v2.2.3 signal auth_error(reason: String) ``` `session_expired` (since v2.2.3) fires when the server rejects the stored session token — expired, revoked, or the account no longer exists. The saved session is cleared and `logout_success` **also** fires, so a menu that already handles `logout_success` needs no changes. Connect `session_expired` only to show something specific ("Session expired — please sign in again"). ### Profile ```gdscript signal profile_loaded(nickname: String, score: int, streak: int, achievements: Array, play_count: int) signal no_profile() signal nickname_changed(new_nickname: String) signal nickname_error(reason: String) ``` `profile_loaded` gained `play_count` as its 5th argument in v2.2.0 — a four-argument handler from an older version must add a trailing `play_count: int`. `nickname_changed` reports the name actually applied, which matters because a **taken** name is auto-suffixed (`Chedz` → `Chedz_1`) rather than rejected. `nickname_error` fires only for a genuinely invalid name — not 3–16 characters, or containing anything outside letters, digits, and underscores — and that rejection is permanent for that value, so prompt for a different name rather than retrying. See [Authentication](https://docs.cheddaboards.com/api/authentication). ### Scores & global leaderboard ```gdscript signal score_submitted(score: int, streak: int) signal score_submitted_to_board(scoreboard_id: String, score: int, streak: int) # since v2.2.2 signal score_error(reason: String) signal leaderboard_loaded(entries: Array) signal player_rank_loaded(rank: int, score: int, streak: int, total_players: int) signal rank_error(reason: String) ``` `score_submitted_to_board` (since v2.2.2) fires on a successful submit to a targeted category board. See [Category boards](https://docs.cheddaboards.com/concepts/category-boards). ### Scoreboards (time-based) ```gdscript signal scoreboards_loaded(scoreboards: Array) signal scoreboard_loaded(scoreboard_id: String, config: Dictionary, entries: Array) signal scoreboard_rank_loaded(scoreboard_id: String, rank: int, score: int, streak: int, total: int) signal scoreboard_error(reason: String) ``` ### Scoreboard archives ```gdscript signal archives_list_loaded(scoreboard_id: String, archives: Array) signal archived_scoreboard_loaded(archive_id: String, config: Dictionary, entries: Array) signal archive_stats_loaded(total_archives: int, by_scoreboard: Array) signal archive_error(reason: String) ``` ### Achievements ```gdscript signal achievement_unlocked(achievement_id: String) signal achievements_loaded(achievements: Array) ``` ### Play sessions (anti-cheat) ```gdscript signal play_session_started(token: String) signal play_session_error(reason: String) ``` `play_session_error` is non-fatal — the score still submits, it just won't be time-validated. The exception: if you've enabled **time validation** for the game, the session token is required and a submit without one is rejected. See [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat). ### Account upgrade (anonymous → verified) ```gdscript signal account_upgraded(profile: Dictionary, migration: Dictionary) signal account_upgrade_failed(reason: String) ``` `account_upgraded` fires **after** `device_code_approved`, once the background migration of the player's anonymous progress lands — `migration` carries `migratedGames` and `migratedScoreboards` counts. Neither upgrade signal fires for a player with no anonymous history; for them, approval is the whole flow. `account_upgrade_failed`'s reason comes from the server. `"Anonymous account not found"` is harmless — the player linked before ever submitting a score, so there was nothing to migrate. See [Authentication](https://docs.cheddaboards.com/api/authentication#upgrading-anonymous-verified-account-linking). ### Device code auth ```gdscript signal device_code_received(user_code: String, verification_url: String, qr_data_url: String) signal device_code_approved(nickname: String) signal device_code_expired() signal device_code_error(reason: String) ``` `qr_data_url` is a base64 PNG of a QR encoding the full verification URL with the code pre-filled — decode and apply it to a `TextureRect` for scanning. ### HTTP (catch-all) ```gdscript signal request_failed(endpoint: String, error: String) ``` ## Achievements.gd (template only) The `Achievements` autoload ships with the [template](https://docs.cheddaboards.com/engines/godot-4), not the addon. If you're on the drop-in SDK, you only need `CheddaBoards.achievement_unlocked` above. ```gdscript signal achievement_unlocked(id: String, name: String) signal achievements_ready() ``` ## Your game (Template wrapper) If you build on the Template's `Game.gd` wrapper, your own game scene emits these — the wrapper listens and handles submission, the game-over screen, and achievements. **Only `game_over` is required;** each of the other three reveals its HUD panel only when your scene declares it, so omit the ones you don't need and those panels stay hidden. ```gdscript signal game_over(final_score: int, stats: Dictionary) # REQUIRED — wrapper can't submit without it signal score_changed(score: int, combo: int) # optional — live score/combo HUD + mid-game achievement pops signal stats_changed(hits: int, misses: int, level: int) # optional — level/misses HUD signal time_changed(time_remaining: float, max_time: float) # optional — countdown timer HUD ``` Full breakdown of the contract and what each value does: [Godot 4 → the game_over contract](https://docs.cheddaboards.com/engines/godot-4#the-game-over-contract). **See also:** [Godot quick start](https://docs.cheddaboards.com/quickstart/godot) · [Godot 4 guide](https://docs.cheddaboards.com/engines/godot-4) · [Authentication](https://docs.cheddaboards.com/api/authentication) · [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat) --- # Web / HTML5 export Exporting a CheddaBoards game for the web has a few platform-specific requirements. Get these right and the same code that runs on desktop and mobile runs in the browser — anonymous play, device code sign-in, leaderboards, and achievements all work. Most projects need **only the steps in "Web export setup" below.** Device code sign-in works on web out of the box with zero configuration. ## Web export setup **1. Set the HTML shell (optional).** Project → Export → Add → Web. Under the **HTML** section: ``` Custom HTML Shell: res://template.html ``` This is optional — it just gives you the branded loading screen. Auth, scores, and leaderboards all run from GDScript over HTTP, so they work with Godot's default shell too. If you *do* use the included `template.html`, export as `index.html` (it loads `index.js`). **2. Export as `index.html`.** Project → Export → Web → Export Project, and save it as **`index.html`** — not `MyGame.html`. The included `template.html` loads `index.js` by name, so any other filename gives the "Engine not defined" error; and itch.io (like most hosts) looks for `index.html` in your upload zip. With Godot's default shell other names technically work, but `index.html` is the safe habit. **3. Serve over HTTP, not `file://`.** Web builds won't run from a local file path. Use any static server: ```bash python3 -m http.server 8000 # Python npx serve . # Node ``` Then open `http://localhost:8000`. That's the whole web checklist. Everything below is optional. ## Web authentication Web builds support every auth method the rest of the SDK does — anonymous, Google and Apple via device code, and account upgrade — with no web-specific setup. Device code sign-in is the recommended path on web exactly as everywhere else: no OAuth credentials, no browser popups, no platform branching. See [Authentication](https://docs.cheddaboards.com/api/authentication). **Sessions persist on web too** (since v2.2.3): the session is saved to `user://`, which the browser keeps in IndexedDB, so players sign in once per site. Two environments can't hold it: - **Safari blocks storage inside third-party iframes**, so a game embedded cross-origin re-auths each visit. - **itch.io serves each new upload from a new path**, orphaning the previous upload's storage — so itch players re-auth once per build you push. Neither breaks the game; players just sign in again. ## Where web traffic goes A web build talks to two hosts: `api.cheddaboards.com` (auth, submits, ranks) and — for leaderboard reads since v2.2.5 — the Internet Computer HTTP gateway directly (`*.raw.icp0.io`), which is what makes board reads CORS-simple with no server of yours. There's nothing to configure: if a network filters the gateway (some corporate/school networks do), the SDK detects it and falls back to the proxy automatically. The only case that needs action is a hosting setup with a strict Content-Security-Policy you control — allow both hosts in `connect-src`. itch.io needs nothing. ## The exit button on web `get_tree().quit()` does nothing useful in a browser — it just freezes the canvas. The right move is to navigate somewhere, and *how* depends on whether your game is embedded in an iframe (itch.io serves web games inside one). **Template (since v2.1.7): it's a setting, not code.** Select the `MainMenu` node and set **Web Exit Url** in the Inspector to your game's website or itch page: - **Empty (the default):** all Exit buttons are hidden on web builds — no dead-end button. - **Set, running full-window:** Exit does a same-tab redirect to the URL. - **Set, running in an iframe (itch.io):** Exit opens the URL in a **new tab**, leaving the host page intact — a same-tab redirect would load your whole website inside the game embed. If a popup blocker eats the new tab, the button shows the URL instead. Native builds always show Exit and quit normally, whatever the setting. **Drop-in SDK: roll the same logic yourself.** The iframe check matters — don't blind-redirect: ```gdscript func _on_exit_pressed(): if OS.has_feature("web"): var in_iframe = JavaScriptBridge.eval("window.self !== window.top", true) if in_iframe: # itch.io etc. — new tab keeps the host page intact JavaScriptBridge.eval("window.open('https://yourdomain.com', '_blank')", true) else: JavaScriptBridge.eval("window.location.href = 'https://yourdomain.com'", true) return get_tree().quit() ``` ## Mobile name entry Godot's in-engine `LineEdit` can't receive typed characters on mobile browsers, so on web the template swaps in an **HTML name-entry overlay** instead. This is why `template.html` carries two small helper functions — `window.chedda_prompt_name(...)` and `window.chedda_poll_name()` — that `MainMenu.gd` opens and polls via `JavaScriptBridge`. If you use the included `template.html`, this works out of the box. If you supply your **own** HTML shell for a web build that needs name entry on mobile, carry those two helpers across, or mobile players won't be able to type a name. Desktop web works without them either way. The overlay validates the nickname client-side against the same rule the server enforces — **3–16 characters, letters, digits, and underscores** — so a bad name is caught before the round trip. ## template.html `template.html` is **just** the loading screen, the Godot engine bootstrap, and the mobile name-entry helper above — no SDK, no OAuth scripts, no v1 JavaScript bridge. Authentication and scores run from GDScript over the HTTP API, so the shell carries none of that. Those `chedda_prompt_name` / `chedda_poll_name` helpers are a small self-contained name prompt — **not** the legacy v1 `cheddaboards_v1` bridge. If your `template.html` has that script, a large `CONFIG` block, or `window.chedda_*` OAuth bridge functions, it's an old v1.x shell — replace it with the lean current one from the template. ## Checklist - [ ] *(Optional)* Custom HTML Shell set to `res://template.html` for the branded loader - [ ] Exported as `index.html` - [ ] Served over HTTP (not `file://`) - [ ] `web_exit_url` set on MainMenu (or deliberately left empty to hide Exit on web); drop-in projects use the iframe-aware redirect above - [ ] Login and leaderboards tested in the browser **See also:** [Godot quick start](https://docs.cheddaboards.com/quickstart/godot) · [Godot 4 guide](https://docs.cheddaboards.com/engines/godot-4) · [Authentication](https://docs.cheddaboards.com/api/authentication) · [REST API](https://docs.cheddaboards.com/quickstart/rest) · [Game jams](https://docs.cheddaboards.com/quickstart/jam) --- # What's stored The short version: for each player, CheddaBoards keeps a **personal best** (high score and high streak) on each board, a small **profile**, and the **achievements** they've unlocked. Your per-run details — hits, level, accuracy — stay on the device. Here's the whole picture. ## The leaderboard entry One row per player, per board. Reading a board gives you: | Field | What it is | |-------|------------| | `rank` | The player's position on that board | | `nickname` | The player's public display name | | `score` | The player's **highest** score on that board | | `streak` | The player's **highest** streak on that board | | `authType` | How the player signed in (e.g. `external` for anonymous / API-key play) | | `submittedAt` | When that best was set (nanosecond timestamp) | A few things worth knowing: - **It's a personal best, not a history.** Submitting a lower score never replaces a higher one — only the best survives. There's one row per player, not one per run. - **Score and streak are independent maxima.** Your best score and your best streak don't have to come from the same run; each is kept as its own high-water mark. - **Bests are per board.** Your all-time best and your weekly best are tracked separately, so the same player can sit at different scores on different boards. ## The player profile One profile per player, per game. Reading it back (`GET /players/{playerId}/profile`, or embedded in sign-in responses) looks like: ```json { "nickname": "PlayerName", "created": 1788570532131407400, "gameProfile": { "score": 1234, "streak": 3, "achievements": ["first_win", "combo_10"], "playCount": 2, "lastPlayed": 1788571122315540500 } } ``` | Field | Notes | |-------|-------| | `nickname` | Public display name — the only identity other players ever see | | `score` / `streak` | The player's bests, as above | | `playCount` | How many runs they've finished | | `achievements` | The set of achievement IDs they've unlocked | | `created` / `lastPlayed` | Timestamps in **nanoseconds** since epoch — divide by 1,000,000 for JavaScript milliseconds | | user ID | **Private.** Anonymous players get a generated `dev_…` ID; signed-in players are identified by their Google/Apple account (which may be an email address). It identifies and links the account and is **never shown to anyone** — only the nickname is public. | ## Achievements Achievements are stored as **unlocked / not-unlocked flags, keyed by ID** — not free-form data. They live on the player's profile (the `achievements` array above), including for anonymous players, whose achievements are kept server-side against their anonymous profile, not just on the device. When an anonymous player later links a Google/Apple account, their achievements come with them (see [account linking](#identity-account-linking) below). Use them for milestones and badges, not as a place to stash per-run stats. ## Scoreboards & archives Every game gets its standard time-based boards (all-time, weekly, daily) automatically, and you can add as many custom boards as you like. Each board tracks its own per-player bests. When a timed board resets — weekly at Monday 00:00 UTC, daily at midnight UTC, monthly on the 1st — its final standings are **archived**: a snapshot you can read back later (e.g. "last week's winners"). Resetting a board doesn't throw the results away; the archive keeps them. See [Timed leaderboards](https://docs.cheddaboards.com/concepts/timed-leaderboards). ## Identity & account linking - **Anonymous:** a `dev__` ID your game generates and stores on the device. The profile is created on the first score submission. - **Signed in:** Google or Apple, via device code. The account is identified by a private user ID (possibly an email), never shown publicly. - **Linking:** an anonymous player can upgrade to a Google/Apple account and keep all their progress. Everything carries over, but the merge rules differ by field: - **Score and streak** merge as **per-field maxima** — the higher value wins, so linking can only ever keep or raise a best, never lower one. - **Achievements** are **combined** — the union of both sets, deduplicated. - **Play count** is **summed** — the total reflects all runs finished across both. After the merge, the anonymous account is absorbed into the linked one — no separate anonymous profile is left behind. Full flow: [Authentication](https://docs.cheddaboards.com/api/authentication#upgrading-anonymous-verified-account-linking). ## Play sessions A play session is a **short-lived server-side token** created for a single run, used to validate the score against elapsed time (anti-cheat). It isn't long-term player data — it exists only around a run and is cleared when the game ends the session after submitting, or when it expires unused. See [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat). ## Developer moderation & the deletion log Game owners can remove score entries from their own boards — a single entry on one board, or a player's entries across every board — from the dashboard. See [Moderation](https://docs.cheddaboards.com/concepts/moderation). Each removal is recorded in a small, capped **deletion audit log**. Log entries identify the affected player only by a **one-way hash** — the raw user ID (email or principal) is never written to the log, so it can't leak identity. ## What CheddaBoards does *not* store - **The rest of your run stats.** On the Godot template, `hits`, `misses`, `level`, and `accuracy` are used for the game-over screen and achievement checks, then discarded — they never reach the server. See [where each value goes](https://docs.cheddaboards.com/engines/godot-4#where-each-value-goes). - **Arbitrary per-entry metadata.** A score row is score + streak — there's no free-form field to attach extra data to an entry. To rank a custom value, map it onto score or streak. - **Anything you don't send.** Beyond the entries, profiles, achievements, archives, and the deletion log described above, the data model is exactly what's on this page. **See also:** [REST quick start](https://docs.cheddaboards.com/quickstart/rest) · [Timed leaderboards](https://docs.cheddaboards.com/concepts/timed-leaderboards) · [Authentication](https://docs.cheddaboards.com/api/authentication) · [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat) · [Moderation](https://docs.cheddaboards.com/concepts/moderation) --- # Players and accounts Every score, streak, and achievement belongs to a **player**. This page explains what a player *is* in CheddaBoards — how anonymous and signed-in players differ, what's public versus private, and how one becomes the other without losing progress. ## Two kinds of player **Anonymous.** A player identified by an ID your game generates and stores on the device — the SDKs use the form `dev__`. No account, no sign-in, no email. The profile is created the first time they submit a score. This is the zero-friction default: a player can be on the leaderboard seconds after opening your game. **Signed in.** A player who has authenticated with Google or Apple via [device code](https://docs.cheddaboards.com/concepts/device-code). Their account is identified by a private user ID tied to that provider. Signing in buys two things an anonymous player doesn't have: their progress follows them **across devices**, and it survives a cleared browser cache or a reinstall. Both kinds are full leaderboard citizens — anonymous players rank, earn achievements, and appear on boards exactly like signed-in ones. Signing in is about *portability of identity*, not access. ## Public vs. private Only one thing about a player is ever public: their **nickname**. That's the sole identity other players see on a leaderboard. Everything else is private. The anonymous `dev_…` ID, and a signed-in player's provider account (which may be an email address), are used internally to identify and link the account and are **never exposed** — not on boards, not in profile reads other players can make, not in the moderation deletion log (which stores only a one-way hash). See [What's stored](https://docs.cheddaboards.com/concepts/data-model) for the field-by-field picture. ### Nicknames Nicknames are **3–16 characters, letters, digits, and underscores** (`A–Z a–z 0–9 _`). Two behaviors worth knowing: - **Taken names auto-suffix on rename.** Asking for a nickname someone already has isn't an error — the backend appends a number (`Chedz` → `Chedz_1`) and tells you the name it actually applied. Only a genuinely invalid name (too short, bad characters) is rejected, and that rejection is permanent for that value. (The create path is different: a chosen name that's taken when the first score creates the profile falls back to `Player_N`; the SDKs then re-send the rename on the next profile load, raw REST clients call the rename endpoint.) - **Nicknames aren't unique identity.** Because of suffixing, two players can have very similar names, and a nickname can change. The stable identity is always the private user ID, never the display name. How to let players pick a name without accidentally renaming them, with copy-paste flows for Godot and Unity, is on [Player names](https://docs.cheddaboards.com/concepts/player-names). ## What a profile holds One profile per player, per game — score and streak bests, play count, unlocked achievements, and the nickname. The full shape is on the [data model page](https://docs.cheddaboards.com/concepts/data-model#the-player-profile). The key idea: a profile is a set of **bests and totals**, not a history of runs. There's one row per player on each board, and it only ever moves up. ## Becoming a signed-in player (linking) An anonymous player can sign in later and keep everything. The anonymous progress **merges** into the account rather than being replaced: - **Score and streak** take the higher value per field — linking can only raise or keep a best, never lower one. - **Achievements** combine into the union of both sets. - **Play counts** add together. After the merge the anonymous profile is absorbed and gone — linking is one-way, there's nothing to unlink back to. And because the merge is per-field maxima, a player who's been anonymous on two devices can link both to the same account and their best-of-everything comes together cleanly. The mechanics — the `/migrate-account` call, the device-code flow, the signals — are in [Authentication](https://docs.cheddaboards.com/api/authentication#upgrading-anonymous-verified-account-linking). ### Why linking matters to you, the developer It's tempting to think of sign-in as a feature for the player's benefit only. It's also **retention infrastructure**: a linked player survives the things that otherwise silently lose you a player — a cleared browser cache, a new phone, a reinstall. An anonymous player who does any of those becomes a brand-new anonymous player, their old bests stranded on an ID the device no longer holds. Linking is the fix, which is why it's worth pitching to players as "save your progress" rather than "create an account." You don't have to push it. Anonymous-only is a perfectly good mode, and many players will never link. But offering the option — especially after a personal best, or when a returning player's local data looks empty — is the single highest-leverage thing you can do for player retention. **See also:** [Authentication](https://docs.cheddaboards.com/api/authentication) · [Device code login](https://docs.cheddaboards.com/concepts/device-code) · [What's stored](https://docs.cheddaboards.com/concepts/data-model) · [Privacy](https://docs.cheddaboards.com/concepts/privacy) --- # Player names How nicknames work, the three rules that keep them from going wrong, and copy-paste name-entry flows for Godot and Unity. Applies to the Godot 4 addon **2.2.7+** and the Unity SDK **2.3.0+** (the shared-device section needs **2.3.1**). Older versions had a bug where a submit could overwrite a saved name with a generated one; if names are "changing on their own", update the SDK first. The Godot 3.6 backport (2.2.5-3x) still has that bug: on 3.6, call `refresh_profile()` after login and wait for `profile_loaded` or `no_profile` before the first submit (see the [3.6 guide](https://docs.cheddaboards.com/engines/godot-3#known-differences-from-2-2-7)). Everything else on this page applies to 3.6 as written. ## The one thing to understand **A player's name lives on the server, not in your game.** It's attached to their profile (an anonymous device ID, or a linked account) and it's the same across every game on CheddaBoards. The SDK keeps a local copy and only writes to the server when: 1. the player's **first score submit** creates their profile (the server assigns a name like `Player_1248` if none was chosen), 2. you call **`change_nickname()` / `ChangeNickname()`**, or 3. the player **links an account** and that account is brand new (it's created with the in-game name they chose). Nothing else writes a name. In particular, **submitting a score does not rename anyone**, and **logging in does not rename anyone** unless you pass a name into the login call, which is the mistake almost everyone makes. ## Three rules ### 1. Log in with no name ```gdscript CheddaBoards.login_anonymous() # right CheddaBoards.login_anonymous("Alex") # wrong, unless Alex just typed it in ``` ```csharp cb.LoginAnonymous(); // right cb.LoginAnonymous("Alex"); // wrong, unless Alex just typed it in ``` Any name you pass becomes the current nickname and is sent with the next submit. For an existing player that means: if the name is free, they are **silently renamed**; if it's taken, nothing happens and they keep their old name, with no error either way. Passing a name every launch (a saved name, a default, a random one) is how players end up renamed, how a returning player's `Alex` becomes `Player_1248`, and how a leaderboard fills with one player under five names. Leave it empty. Returning players keep their stored name automatically. Brand-new players get a server-assigned name on their first submit, unless they pick one first (rule 3). ### 2. Treat `""` as "Guest" Until the profile loads, and for a new player who hasn't picked a name, `get_nickname()` / `GetNickname()` returns `""`. Show "Guest" in your UI. Don't fill the gap with a name of your own; see rule 1. ### 3. Rename through the SDK, and listen for the answer ```gdscript CheddaBoards.change_nickname("Alex") ``` ```csharp cb.ChangeNickname("Alex"); ``` Then wait for one of two signals: - **`nickname_changed(name)` / `OnNicknameChanged`**: accepted. Use the `name` the signal gives you, not the input box. - **`nickname_error(reason)` / `OnNicknameError`**: rejected. **The rejection is permanent for that value**; retrying the same string will fail the same way. Show the reason and let the player type something else. Name rules (pre-checked by the SDK, enforced by the server): **3 to 16 characters, letters, numbers and underscores only.** Spaces, emoji and punctuation are rejected. There's a wrinkle for **brand-new players**. Until their first score creates a profile, they don't exist on the server, so `change_nickname()` holds the name locally and fires `nickname_changed` straight away with exactly what was typed. The server first sees it on their first submit. If the name is free, the profile is created with it and you're done. If `Alex` is already taken, the server creates the profile as `Player_N`; the SDK notices on the next profile load, re-sends the rename, and `nickname_changed` fires again with `Alex_1`. So the sequence for a new player is: pick a name, play, submit, and refresh the profile once after the first score. (On raw REST there is no re-sync: a taken name at first submit stays `Player_N` until you call the rename endpoint.) For players who already have a profile, the rename goes to the server immediately and the signal carries the final name. The practical rule covering both: **after `profile_loaded`, redraw the name from `get_nickname()`.** Treat it as the source of truth whenever it fires. ## Name-entry flow: Godot 4 Drop this on a Control with a `LineEdit` named `NameInput`, a `Button` named `ConfirmButton`, and a `Label` named `StatusLabel`. It shows the box only when the player has no name yet. ```gdscript extends Control @onready var name_input: LineEdit = $NameInput @onready var confirm_button: Button = $ConfirmButton @onready var status_label: Label = $StatusLabel func _ready() -> void: visible = false confirm_button.pressed.connect(_on_confirm) name_input.text_submitted.connect(func(_t): _on_confirm()) CheddaBoards.profile_loaded.connect(_on_profile_known) CheddaBoards.no_profile.connect(_on_profile_known) CheddaBoards.nickname_changed.connect(_on_nickname_changed) CheddaBoards.nickname_error.connect(_on_nickname_error) # Log in with NO name. Returning players keep theirs. CheddaBoards.login_anonymous() CheddaBoards.refresh_profile() # Fires once we know whether the player already has a name (profile_loaded # passes arguments, no_profile passes none; we ignore both). func _on_profile_known(_a = null, _b = null, _c = null, _d = null, _e = null) -> void: if CheddaBoards.get_nickname() == "": visible = true # new player: ask for a name name_input.grab_focus() else: visible = false # returning player: nothing to do func _on_confirm() -> void: var wanted := name_input.text.strip_edges() status_label.text = "Checking..." confirm_button.disabled = true CheddaBoards.change_nickname(wanted) # SDK validates 3-16 / [A-Za-z0-9_] first func _on_nickname_changed(final_name: String) -> void: # Use final_name, not name_input.text. For a new player this is what they # typed (stored locally until their first score); if it turns out to be # taken, the suffixed name arrives on the next profile_loaded, so always # redraw name displays from get_nickname() there. status_label.text = "Welcome, %s!" % final_name confirm_button.disabled = false visible = false func _on_nickname_error(reason: String) -> void: # Permanent for this value. Don't auto-retry; let them type another. status_label.text = reason confirm_button.disabled = false name_input.grab_focus() ``` Anywhere else in the game, display the name like this: ```gdscript var shown := CheddaBoards.get_nickname() if shown == "": shown = "Guest" ``` **Godot 3.6**: same API and signals; connect with `CheddaBoards.connect("nickname_changed", self, "_on_nickname_changed")` and so on. ## Name-entry flow: Unity Same shape. Attach to a panel with a `TMP_InputField`, a `Button` and a `TMP_Text`. The panel appears only for players with no name. ```csharp using CheddaTech; using TMPro; using UnityEngine; using UnityEngine.UI; public class NameEntry : MonoBehaviour { public GameObject panel; public TMP_InputField nameInput; public Button confirmButton; public TMP_Text statusLabel; private CheddaBoards cb; void Start() { cb = CheddaBoards.Instance; panel.SetActive(false); confirmButton.onClick.AddListener(OnConfirm); nameInput.onSubmit.AddListener(_ => OnConfirm()); cb.OnProfileLoaded += (nick, score, streak, ach, plays) => OnProfileKnown(); cb.OnNoProfile += OnProfileKnown; cb.OnNicknameChanged += OnNicknameChanged; cb.OnNicknameError += OnNicknameError; // Log in with NO name. Returning players keep theirs. cb.LoginAnonymous(); cb.RefreshProfile(); } void OnProfileKnown() { bool needsName = string.IsNullOrEmpty(cb.GetNickname()); panel.SetActive(needsName); if (needsName) nameInput.ActivateInputField(); } void OnConfirm() { string wanted = nameInput.text.Trim(); statusLabel.text = "Checking..."; confirmButton.interactable = false; cb.ChangeNickname(wanted); // SDK validates 3-16 / [A-Za-z0-9_] first } void OnNicknameChanged(string finalName) { // Use finalName, not nameInput.text. For a new player this is what they // typed (stored locally until their first score); if it turns out to be // taken, the suffixed name arrives on the next OnProfileLoaded, so // always redraw name displays from GetNickname() there. statusLabel.text = $"Welcome, {finalName}!"; confirmButton.interactable = true; panel.SetActive(false); } void OnNicknameError(string reason) { // Permanent for this value. Don't auto-retry; let them type another. statusLabel.text = reason; confirmButton.interactable = true; nameInput.ActivateInputField(); } } ``` Display helper: ```csharp string shown = string.IsNullOrEmpty(cb.GetNickname()) ? "Guest" : cb.GetNickname(); ``` ## What happens when a player signs in (links an account) When a player links via the device-code flow, their anonymous progress merges into the account. Names follow the account: - **New account** (first time this Google/Apple user has linked anywhere): the account is created with the name the player chose in your game. Nothing changes from their point of view. - **Existing account** (they've linked before, in your game or another): the account's name wins. Your local copy is replaced. This is correct: it's the same person, and their name is the same across every game. So after `device_code_approved` / `OnDeviceCodeApproved` and `account_upgraded` / `OnAccountUpgraded`, read `get_nickname()` again and redraw. Don't push your old local name back with `change_nickname()`; you'd be renaming them across all their games. ## Shared devices: several players, one install Arcade cabinets, couch play, a classroom laptop, a demo machine at an event. Several people take turns on one install and each wants their own name and their own row on the board. The answer is **not** to send a nickname with every score. The nickname is just a label on a profile; what makes someone a separate player is their **player ID**. On a normal install the SDK generates one `dev_…` ID per device and reuses it, so one device is one player. Sending a different nickname each time would rename that single profile back and forth, and everyone would share one row that only ever moves up. Instead, keep a small roster in the game: one player ID per person, generated once and saved locally, and make the chosen person's ID the active one before they play. Each ID is a separate profile with its own name, bests and board rows, exactly as if they were on separate devices. Four steps for switching (SDK **2.3.1+**, where `set_player_id()` resets the previous person's cached profile, name, pending rename and play session when the ID changes): 1. **Call `logout()` / `Logout()`** first. `set_player_id()` does not drop a signed-in session or a link code that is still waiting for approval; `logout()` does. If nobody on the device ever links an account this is a no-op, so always call it. It emits `logout_success` / `OnLogoutSuccess`: if your game sends that signal to a login screen, ignore it during a switch. 2. **Set the ID**, then **`login_anonymous()` / `LoginAnonymous()`** with no name. 3. **Fetch the profile with `get_player_profile()` / `GetPlayerProfile()`**, not `refresh_profile()`: the refresh helper has a 2-second cooldown and silently does nothing inside it, which a quick switch can hit. 4. **Start a new play session** when the new person's run begins. Sessions are per player. The SDKs don't persist a `set_player_id()` override, so re-apply it from your roster on startup too. > **On 2.3.0** 2.3.0 did not fully reset the previous person's state on a switch, so a new person's `change_nickname()` before their first score was sent to the server and lost. Update to 2.3.1. If you must stay on 2.3.0, follow the same four steps, but give a new person their name at creation with `login_anonymous(name)` instead of `change_nickname()`. Name entry per person is the normal flow from above: offer the box when `get_nickname()` is `""` after the profile is known, rename through the SDK, never pass names into login. ### Linking on a shared device Linking uses up the slot. When someone links, their anonymous profile is merged into their account and its `dev_…` ID stops existing on the server. Selecting that slot again would start a brand-new empty player under the old ID, so remove the slot from your roster when `account_upgraded` / `OnAccountUpgraded` fires. The person's scores are safe on their account, and they stay signed in until the next switch; to play as that account on this device again later, they link again. A signed-in session belongs to one person, which is why every switch starts with `logout()`. Without it the next person's scores and renames go to the linked account, whatever player ID is set. If that's more than your game needs, the simple option is to not offer linking on shared installs at all. ### Roster: Godot 4 ```gdscript # Roster.gd (autoload). One dev_ ID per local player, saved in user://. extends Node const PATH := "user://roster.cfg" var players: Array = [] # [{ "id": "dev_...", "label": "Player 1" }, ...] var active := -1 func _ready() -> void: var cfg := ConfigFile.new() if cfg.load(PATH) == OK: players = cfg.get_value("roster", "players", []) active = cfg.get_value("roster", "active", -1) func add_player(label: String) -> int: # Same shape the SDK uses for its own device ID. var ts := str(Time.get_unix_time_from_system()).replace(".", "") var id := "dev_%s_%08x" % [ts, randi() & 0x7FFFFFFF] players.append({ "id": id, "label": label }) _save() return players.size() - 1 func select(index: int) -> void: active = index _save() CheddaBoards.logout() # drops a signed-in session or pending link code from the previous person CheddaBoards.set_player_id(players[index]["id"]) # 2.3.1+: resets the previous person's state CheddaBoards.login_anonymous() # no name CheddaBoards.get_player_profile() # no cooldown; profile_loaded / no_profile then drives name entry func _save() -> void: var cfg := ConfigFile.new() cfg.set_value("roster", "players", players) cfg.set_value("roster", "active", active) cfg.save(PATH) ``` On startup: if `active >= 0`, call `Roster.select(active)` before anything else touches CheddaBoards. When a run starts for the selected person, `start_play_session()` as usual. ### Roster: Unity ```csharp using System.Collections.Generic; using UnityEngine; using CheddaTech; // One dev_ ID per local player, saved in PlayerPrefs. public static class Roster { [System.Serializable] class Entry { public string id; public string label; } [System.Serializable] class Store { public List players = new List(); public int active = -1; } const string KEY = "cb_roster"; static Store store; static void Load() { if (store != null) return; store = PlayerPrefs.HasKey(KEY) ? JsonUtility.FromJson(PlayerPrefs.GetString(KEY)) : new Store(); } static void Save() { PlayerPrefs.SetString(KEY, JsonUtility.ToJson(store)); PlayerPrefs.Save(); } public static int Add(string label) { Load(); // Same shape the SDK uses for its own device ID. string id = $"dev_{System.DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{Random.Range(0, int.MaxValue):x8}"; store.players.Add(new Entry { id = id, label = label }); Save(); return store.players.Count - 1; } public static void Select(int index) { Load(); store.active = index; Save(); var cb = CheddaBoards.Instance; cb.Logout(); // drops a signed-in session or pending link code from the previous person cb.SetPlayerId(store.players[index].id); // 2.3.1+: resets the previous person's state cb.LoginAnonymous(); // no name cb.GetPlayerProfile(); // no cooldown; OnProfileLoaded / OnNoProfile then drives name entry } public static int Active { get { Load(); return store.active; } } public static IReadOnlyList Labels { get { Load(); return store.players.ConvertAll(p => p.label); } } } ``` On startup: if `Roster.Active >= 0`, call `Roster.Select(Roster.Active)` before anything else touches CheddaBoards. Start a play session per run as usual. ### REST Nothing special: `playerId` on every request is whatever you send. Generate one `dev__` per person, store them, and send the selected one. Include `nickname` only on that person's first submit (or use the rename endpoint); see [Players](https://docs.cheddaboards.com/api/players#change-a-nickname). ## Things that cause confusion | Symptom | Cause | Fix | |---|---|---| | Returning player's name reverts to `Player_1248` | A name is passed into `login_anonymous()` / `LoginAnonymous()` on every launch, or the SDK is older than 2.2.7 | Log in with no name; update the SDK | | Leaderboard shows the same player under several names | Game generates or stores its own name and passes it at login | Same as above; the server is the source of truth, not your save file | | `change_nickname` keeps failing with the same message | The value is invalid or taken-and-unsuffixable; rejections are permanent per value | Show the reason, let the player type a different one, never auto-retry | | Existing player typed `Alex`, UI shows `Alex` but board shows `Alex_1` | `Alex` was taken; the rename was suffixed | Redraw name displays from `get_nickname()` on every `profile_loaded` | | New player typed `Alex`, board shows `Player_1248` | `Alex` was taken when their first score created the profile | Refresh the profile once after the first score; the SDK re-sends the rename and `nickname_changed` fires with `Alex_1`. On raw REST, call the rename endpoint yourself | | Passed a name at login, nothing changed | The name was already taken, so the submit kept the old one; no error is raised on this path | Don't pass names at login; use `change_nickname()`, which reports the outcome | | Name chosen right after launch is gone a moment later | The name was set while the profile fetch was still in flight; when the profile landed it overwrote the local name | Don't offer the name box until `profile_loaded` or `no_profile` has fired (the flows above do this) | | After signing in, the name changed to something else | Player linked an existing account; the account's name wins | Expected. Redraw from `get_nickname()` after `account_upgraded` | | New player shows `""` / blank | Profile hasn't loaded yet, or they haven't picked a name | Show "Guest" | | Several people share one device and keep overwriting each other | One device ID, so they are one profile; sending names per submit just renames it | One player ID per person, switched before they play; see [Shared devices](#shared-devices-several-players-one-install) | | On a shared device, the next person shows up with the previous person's name, or their scores land on someone else's row | The previous person linked an account and the switch didn't call `logout()`, so their session is still active | Call `logout()` before `set_player_id()` on every switch; see [Linking on a shared device](#linking-on-a-shared-device) | | On a shared device, a linked person's slot comes back empty | Linking merged that slot into their account; the old ID no longer exists | Remove the slot on `account_upgraded`; they link again to play as their account | ## Reference | | Godot | Unity | |---|---|---| | Log in (no name) | `login_anonymous()` | `LoginAnonymous()` | | Current name (`""` = unnamed) | `get_nickname()` | `GetNickname()` | | Rename | `change_nickname(name)` | `ChangeNickname(name)` | | Accepted | `nickname_changed(name)` | `OnNicknameChanged(name)` | | Rejected (permanent per value) | `nickname_error(reason)` | `OnNicknameError(reason)` | | Profile known | `profile_loaded(...)` / `no_profile()` | `OnProfileLoaded(...)` / `OnNoProfile` | Name rule: 3 to 16 characters, `A-Z a-z 0-9 _`. Renames to a taken name are suffixed (`Alex_1`). Players who never pick a name, or whose chosen name is taken at the moment their first score creates the profile, get `Player_N`. A name chosen before the first submit is held locally and sent with that submit. Changing a name changes it on every board and in every game the player has played. --- # Device code login Sign players in with Google or Apple on **any** platform — desktop, mobile, web, even consoles — with no bundled OAuth SDK and no in-game browser popup. The player authorises on their phone; your game polls and picks up the session automatically. This is the hands-on companion to [Authentication](https://docs.cheddaboards.com/api/authentication), which covers the wider picture (anonymous play, account linking, the raw REST endpoints). Here we build the **login screen** itself, in Godot. - On the **Drop-in** and **Template** paths, the screen ships inside the addon (since v2.3.0) — skip to [Fastest path](#fastest-path). - Want your own look? [Build your own](#build-your-own-screen) shows the pattern. - On **REST / other engines**, the two endpoints behind all of this are in [Authentication → device code](https://docs.cheddaboards.com/api/authentication#sign-in-with-google-apple-device-code). ## How it works (30 seconds) 1. You call `login_with_device_code()`. 2. The SDK emits `device_code_received` with a short code, a verification URL, and a QR image. 3. You show those to the player. They scan the QR (or open the link) on their phone and sign in with Google or Apple. 4. The SDK polls in the background and emits `device_code_approved(nickname)` when they're done — or `device_code_expired` after 5 minutes. If the player was anonymous, their progress then merges into the account in the background — see [After approval](#after-approval-account-upgrade-signals). Players do this **once** (since v2.2.3): the session persists to `user://` and is restored on startup, so this screen only reappears after a logout or a server-side expiry — see [Authentication → sessions](https://docs.cheddaboards.com/api/authentication#sessions). The in-flight code survives too (since v2.3.0): a pending device code is saved to `user://` the moment it's issued, so a page reload or app restart mid-link resumes polling on the **same** code instead of minting a new one — see [Pending codes](#pending-codes-reload-and-restart). ## Fastest path The addon ships a reusable popup scene + script at `addons/cheddaboards/ui/DeviceCodeLogin.tscn` that wires every signal and cleans itself up. (The Template instantiates this same copy — it no longer carries its own.) Instantiate it and start the flow: ```gdscript var popup = preload("res://addons/cheddaboards/ui/DeviceCodeLogin.tscn").instantiate() add_child(popup) popup.start_sign_in() popup.signed_in.connect(func(nickname): print("Welcome, %s!" % nickname)) popup.cancelled.connect(func(): print("Sign-in dismissed")) ``` It emits `signed_in(nickname)` on success and `cancelled()` on an explicit cancel or expiry, then frees itself. Closing the popup is a **soft dismiss**: the SDK keeps polling in the background and `signed_in` still fires if the player finishes on their phone — see [Dismiss vs cancel](#dismiss-vs-cancel). (The script also exposes a `show_sign_in(parent)` static helper for a true one-liner — see the note at the end.) ## The signal lifecycle Whether you use the prebuilt popup or roll your own, these four SDK signals drive the entire flow: | Signal | Fires when | Your UI should… | |--------|------------|-----------------| | `device_code_received(user_code, verification_url, qr_data_url)` | The code is ready | Show the QR + raw code + link, start the countdown | | `device_code_approved(nickname)` | Player finished on their phone | Show success, then continue into the game | | `device_code_expired()` | The 5-minute window elapsed | Offer "try again" | | `device_code_error(reason)` | Something went wrong | Show the reason, offer retry | Connect them in `_ready()`, and disconnect on teardown so a second attempt starts clean. ## After approval: account upgrade signals `device_code_approved` isn't always the end of the story. If the player was **anonymous** before linking, the SDK automatically migrates their progress (scores, streaks, achievements, play counts) into the account in the background, and exactly one of these follows: | Signal | Fires when | |--------|------------| | `account_upgraded(profile, migration)` | The merge landed — `migration` carries `migratedGames` and `migratedScoreboards` counts | | `account_upgrade_failed(reason)` | The merge couldn't run — the `reason` string comes from the server | Two things worth knowing: - **"Anonymous account not found" is harmless.** It means the player linked before ever submitting a score, so there was nothing to migrate. Safe to ignore in your UX. - **A fresh player who links with no anonymous history gets neither signal** — no migration is attempted, and `device_code_approved` is the whole flow. Your login screen doesn't need to handle these (the popup closes on `device_code_approved`), but connect them if you show a "progress transferred!" message or gate anything on the merge. The full linking picture, including the per-field merge rules, is in [Authentication](https://docs.cheddaboards.com/api/authentication#upgrading-anonymous-verified-account-linking). > **Nicknames can settle a beat after approval** When linking *creates* the account, it's born with the player's in-game name — but if their anonymous profile still holds that name at creation time, the account briefly gets a suffixed one (`Jegg_1`) and the SDK reclaims the exact name right after the merge, emitting `nickname_changed`. If you display the player's name anywhere persistent, connect `nickname_changed` rather than caching the string from `device_code_approved`. ## Dismiss vs cancel These are different things, and the prebuilt popup treats them differently. Do the same in your own screen. - **Dismiss** (player closes the popup, taps outside, backs out to the menu): hide the UI and do nothing else. The SDK carries on polling, and `device_code_approved` fires whenever the player finishes on their phone — so a player who scans the QR, pockets their phone, and gets back to the game still ends up signed in. - **Cancel** (an explicit "Cancel" / "Don't sign in" action): call `CheddaBoards.cancel_device_code()`. This stops polling and clears the pending code, so the next attempt mints a fresh one. Only call `cancel_device_code()` on the explicit action. Calling it on every close means a player who dismissed the popup a second early loses the sign-in they'd already completed. ## Pending codes: reload and restart Since v2.3.0 the SDK writes the pending device code to `user://` as soon as it's issued, and clears it on approval, expiry, or cancel. This matters most on web builds, where a tab reload used to throw the code away while the player was mid-sign-in on their phone. - `login_with_device_code()` reuses a pending code if one exists — `device_code_received` fires again with the same code, URL, and QR, and polling resumes. Pass `force_new = true` to discard it and mint a fresh one. - `has_pending_device_code()` tells you whether one is waiting, so you can reopen the login screen on startup rather than asking the player to scan again. `get_device_verification_url()` and `get_device_code_seconds_remaining()` let that screen redraw a restored code with its real remaining time, which is less than the original 5 minutes. The Unity SDK (2.3.0+) does the same: `LoginWithDeviceCode(forceNew)`, `HasPendingDeviceCode()`, `GetDeviceVerificationUrl()`, `GetDeviceCodeSecondsRemaining()`, with the pending code in `PlayerPrefs`. ```gdscript func _ready(): if CheddaBoards.has_pending_device_code() and not CheddaBoards.is_authenticated(): _show_login_screen() # resumes the same code ``` ## Build your own screen A minimal version, distilled from the reference implementation: ```gdscript extends CanvasLayer func _ready(): CheddaBoards.device_code_received.connect(_on_received) CheddaBoards.device_code_approved.connect(_on_approved) CheddaBoards.device_code_expired.connect(_on_expired) CheddaBoards.device_code_error.connect(_on_error) CheddaBoards.login_with_device_code() func _on_received(user_code: String, verification_url: String, qr_data_url: String): $CodeLabel.text = user_code # raw code (always show as fallback) _set_qr_from_data_url(qr_data_url) # the QR image — see below $Status.text = "Waiting for you to sign in..." func _on_approved(nickname: String): print("Signed in as %s" % nickname) queue_free() func _on_expired(): $Status.text = "Code expired — try again." func _on_error(reason: String): $Status.text = "Error: %s" % reason ``` ## Rendering the QR code This is the part that trips everyone up. `device_code_received` hands you `qr_data_url` as a base64 PNG **data URL** — a string like `data:image/png;base64,iVBORw0KGgo...`. Godot can't apply that to a `TextureRect` directly; strip the prefix, base64-decode it, load it as a PNG, and wrap it in a texture: ```gdscript ## Decode a base64 PNG data URL onto a TextureRect. Returns true on success. func _set_qr_from_data_url(data_url: String) -> bool: var comma = data_url.find(",") # strip the "data:image/png;base64," prefix if comma == -1: push_warning("Invalid QR data URL (no comma found)") return false var b64 = data_url.substr(comma + 1) var raw: PackedByteArray = Marshalls.base64_to_raw(b64) if raw.is_empty(): return false var img = Image.new() if img.load_png_from_buffer(raw) != OK: return false $QRCode.texture = ImageTexture.create_from_image(img) return true ``` > **tip** Always show the raw `user_code` as well. `qr_data_url` can come back null (the SDK falls back to the raw code), and plenty of players don't have a second camera device handy. ## The expiry countdown The code is valid for 5 minutes. Record the deadline when it arrives and tick it down in `_process`. Ask the SDK for the remaining time rather than assuming 300 s, because a code restored after a reload has less than that left: ```gdscript var _expires_at := 0.0 func _on_received(_user_code, _url, _qr): _expires_at = Time.get_unix_time_from_system() + CheddaBoards.get_device_code_seconds_remaining() func _process(_delta): if _expires_at <= 0.0: return var remaining = _expires_at - Time.get_unix_time_from_system() if remaining <= 0: $TimerLabel.text = "Expired" return $TimerLabel.text = "Expires in %d:%02d" % [int(remaining) / 60, int(remaining) % 60] ``` ## Opening the link (desktop / no camera) For players who can't scan, make the verification URL clickable. `OS.shell_open` works everywhere — on web it routes through the browser's `window.open`, on desktop/mobile it hands off to the OS handler: ```gdscript func _on_link_pressed(): if _verification_url.is_empty(): return OS.shell_open(_verification_url) ``` The verification URL already has the code pre-filled, so the player just taps a provider button on the page. ## Cleaning up When the flow ends — approved, expired, or cancelled — disconnect the signals and free the node. If you tear down on a plain dismiss, leave the SDK polling; only stop it on an explicit cancel (see [Dismiss vs cancel](#dismiss-vs-cancel)): ```gdscript func _close(explicit_cancel: bool = false): if explicit_cancel and _still_waiting: CheddaBoards.cancel_device_code() # disconnect device_code_* signals here queue_free() ``` If you free the screen on a dismiss, connect `device_code_approved` somewhere longer-lived (your main menu, an autoload) so the sign-in still lands when it completes. ## The one-liner helper The reference script exposes a static `show_sign_in(parent)` that instantiates, adds, and starts the flow in a single call. To use it as `DeviceCodeLogin.show_sign_in(self)`, the script needs a `class_name` and the popup scene must sit at the path the helper loads (`res://addons/cheddaboards/ui/DeviceCodeLogin.tscn`). If you've renamed either, update both to match. The explicit `preload(...).instantiate()` form above always works regardless. **See also:** [Authentication](https://docs.cheddaboards.com/api/authentication) · [Signals reference](https://docs.cheddaboards.com/engines/godot-signals) · [Godot quick start](https://docs.cheddaboards.com/quickstart/godot) --- # Timed leaderboards Run weekly competitions, daily challenges, and monthly tournaments — boards that reset on a schedule and archive their final standings so you can show past winners. ## The board types | Type | Resets | Archives kept | Use case | |------|--------|---------------|----------| | **All-time** | Never | — | Career high scores | | **Weekly** | Monday 00:00 UTC | 52 | Weekly competitions | | **Daily** | Midnight UTC | 90 | Daily challenges | | **Monthly** | 1st of month, 00:00 UTC | 12 | Monthly tournaments | | **Custom interval** | Every N days | 52 | Sprints, fortnightly events | Resets happen on calendar boundaries in UTC, not rolling windows — a weekly board always turns over at Monday 00:00 UTC, so every player's week starts and ends at the same moment. > **Timed vs targeted** A board's reset cadence is independent of its write mode. By default a board is **fan-out** (receives every submit); you can instead make it **targeted** so it only receives scores sent to it by ID. Any cadence here works with either mode. See [Category boards](https://docs.cheddaboards.com/concepts/category-boards). ## Creating one In the Developer Console → your game → **Scoreboards** → **Add Scoreboard**: | Field | Example | Description | |-------|---------|-------------| | ID | `weekly-scoreboard` | Unique identifier | | Name | `Weekly Challenge` | Display name | | Reset Period | `Weekly` | When to archive & reset | | Sort By | `Score (high to low)` | Ranking method | For a custom cadence, choose **Custom interval (every N days)** and set the day count — e.g. `3` for a short sprint, `14` for a fortnightly event. Leave it blank and the board never auto-resets (same as All-Time). ## Submitting and reading You don't submit to timed boards individually. Submit once, and the score fans out to every standard board on the game — all-time, weekly, daily — automatically: ```gdscript CheddaBoards.submit_score(score, streak) ``` Read a specific board by ID: ```gdscript CheddaBoards.get_scoreboard("weekly-scoreboard", 100) func _on_scoreboard_loaded(scoreboard_id, config, entries): for entry in entries: print("#%d %s: %d pts" % [entry.rank, entry.nickname, entry.score]) ``` ```bash curl "https://api.cheddaboards.com/games/my-game/scoreboards/weekly-scoreboard?limit=100" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" ``` ## Archives When a timed board resets, its final standings are snapshotted into an **archive** you can read back later — "last week's winners", a hall of fame, and so on. Resetting never throws results away. ### REST endpoints | Endpoint | Returns | |----------|---------| | `GET /games/{gameId}/scoreboards/{id}/archives` | List of archived periods for a board (add `?after=&before=` nanosecond timestamps to filter a date range) | | `GET /games/{gameId}/scoreboards/{id}/archives/latest` | The most recent archive (last week / yesterday / last month) | | `GET /archives/{archiveId}` | One specific archive | | `GET /games/{gameId}/archives/stats` | Archive statistics for the game | ```bash curl "https://api.cheddaboards.com/games/my-game/scoreboards/weekly-scoreboard/archives/latest?limit=10" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" ``` An archive ID has the form `gameId:scoreboardId:timestamp` (the timestamp is nanoseconds, ICP-standard). ### Godot The SDK wraps these with signals and a few convenience calls: ```gdscript # Most recent archived period CheddaBoards.get_last_archived_scoreboard("weekly-scoreboard", 100) CheddaBoards.archived_scoreboard_loaded.connect(_on_archive) func _on_archive(archive_id, config, entries): if entries.is_empty(): return # a brand-new board has no archives yet var winner = entries[0] print("Last week's champion: %s (%d pts)" % [winner.nickname, winner.score]) # Shorthands for the common cases CheddaBoards.get_last_week_scoreboard() CheddaBoards.get_yesterday_scoreboard() CheddaBoards.get_last_month_scoreboard() # List every archived period, e.g. for a hall of fame CheddaBoards.get_scoreboard_archives("weekly-scoreboard") CheddaBoards.archives_list_loaded.connect(_on_list) ``` The archive signals are in the [signals reference](https://docs.cheddaboards.com/engines/godot-signals#scoreboard-archives). The included `Leaderboard.tscn` already wires an All-Time / Weekly toggle and a Current / Last-Period toggle, so on the template you get archive browsing without writing this yourself. ### The config dictionary Scoreboard and archive reads both carry a `config`, but with slightly different fields — a live board knows when it last reset, an archive knows the exact period it covers. **Live board** (`GET /games/{gameId}/scoreboards/{id}`, and the SDK's `scoreboard_loaded`): ``` name display name description board description ("" if none) period the reset cadence: allTime, weekly, … sortBy score | streak lastReset nanosecond timestamp of the current period's start ``` **Archive** (`…/archives/latest`, `/archives/{archiveId}`, and the SDK's `archived_scoreboard_loaded`): ``` name display name period the cadence of the board it came from sortBy score | streak periodStart nanosecond timestamp — when the archived period began periodEnd nanosecond timestamp — when it ended (the reset moment) ``` An archive response also carries its `archiveId` alongside `config`, and both kinds return `entries` plus a `totalEntries` count. Real example, trimmed: ```json { "ok": true, "data": { "archiveId": "my-game:weekly:1789344976813703772", "config": { "name": "weekly", "period": "weekly", "sortBy": "score", "periodStart": 1788739200000000000, "periodEnd": 1789344976813703772 }, "entries": [ { "rank": 1, "nickname": "Chedz", "score": 5148, "streak": 4, "authType": "external", "submittedAt": 1788964834738168040 } ], "totalEntries": 1 } } ``` Timestamps are nanoseconds since epoch — divide by 1,000,000,000 for seconds before handing to `Time.get_datetime_dict_from_unix_time()`. ## Retention | Cadence | Archives kept | |---------|---------------| | Weekly | 52 (a year) | | Daily | 90 | | Monthly | 12 (a year) | | Custom interval | 52 | Archives don't change once written, so they're safe to cache client-side. ## Good practice - **Always keep an all-time board** alongside timed ones — players want their career best, not just this week's. - **Show current and last period together** — "This Week" / "Last Week" is the pairing players expect. - **Celebrate winners** — a crown and a highlight on the #1 archive entry does a lot. **See also:** [Category boards](https://docs.cheddaboards.com/concepts/category-boards) · [What's stored](https://docs.cheddaboards.com/concepts/data-model) · [REST quick start](https://docs.cheddaboards.com/quickstart/rest) · [Godot signals](https://docs.cheddaboards.com/engines/godot-signals) --- # Category boards Run per-level, per-mode, or per-category leaderboards under a single game. ## Two independent dials A CheddaBoards scoreboard has two settings that don't affect each other: | Dial | Options | Controls | |------|---------|----------| | **Write mode** | Fan-out · Targeted | *Which* board a score lands on | | **Reset cadence** | Never · Daily · Weekly · Monthly · Every N days | *When* the board resets — see [Timed leaderboards](https://docs.cheddaboards.com/concepts/timed-leaderboards) | This page is about the first dial. The two are orthogonal: a targeted board can still reset weekly, and a fan-out board can be all-time. | Mode | Receives | Use case | |------|----------|----------| | **Fan-out** (default) | *Every* plain score submit | One overall leaderboard per game | | **Targeted** | *Only* scores addressed to it by ID | `level-01 … level-28`, `boss-rush`, `time-trial`, `runs`, difficulty tiers | A plain submit fans out to every non-targeted board on the game. A **targeted** board is invisible to that fan-out — it only ever receives scores you send to it explicitly, by its ID. That's what lets you run a separate leaderboard per level (or per mode/category) without registering a separate game for each. ## When to use targeted boards - **Per-level boards** — a leaderboard for every level in your game. - **Per-mode boards** — Easy / Normal / Hard, or Solo / Co-op. - **Category boards** — fastest time, longest run, most coins, kept separate from your main score board. If you just want one leaderboard for the whole game (plus optional weekly/daily resets), you don't need targeted boards — stick with fan-out. ## Creating one In the Developer Console → your game → **Scoreboards** → **Create New Scoreboard**: | Field | Example | Description | |-------|---------|-------------| | Scoreboard ID | `level-14` | Unique identifier (lowercase, digits, hyphens) | | Display Name | `Level 14` | Shown in your UI | | **Board Type** | **Targeted** | This is what makes it a category board | | Reset Period | `All Time` | Any cadence works, including every-N-days | | Sort By | `Score (high to low)` | Ranking method | The **Board Type** selector is the whole trick: leave it on *Fan-out* and the board behaves normally; set it to *Targeted* and it drops out of the fan-out and waits for scores sent to its ID. A targeted board ranks scores exactly like any other (keep-highest per player, sorted by score or streak) — "targeted" only changes *which* submits reach it, not how it ranks them. ## Submitting to a targeted board ### REST (any engine) A targeted submit is a normal `POST /scores` with a `scoreboardId` field added: ```bash curl -X POST https://api.cheddaboards.com/scores \ -H "Content-Type: application/json" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" \ -d '{ "playerId": "dev_1730000000_1a2b3c4d", "gameId": "my-game", "score": 1000, "streak": 5, "scoreboardId": "level-14" }' ``` On success the response confirms the board: `"✅ Submitted to level-14 - Score: 1000, Streak: 5"`. If time validation is enabled, include a `playSessionToken` exactly as for a normal submit — targeted submits go through the same anti-cheat gate. Full mechanics: [REST quick start](https://docs.cheddaboards.com/quickstart/rest#submitting-to-one-specific-board-category-targeted-scoreboards). ### Godot ```gdscript # Send this run's score to ONE board, by ID. CheddaBoards.submit_score_to_board("level-14", score, streak) ``` This writes to `level-14` only — no fan-out to your all-time/weekly/daily boards. You can address several boards in one run (e.g. a per-level board plus a shared `runs` board): ```gdscript CheddaBoards.submit_score_to_board("level-14", score, streak) CheddaBoards.submit_score_to_board("runs", score, streak) ``` The submission throttle is keyed per board, so these back-to-back calls won't trip the 2-second rate gate. ## Reading a targeted board No different from any other board — same call, same signal, same REST path: ```gdscript CheddaBoards.scoreboard_loaded.connect(_on_scoreboard_loaded) # once, in _ready() CheddaBoards.get_scoreboard("level-14", 100) func _on_scoreboard_loaded(scoreboard_id, config, entries): for entry in entries: print("#%d %s: %d pts" % [entry.rank, entry.nickname, entry.score]) ``` ```bash curl "https://api.cheddaboards.com/games/my-game/scoreboards/level-14?limit=100" \ -H "X-API-Key: cb_my-game_xxxxxxxxx" \ -H "X-Game-ID: my-game" ``` ## How a targeted submit behaves - **One board only.** It writes to the board you named and nothing else — no fan-out. - **Play count, not bests.** A targeted submit counts toward the player's play count for the game, but the aggregate profile's score/streak bests only move on a plain submit. If you also want the run reflected in the player's overall bests, send a separate `submit_score(...)`. - **Same anti-cheat.** Play-session / time-validation and rate-limit rules are identical to a plain submit. - **Per-board throttle.** The 2-second gate is keyed per (player, game, board), so chaining several board submits in one run is fine. - **Must exist and be targeted.** The board has to exist *and* be marked Targeted. Submitting a `scoreboardId` for a board that doesn't exist returns `"Scoreboard '' not found for this game."` — the API never auto-creates a board on submit. See [Errors](https://docs.cheddaboards.com/api/errors). - **Never target a fan-out board.** Sending the ID of a fan-out board (`all-time`, `weekly`, `daily`…) in `scoreboardId` is rejected — those are updated by a plain submit, so just omit the field. ## Combining with reset cadence Targeted and timed are independent, so you can mix them: - **All-time per-level boards** — `level-01 … level-28`, each Targeted + All Time. Career bests per level. - **Weekly category board** — a `time-trial` board, Targeted + Weekly, archiving each week. - **Every-N-days event** — a `sprint` board, Targeted + Custom interval (e.g. every 3 days). When a targeted board has a reset cadence, it archives on reset just like any timed board — see [Timed leaderboards](https://docs.cheddaboards.com/concepts/timed-leaderboards). ## Good practice - **Keep IDs predictable** — `level-01`, `level-02` (zero-padded) so you can build the board ID programmatically from the current level. - **Decide whether targeted runs also count globally** — they don't update the aggregate bests; send both a targeted and a plain submit if they should. - **Don't over-create boards** — per-level for a 28-level game is fine; per-level for 500 procedural levels probably isn't. - **Pre-create boards in the dashboard** — a targeted submit fails if the board doesn't exist yet. **See also:** [Timed leaderboards](https://docs.cheddaboards.com/concepts/timed-leaderboards) · [REST quick start](https://docs.cheddaboards.com/quickstart/rest) · [What's stored](https://docs.cheddaboards.com/concepts/data-model) · [Errors](https://docs.cheddaboards.com/api/errors) --- # Anti-cheat Anti-cheat is built in and server-side. You set the limits from your dashboard and CheddaBoards enforces them on every submission — no game code required for caps and validation. **Play sessions**, which let the server check a score against real elapsed play time, are the one piece that touches your client: a few calls around each run (the Godot template's wrapper makes them for you). | Protection | How it works | |------------|--------------| | **Score & streak caps** | Reject any single submission above a per-round maximum, and reject scores past an absolute all-time ceiling | | **Time validation** | With a play session, reject scores impossible for the elapsed time | | **Rate limiting** | One submit per player per board every 2 seconds, always on | | **Suspicion log** | Every rejection is recorded for you to review — the player only sees a generic error | ## Configuring limits Set your limits from your game's **Security** tab in the Developer Console, based on your game's mechanics. Caps come in two tiers: - **Per-round caps** — the most a single submission may add (e.g. max 200,000 points and max streak of 10 in one run). This catches a fabricated score in a single submit. - **All-time caps** — an absolute ceiling a player's score or streak can never exceed, no matter how many legitimate runs. This catches slow accumulation past what's humanly possible. There are no default caps: validation is per-game and entirely dashboard-driven, so a fast arcade game and a slow puzzle game can run completely different rules with no code change on either side. Start loose, then tighten as you see real player data. Rate limiting is the exception — the 2-second-per-player-per-board throttle is always on and isn't configurable, since it only blocks bot-speed submission and never legitimate play. ## Play sessions A play session is a server-tracked window around a single run. Start one when the run begins, pass its token when you submit, end it after. That token is what lets the backend compare the score against how long the run actually took, so a client that fabricates a huge score in two seconds gets caught. The lifecycle over REST: 1. `POST /play-sessions/start` → returns a session token 2. `POST /scores` with `"playSessionToken": ""` in the body 3. `POST /play-sessions/end` Full worked example: [REST quick start §4](https://docs.cheddaboards.com/quickstart/rest#_4-anti-cheat-play-sessions-recommended). The SDKs attach the token to every submit for you, but you start and end the session yourself — `start_play_session()` / `StartPlaySession()` when the run begins, `clear_play_session()` / `EndPlaySession()` after the submit (see the [Godot quick start](https://docs.cheddaboards.com/quickstart/godot#step-3-anti-cheat-play-sessions-recommended)). On the [Godot template](https://docs.cheddaboards.com/engines/godot-4), the game wrapper does all of it. **If time validation is off**, session tokens are accepted but not checked — submits succeed with or without one. Wire the lifecycle up anyway: it costs nothing while validation is off, and the day you enable it on the dashboard your scores are already protected instead of suddenly rejected. Sessions are capped per player, so always end them when a run finishes rather than leaving them open. ## What a rejected score looks like When a submission trips a limit, the player gets a **deliberately generic** error: ```json {"ok":false,"error":"rejected by game validation rules"} ``` The specific reason — which cap, by how much, the actual play duration — goes to your **suspicion log**, visible only to you as the game owner. This is intentional: if the error told the client exactly which limit it hit and by how much, a cheater could binary-search your caps until their fake scores slipped under. You get the detail; they get a wall. Surface the generic error to the player as a soft "score not accepted" and check your suspicion log if you want to know what actually happened. ## Choosing caps The caps are yours to tune, and the right values are entirely game-specific. A few principles: - **Set the per-round max just above your best legitimate single run,** with headroom for the exceptional run, and the all-time cap above the highest total a real player could ever accumulate. Too tight and you reject your own top players; too loose and it does nothing. - **Time validation is your strongest tool** for score-attack and endless games, where score scales with time survived — it makes "impossible in the time elapsed" the thing you're actually checking. - **Start loose, watch the suspicion log, tighten.** The log tells you where real submissions cluster, which is the only reliable guide to where the ceiling should sit. - Some games legitimately submit very fast or very often (per-kill boards, presence heartbeats). If yours does, lean on caps rather than expecting tight timing, and keep the per-board throttle in mind when you design submit frequency. **See also:** [REST quick start](https://docs.cheddaboards.com/quickstart/rest) · [Errors](https://docs.cheddaboards.com/api/errors#rejected-by-game-validation-rules) · [Moderation](https://docs.cheddaboards.com/concepts/moderation) · Godot SDK: [signals reference](https://docs.cheddaboards.com/engines/godot-signals) --- # Moderation Remove unwanted entries from your leaderboards — test accounts, junk scores, or anything that shouldn't be there. Available to game owners from the Developer Console. ## What you can do | Action | Scope | Where | |--------|-------|-------| | Delete a single entry | One score on one board | ✕ button on any row | | Wipe a player | All of that player's scores across every board in your game | ✕ button → "All boards in this game" | | Purge archives | Also removes the player's entries from archived daily/weekly boards | Checkbox in the confirm dialog | | View deletion log | Audit trail of every deletion on your game | Deletion Log button on the board view | ## Deleting an entry 1. Open your game's scoreboard in the dashboard. As the owner you see the admin view — the same board, plus a ✕ column. 2. Click ✕ on the entry you want gone. 3. In the **Remove Player Entry** dialog, choose the scope: - **Just this board** — removes that one score from that one board. - **All boards in this game** — wipes every score the player has on every board in this game, and resets their profile stats (score, streak, and play count go to zero). Achievements are kept. 4. Optionally tick **Also purge from daily/weekly archives** to remove them from archived boards too. 5. Confirm. Changes are live immediately — board caches refresh and entry counts update. ## Deletion is not a ban Removing a player's scores does **not** stop them submitting again. If someone is actively spamming your board, deletion is cleanup, not prevention. A per-game blocklist is on the roadmap; until then, the [anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat) parameters (score caps, time validation) in the Security tab are your prevention tools. The delete confirm dialog says as much, so you're never surprised that a wiped player can resubmit. ## The deletion log Every deletion is recorded: what was removed, which board, the scope, whether archives were purged, and when. The log is per-game, visible only to you, and keeps the most recent 2000 records. Player identifiers in the log are **one-way hashes** — emails and account details are never written to it, so the audit trail can't leak a player's identity. (This is the same privacy posture as the rest of the platform: only nicknames are ever public. See [What's stored](https://docs.cheddaboards.com/concepts/data-model).) ## Who can do this Only the game owner — the account that registered the game. Deletion requests from anyone else are rejected, verified server-side rather than just hidden in the UI, so a crafted API call from a non-owner fails too. Works with either sign-in method on the dashboard. ## REST API The same operations are available over the HTTP API with your dashboard session: | Method | Route | Does | |--------|-------|------| | `GET` | `/games/{gameId}/scoreboards/{boardId}/admin` | Admin view of a board (includes player keys) | | `DELETE` | `/games/{gameId}/scoreboards/{boardId}/entries/{playerKey}` | Delete one entry | | `DELETE` | `/games/{gameId}/players/{playerKey}/scores` | Wipe a player from all boards | | `GET` | `/games/{gameId}/deletion-log` | Fetch the deletion log | Add `?archives=true` to the DELETE routes to purge archives as well. The `playerKey` values come from the admin view route — they're stable per player per game, and are the hashed keys, never raw account identifiers. ## FAQ **Can a deleted player come back?** Yes — deletion removes their scores, nothing more. They can resubmit on their next play. **Does wiping a player delete their account?** No. It only clears their data in *your* game. Their account and their progress in other games are untouched. **Can I undo a deletion?** No — deletions are permanent, which is why the confirm dialog exists. The deletion log tells you what was removed if you ever need to check. **See also:** [Anti-cheat](https://docs.cheddaboards.com/concepts/anti-cheat) · [What's stored](https://docs.cheddaboards.com/concepts/data-model) · [Privacy](https://docs.cheddaboards.com/concepts/privacy) --- # Privacy CheddaBoards is built to store the **minimum** needed to run leaderboards, so integrating it puts very little player data in play. This page is the developer's view: what CheddaBoards collects, what that means for your own privacy obligations, and where the canonical policy lives. The full, player-facing policy is at [cheddaboards.com/privacy](https://cheddaboards.com/privacy) — that's the source of truth. This page summarizes it from an integrator's angle and never overrides it. ## What CheddaBoards stores about your players **Anonymous players (the default):** a random device-tied identifier, the nickname, and their scores, streaks, play counts, and achievement IDs. No email, no real name, no account. A player can be on your board without providing any personal information at all. **Signed-in players:** additionally, the email address and provider (Google or Apple) for the linked account — used as sign-in identity and to merge progress across devices. CheddaBoards never receives the player's password; sign-in happens with Google or Apple directly, and only the token they issue is verified. That's the whole set. See [What's stored](https://docs.cheddaboards.com/concepts/data-model) for the field-by-field shape. ## What it deliberately doesn't collect No passwords, no payment info, no advertising identifiers or cross-site tracking, no precise location, no device contents. There are no tracking cookies in the API. This matters to you because it means **integrating CheddaBoards doesn't add an ad-tracking or data-broker surface to your game** — a claim you can make honestly to your own players. ## What's public Leaderboards are public by design: a player's **nickname, scores, streaks, and achievements** are visible to other players, and their cross-game profile carries these between CheddaBoards games. A player's **email is never shown publicly** — only the nickname. Worth surfacing in your own UI so players pick a nickname they're happy to see on a scoreboard. ## Where the data lives Game data is stored on the **Internet Computer** (replicated across independent node providers), and the API layer processes requests in transit. Both are infrastructure processors acting on CheddaTech's behalf, not given data for their own use. You don't run or host any of this — there's no player-data store on your side unless your game keeps its own. ## What this means for your privacy policy If you publish a privacy policy for your game (and on most stores you must), and your game uses CheddaBoards, the honest disclosure is short: - Your game sends a nickname and score data to CheddaBoards, a third-party leaderboard service, to display leaderboards. - If you offer sign-in, players may link a Google or Apple account, and their email is stored by CheddaBoards for that purpose. - Link to CheddaBoards' own policy so players can read the detail: `https://cheddaboards.com/privacy`. A real example: the browser game *Flood of Packages* publishes a short bilingual policy that names CheddaBoards as its leaderboard service, discloses nickname transmission, describes the anonymous local identifier, and gives a deletion-request contact. That's the shape of a good, minimal disclosure. > **You are your players' first contact** CheddaBoards is a processor for the leaderboard data, but **your game is what your players installed.** Deletion requests, questions, and store-review privacy fields are yours to field first. The next section is what you can tell them. ## Deletion and player rights Two routes exist, and it's worth knowing which is which: - **You can remove a player's data from your game yourself** — a single entry or a full wipe across your boards — from the dashboard [moderation](https://docs.cheddaboards.com/concepts/moderation) tools. This is immediate and covers "please remove my score from your game." - **CheddaBoards handles full data-subject requests** (access, correction, deletion, export, restriction under UK GDPR) directly: a player emails info@cheddaboards.com and CheddaTech acts on it. Point a player there for anything beyond removing their scores from your specific game. ## Children Anonymous play collects no personal information. Account linking uses Google or Apple, which carry their own age requirements. CheddaBoards doesn't knowingly collect personal information from children under 13. If your game targets children, keep that in mind when deciding whether to offer account linking at all — anonymous-only is the zero-personal-data option. **See also:** [Full privacy policy](https://cheddaboards.com/privacy) · [What's stored](https://docs.cheddaboards.com/concepts/data-model) · [Moderation](https://docs.cheddaboards.com/concepts/moderation) · [Players and accounts](https://docs.cheddaboards.com/concepts/accounts) --- # Self-hosting CheddaBoards is open source. The backend canister — all the leaderboard, auth, achievement, moderation, and anti-cheat logic — is public and MIT-licensed, and you can deploy your own instance to the Internet Computer. This section is how. It's worth being upfront about what self-hosting is and isn't. ## What's open, what you build | Piece | Status | |-------|--------| | **The backend canister** (`main.mo`, the Candid interface) | Open source — [github.com/cheddatech/cheddaboards](https://github.com/cheddatech/cheddaboards). Deploy your own. | | **The SDKs** (Godot, Unity) | Open source. Point them at your proxy. | | **The proxy / API layer** | **Closed source.** The hosted proxy at cheddaboards.com is not part of the open-source release — you build your own. | So self-hosting is two jobs: deploy the canister ([canister guide](https://docs.cheddaboards.com/self-hosting/canister)), then build an HTTP proxy in front of it that verifies OAuth tokens and signs canister calls with your verifier identity ([proxy guide](https://docs.cheddaboards.com/self-hosting/proxy)). > **The public repo trails the live canister** The open-source backend is kept a release or two behind the canister running the hosted service — the public repo is synced in batches, not on every deploy. So a fresh self-host gets a stable, real version of the backend, just not always the newest one that's live at cheddaboards.com. The interface is stable; the gap is in fixes and features that haven't been pushed to the public mirror yet. ## Be honest with yourself about the effort This is not a one-click deploy or a Docker template. Running your own instance means: - You're comfortable with **dfx and the Internet Computer** — deploying, upgrading, and managing a canister, and paying its cycles. - You'll **write and host your own proxy** — the canister is deliberately gated so it only accepts privileged auth calls from a signing identity you control, which means there's an HTTP layer you have to build and run. There's no reference proxy to copy. - You'll **manage your own OAuth credentials, CORS, secrets, and key rotation.** - **You're on your own for support.** The whole stack is available and the interface is documented, but self-hosting is a "here are the parts, assemble them" arrangement, not a supported product. If that sounds like more than you want to take on, the [hosted service](https://cheddaboards.com) is free, runs the same backend, and takes about three minutes to wire up — that's the recommended path for almost everyone. Self-hosting exists because infrastructure you build a game on shouldn't be something that can be taken away, not because it's the easy option. ## Why it's built this way The canister is the permanent, on-chain part — it holds the data and the rules, and it runs on the IC regardless of any company. The proxy is a thin translation layer: it turns plain HTTP into canister calls and verifies OAuth tokens before minting sessions. Keeping the two separate is what lets the canister be fully open (nothing secret lives in it) while the trust boundary — the signing identity that's allowed to mint sessions — stays under whoever operates the instance. When you self-host, that identity is yours. **Next:** [Deploy the canister](https://docs.cheddaboards.com/self-hosting/canister) · [Build your proxy](https://docs.cheddaboards.com/self-hosting/proxy) --- # Deploy the canister Deploying your own CheddaBoards backend to the Internet Computer. This is the first of the two self-hosting jobs; the second is [building your proxy](https://docs.cheddaboards.com/self-hosting/proxy). ## Prerequisites - [dfx](https://internetcomputer.org/docs/current/developer-docs/setup/install/) (the IC SDK) - Basic familiarity with Motoko and the Internet Computer - For mainnet: a cycles wallet with enough cycles to create and run a canister ## 1. Clone ```bash git clone https://github.com/cheddatech/cheddaboards.git cd cheddaboards ``` The repo is the backend: `src/main.mo` (the canister logic) and `src/cheddaboards.did` (the Candid interface — the full API contract). ## 2. Set your principals Open `src/main.mo` and replace the placeholder principals (`aaaaa-aa`) with your own: ```motoko // Your proxy's signing identity — the only principal allowed to call // the privileged auth methods. REPLACE with your own. private transient let VERIFIER_PRINCIPAL : Text = "aaaaa-aa"; // Super admin (your dfx identity) — REPLACE with your own private var CONTROLLER : Principal = Principal.fromText("aaaaa-aa"); // Bootstrap admin in postupgrade() (can be the same as controller) — REPLACE let firstAdmin = Principal.fromText("aaaaa-aa"); ``` Get your own principal with: ```bash dfx identity get-principal ``` The `VERIFIER_PRINCIPAL` is the principal of the signing identity your **proxy** will use — you'll generate that keypair when you build the proxy, so if you don't have it yet, come back and set it before you rely on auth. `CONTROLLER` and `firstAdmin` are your dfx identity (the admin of the instance). > **Get CONTROLLER right the first time** The actor is `persistent`, so top-level variables are stable and survive upgrades — editing a literal after the first deploy won't change the running value. `VERIFIER_PRINCIPAL` is re-applied in `postupgrade()`, so it *can* be rotated by upgrading. `CONTROLLER` is not, so set it correctly before your first deploy. ## 3. Deploy ```bash # Local testing dfx start --background dfx deploy # Production (mainnet) dfx deploy --network ic ``` Note the canister ID it prints — your proxy will need it. ## 4. Generate the Candid bindings ```bash dfx generate cheddaboards_v2_backend ``` The Candid interface (`cheddaboards.did`) defines every method and signature your proxy can call. Every deployed canister also gets the Candid UI for free, so you can exercise the API directly in a browser against your own instance while you build the proxy. ## What you've got, and what's next At this point you have a running canister with all the leaderboard, achievement, moderation, and anti-cheat logic — but **games can't reach it yet.** The canister only accepts privileged auth calls from the `VERIFIER_PRINCIPAL`, and it speaks the IC's binary interface, not plain HTTP. Standing up the HTTP layer that games actually talk to is the [proxy](https://docs.cheddaboards.com/self-hosting/proxy) job. Board *reads* are the exception — the canister serves those over HTTP directly (that's how the SDKs read boards without the proxy), so a freshly deployed canister can already be read from. Everything that writes or authenticates goes through your proxy. **Next:** [Build your proxy](https://docs.cheddaboards.com/self-hosting/proxy) --- # Build your proxy The proxy is the HTTP layer games talk to. It verifies OAuth tokens, signs canister calls with your verifier identity, and translates plain HTTP into the canister's binary interface. **You build and host this yourself.** The hosted proxy at cheddaboards.com is closed source — it's not in the open-source release and there's no reference implementation to copy. What follows is the contract your proxy must satisfy, not the hosted proxy's code. ## What the proxy is for The canister is deliberately gated: it only accepts privileged auth calls (minting sessions, etc.) from one principal — the **verifier**. That gate is the whole security model. It means a game client can't call the canister directly to forge a session; it has to go through something that holds the verifier's signing key and has verified the player first. That something is your proxy. So the proxy does two jobs the canister can't do for itself: 1. **Verify OAuth tokens.** When a player signs in with Google or Apple, the proxy checks the token with the provider (e.g. via JWKS) before it will mint anything. The canister trusts the proxy to have done this. 2. **Sign canister calls as the verifier.** The proxy calls the canister with the signing identity whose principal you set as `VERIFIER_PRINCIPAL`. The canister rejects those privileged calls from anyone else. ## What your proxy needs However you choose to build and configure it, a working proxy needs: - **The canister ID** of your deployed instance, and an IC host — `https://icp-api.io` for mainnet, or `http://127.0.0.1:4943` for local dfx. - **A signing identity** (e.g. an Ed25519 keypair) whose principal you set as `VERIFIER_PRINCIPAL` in `main.mo`. **Keep the private key secret** — it can mint a session for any user, so it's the most sensitive thing in your deployment. Treat it like a root credential: never commit it, store it encrypted, and have a rotation plan (the canister lets you rotate the verifier by upgrading, since it's re-applied in `postupgrade()`). - **OAuth client IDs** for whichever providers you support — Google client ID, Apple Service ID / Bundle ID — and the logic to verify tokens against them. - **Allowed CORS origins** for the domains your games are served from. Avoid wildcards in production. ## The interface it calls The Candid interface (`cheddaboards.did`) defines every method and signature. The methods your proxy will lean on, by area: - **Auth:** `socialLoginAndGetProfile`, `createSessionForVerifiedUser`, `validateSession`, `destroySession` - **Scores:** `submitScore` (fan-out), `submitScoreToBoard` (targeted), `getLeaderboard`, `getScoreboard`, `getPlayerScoreboardRank` - **Sessions:** `startGameSessionByApiKey`, `startGameSessionBySession`, `getPlaySessionStatus` - **Profiles:** `getMyProfileBySession`, `getUserProfile`, `changeNicknameAndGetProfile` - **Account linking:** `migrateAnonymousAccount` - **Moderation:** `getScoreboardAdmin`, `removeScoreEntry`, `removePlayerScores`, `getEntryDeletionLog` (each in session and principal variants) - **Achievements:** `unlockAchievement`, `getAchievements` Most methods come in two flavours — a principal-authenticated variant and a `BySession` variant — because the dashboard authenticates by principal while game clients authenticate by session token. Match the variant to how the caller is authing. > **Keep your Candid bindings in sync with the canister** Your proxy calls the canister through generated Candid bindings (`.did.js` / the declarations from `dfx generate`). Those bindings must contain **every method your proxy calls** — if the proxy calls a method that isn't in its copy of the interface, the call throws at runtime with an unhelpful error, not a clear "unknown method". So whenever you upgrade the canister with new or changed methods, regenerate the bindings and redeploy the proxy with the fresh copy. A canister/proxy interface mismatch is one of the easier ways to break a working deployment. ## The HTTP shape is yours to define There's no required URL scheme — your proxy exposes whatever HTTP routes you like and maps them onto canister calls. If you want the **official SDKs to work against your instance unmodified**, mirror the routes they expect (the [REST reference](https://docs.cheddaboards.com/api/overview) documents the hosted API's shape). If you're only serving your own games, you're free to design the HTTP surface however suits you. ## Device-code login The device-code sign-in flow (the QR / short-code login) lives **in the proxy layer, not the canister.** If you want it, you implement it in your proxy — issuing codes, holding pending-authorization state, and polling. The canister provides the session primitives (`createSessionForVerifiedUser` and friends); the RFC 8628 dance around them is the proxy's job. ## Honestly, though Building a correct, secure proxy — token verification, key custody, CORS, rate limiting, session handling — is real work, and getting the verifier-key handling wrong undermines the whole gate. The [hosted service](https://cheddaboards.com) exists so you don't have to. Self-host if you specifically want to own the whole stack; otherwise the free hosted proxy runs this exact backend for you. **See also:** [Self-hosting overview](https://docs.cheddaboards.com/self-hosting/overview) · [Deploy the canister](https://docs.cheddaboards.com/self-hosting/canister) · [API reference](https://docs.cheddaboards.com/api/overview) ---