---
name: g-less-leaderboard
description: Add a leaderboard to an HTML5 / Unity WebGL / Godot Web game that is uploaded to the G-Less platform (g-less). Use whenever the user says things like "add a leaderboard", "submit score", "high score board", "g-less leaderboard", or their game is hosted at /games/<id> on G-Less. Do NOT use for games that will never be embedded in the G-Less iframe.
---

# G-Less Leaderboard Skill

This skill helps you wire up a leaderboard for a web game that will be uploaded to and played through the G-Less platform. The G-Less platform embeds every game in an iframe on its own game-detail page. Leaderboard traffic is proxied by the parent page, so **the game never holds any auth tokens, API keys, or app IDs**.

## When to use

Use this skill only when **all** of the following are true:

1. The game is (or will be) uploaded to https://g-less.com as a web-playable game (hosted ZIP or external web link that the G-Less site embeds).
2. The game is meant to be played inside the G-Less iframe. If the user wants a leaderboard that also works on their own website unrelated to G-Less, stop and tell them: external-origin leaderboards are not yet supported.
3. The user wants signed-in players to be able to submit scores. Unsigned visitors can still play but their scores will not be accepted.

If any of the above is false, stop and ask the user to clarify.

## Security model (read this before writing code)

- All API traffic is **postMessage to `window.parent`**, not HTTP.
- The parent page (G-Less) attaches the signed-in user's real Supabase session when it forwards the request. The game itself has no way to spoof `user_id` or bypass auth. Do **not** invent an app ID / secret / appid parameter in the game code.
- The game `submit()` always resolves; it never throws. Always await the result and branch on the returned `error`, `hidden`, or `rank` fields.
- Anti-cheat rules are enforced server-side. Your job in the game is to **not feed the cheater**: see "Anti-cheat rules for AI" below.

## Installation

Add exactly one script tag to the game's main HTML file, above any game code that uses it. Serve it directly from G-Less so updates reach you automatically:

```html
<script src="https://g-less.com/sdk/leaderboard.js"></script>
```

No npm install, no build step, no import. The SDK attaches itself to `window.GlessLeaderboard`. **There is no app ID, no API key, and no secret to configure** — the game's identity is handed to the SDK by the G-Less parent page on handshake, so the same file works for every game.

## Multiple leaderboards per game (Steam-style)

A single game can own **any number of leaderboards**, addressed by a string
`boardKey`. This is directly equivalent to Steam's `FindOrCreateLeaderboard(name, sortMethod, displayType)`:

| Steam SDK | G-Less |
| --- | --- |
| `FindOrCreateLeaderboard(name, sort, displayType)` | `submit(boardKey, ...)` auto-creates; owner dashboard also lets you pre-configure |
| leaderboard `name` | `boardKey` (regex `^[a-z0-9][a-z0-9_-]{0,31}$`) |
| `k_ELeaderboardSortMethod{Ascending,Descending}` | `sort_direction` `asc` / `desc` (per board) |
| `k_ELeaderboardDisplayType{Numeric,TimeSeconds,TimeMilliSeconds}` | `score_format` `integer` / `time_ms` / `float` (per board) |
| `UploadLeaderboardScore(handle, k_ELeaderboardUploadScoreMethodKeepBest, ...)` | `submit()` — keep-best is the only mode; a lower/higher score never overwrites a better one |
| `DownloadLeaderboardEntries(handle, req, start, end)` | `getTop(boardKey, { limit, scope })` |

Typical setup for one game:

```js
await window.GlessLeaderboard.submit("main", totalScore);           // classic high-score (desc / integer)
await window.GlessLeaderboard.submit("speedrun", clearMs);          // fastest clear (asc / time_ms)
await window.GlessLeaderboard.submit("distance", metersTraveled);   // farthest run (desc / float)
```

There is no hard cap on how many leaderboards a game can have. Use a fresh
`boardKey` for every distinct ranking your game wants to expose.

## Testing the integration (owner preview)

You do not need to wait for moderation to verify your integration.

1. Upload your game to G-Less as usual (it enters `pending`).
2. Sign in with the same account on G-Less.
3. Open `https://g-less.com/games/<your-game-id>` — you'll see a yellow
   `// PREVIEW MODE` banner. Nobody else can see that page.
4. Play your game in the embed and call `submit()`. The request goes
   through the same server-side anti-cheat path, but the row is stored
   with `hidden=true` and `meta.__preview=true` so it never shows on the
   public board. The API response carries `preview:true` so your game UI
   can label the result accordingly.
5. `getTop()` in this mode returns only your own preview rows so you can
   verify the read path too.

Once the game is approved, delete the preview rows in bulk from
`/my-games/<your-game-id>/leaderboards` before your players start
competing.

## Quick start

```js
// Call once, early. Safe to await even if the game is opened outside G-Less —
// init() resolves with { available: false } there and all subsequent calls
// become no-ops.
const session = await window.GlessLeaderboard.init();

if (session.available && session.isLoggedIn) {
  console.log("G-Less user:", session.user.name);
}

// On game over:
async function onGameOver(finalScore) {
  const res = await window.GlessLeaderboard.submit("main", finalScore);
  if (res.error === "not_logged_in") {
    showToast("Sign in on G-Less to save your score.");
  } else if (res.error) {
    // Submission failed for another reason; keep going, don't spam retries.
    console.warn("leaderboard submit failed:", res.error);
  } else if (res.hidden) {
    // Score was accepted but flagged as an outlier. Tell the player so they
    // don't think the game froze.
    showToast("Score saved, but held for review.");
  } else {
    showToast(`Rank #${res.rank} with ${res.best}!`);
  }
}

// Optional: fetch and render the top 10 inside the game UI.
const { entries, me } = await window.GlessLeaderboard.getTop("main", { limit: 10, scope: "all" });
```

## API reference

### `init()`
- Returns a Promise that resolves to:
  ```ts
  {
    available: boolean,     // false when NOT running inside the G-Less iframe
    isLoggedIn: boolean,
    user: { id: string, name: string | null } | null,
    gameId: string | null,
    playStartedAt: string | null  // ISO timestamp the parent assigned
  }
  ```
- Call it once on startup. Cached internally; safe to call again.

### `submit(boardKey, score, meta?)`
- `boardKey`: lowercase slug matching `/^[a-z0-9][a-z0-9_-]{0,31}$/`. Use short, meaningful keys like `"main"`, `"endless"`, `"speedrun_any"`. The board auto-creates on first submit.
- `score`: a finite number. For time-based boards (board configured as `time_ms`), submit elapsed time in milliseconds and set direction lower-is-better in the G-Less dashboard.
- `meta`: optional object with simple fields (level, character, etc). Max 16 keys, values must be primitives under 256 chars.
- Resolves to:
  ```ts
  // success (HTTP 200, row written):
  { submitted: boolean, best: number, hidden: boolean, rank: number | null,
    preview?: true, board: { board_key, sort_direction, score_format } }
  // or failure (never throws):
  { error: string, detail?: string, retry_after_seconds?: number, max_score?: number }
  ```
- **`submitted:false` is not an error** — it means the server accepted the
  payload but your new score was worse than the player's existing best for
  that board, so nothing was written. Treat this as a normal outcome
  ("personal best not beaten"), not as a bug.
- **`hidden:true` is not an error either** — the score was saved but flagged
  by anti-cheat as an outlier and is not listed on the public board. Show a
  "held for review" style message, never a "#1!" celebration.
- See the [error code reference](#error-code-reference) below for every
  possible value of `error`.

### `getTop(boardKey, { limit?, scope? })`
- `scope`: `"all" | "month" | "week" | "day"`, defaults to `"all"`.
- Resolves to `{ board, entries: [{ rank, name, avatar_url, score, created_at }], me, scope }` or `{ error }`.
- Call it once per `boardKey` you want to render (Steam has no "multi-board read" either):
  ```js
  const classic = await window.GlessLeaderboard.getTop("main", { limit: 10 });
  const daily   = await window.GlessLeaderboard.getTop("main", { limit: 10, scope: "day" });
  const sprint  = await window.GlessLeaderboard.getTop("speedrun", { limit: 5 });
  ```

### `isAvailable()`
- Synchronous. Returns `false` if the game is opened outside G-Less.
- Use it to hide leaderboard UI gracefully when playing locally during development.

### `getSession()`
- Returns the cached init response, or `null` if `init()` hasn't resolved yet.

## Anti-cheat rules for AI (important)

When writing game code that uses this SDK, **follow these**:

1. **Submit at natural milestones** (death, level complete, run end). Do not batch up local progress and replay it at startup — the server has minimum play duration checks and will reject submits that arrive too soon after `playStartedAt`.
2. **Never** read a "saved high score" from `localStorage` and then submit it. Submit only scores produced by the current play session.
3. **Do not** expose the score in URL hash / query string / form fields in a way the player can trivially edit before submit. Keep it in closure scope.
4. **Do not** send negative scores for descending boards or giant outliers — the server will auto-hide them. That means the player sees a "held for review" state, not a "you're #1" state.
5. For time-based boards, start the timer on the first actual player input, not on page load. Pause the timer when the game pauses.
6. If the player leaves and comes back much later, call `init()` again to refresh `playStartedAt` before submitting.
7. Never encode a user id into the `meta` payload as a substitute for auth — the server always uses the signed-in user's id. Whatever you put in `meta` is treated as untrusted game-supplied data.

## Common board conventions

- `main` — the one canonical high-score board
- `endless` — endless/arcade mode
- `speedrun_any` — any%, ascending
- `daily_<YYYYMMDD>` — daily challenge (auto-creates; ask game owner to disable old ones periodically)
- `hardcore` — separate board for permadeath mode

## Board configuration

Game owners can configure per-board options in the G-Less dashboard at `/my-games/<gameId>/leaderboards`:
- `sort_direction`: `desc` (higher is better, default) or `asc` (lower is better, e.g. speedruns)
- `score_format`: `integer` (default), `time_ms`, or `float`
- `min_play_ms`: how long the player must be in-session before a submit counts (default 3000)
- `max_score`: optional hard cap above which submissions are rejected outright

If you know up front the game should be a speedrun / time board, tell the user to flip `sort_direction` and `score_format` in that dashboard — the SDK itself has no knob for it, because the board is the source of truth.

## Detecting a missing or failed submission

The SDK is deliberately fail-soft: `submit()` **never throws**, never alerts,
never reloads the page. That means if you forget to inspect the return value
the failure will look exactly like a success. Wire up your end-of-run code
like this so silent failures are impossible:

```js
async function onGameOver(score) {
  // 1. Snapshot: did we even try?
  let res;
  try {
    res = await window.GlessLeaderboard.submit("main", score);
  } catch (e) {
    // This branch should be unreachable — the SDK swallows errors —
    // but we keep it so a future SDK bug can't silently vanish.
    console.error("[GlessLB] submit threw (unexpected):", e);
    return showToast("Could not reach leaderboard service.");
  }

  // 2. Did the server reject the submission?
  if (res.error) return handleLeaderboardError(res);

  // 3. Was the score accepted but held by anti-cheat?
  if (res.hidden) return showToast("Score saved — held for review.");

  // 4. Was the score actually written? (It may be a worse-than-best attempt.)
  if (!res.submitted) return showToast(`Personal best still ${res.best}.`);

  // 5. Real success.
  showToast(`Rank #${res.rank ?? "?"} with ${res.best}!`);
}

function handleLeaderboardError(res) {
  switch (res.error) {
    case "unavailable":        // opened outside G-Less
    case "not_logged_in":      return showToast("Sign in on G-Less to save your score.");
    case "rate_limited":       return showToast(`Too fast — try again in ${res.retry_after_seconds ?? 60}s.`);
    case "play_too_short":     return showToast("Play a bit longer before submitting.");
    case "hard_cap_exceeded":  return showToast(`Score exceeds the board cap (${res.max_score}).`);
    case "board_disabled":     return showToast("This leaderboard is currently disabled.");
    case "invalid_score":      console.warn("[GlessLB] bad score payload:", res.detail); return;
    case "bridge_timeout":     console.warn("[GlessLB] parent did not respond. Is the game embedded?"); return;
    default:                   console.warn("[GlessLB] submit failed:", res.error, res.detail); return;
  }
}
```

### Guarding against "I forgot to call submit()"

A common integration bug is shipping a build where the game-over hook was
renamed but `submit()` stayed wired to the old event. Use this tiny dev-mode
watchdog while you're iterating:

```js
if (location.search.includes("lbdebug")) {
  const orig = window.GlessLeaderboard.submit;
  let lastCall = 0;
  window.GlessLeaderboard.submit = async (...args) => {
    lastCall = Date.now();
    const r = await orig(...args);
    console.log("[GlessLB] submit", args, "→", r);
    return r;
  };
  // Call this from your game-over handler too:
  window.__lbAssertSubmittedRecently = () => {
    if (Date.now() - lastCall > 2000) {
      console.error("[GlessLB] game ended but submit() was not called in the last 2s. Forgotten integration?");
    }
  };
}
```

Open the game with `?lbdebug` appended to the URL and watch the console
during a full run. Every `submit()` attempt will be logged with its exact
payload and resolved response.

### "My score didn't appear on the board" — decision tree

Walk these in order; the first match is your answer.

1. **No network request was sent at all.**
   - `GlessLeaderboard.isAvailable()` is `false` → the game isn't inside the
     G-Less iframe (local dev / external host). Expected.
   - `init()` was never awaited → call it on startup.
   - `submit()` was never reached in your code path → see the watchdog above.
2. **Request sent, got `{ error: ... }`.** → Look up the code in the table
   below. The SDK already includes `detail`, `retry_after_seconds`, and
   `max_score` where relevant.
3. **Request sent, got a success response, but nothing on the public board.**
   - `hidden: true` → anti-cheat flagged it as an outlier. Owner can unhide
     in `/my-games/<gameId>/leaderboards`.
   - `preview: true` → you're in owner preview mode (game not yet approved).
     Only you can see these rows; they have `meta.__preview=true`.
   - `submitted: false` → the player's existing best is still better; the
     server is correctly rejecting the worse attempt.
4. **Nothing above fits.** Open DevTools → Network and look at
   `/api/leaderboard/submit` / `/scores` directly. The HTTP status plus the
   JSON body tells you exactly which branch in the server fired.

## Error code reference

Every possible `error` string you can observe in the game. The SDK never
invents new ones; this list is authoritative.

The SDK also forwards any additional fields the server attached to the
error (such as `retry_after_seconds`, `max_score`, `detail`) as top-level
keys on the returned object, so `res.retry_after_seconds` etc. are safe to
read directly.

### From `submit()`

| code                  | origin  | what happened                                              | what the game should do                                  |
| --------------------- | ------- | ---------------------------------------------------------- | -------------------------------------------------------- |
| `unavailable`         | SDK     | not inside G-Less iframe                                   | hide leaderboard UI; skip further submits                |
| `not_logged_in`       | parent  | visitor is anonymous                                       | show soft "Sign in on G-Less" prompt                     |
| `bridge_timeout`      | SDK     | parent page didn't answer within 5s                        | treat as transient; do not auto-retry in a tight loop    |
| `postmessage_failed`  | SDK     | `window.parent.postMessage` threw (detached/blocked)       | same as above; also check X-Frame-Options / CSP headers  |
| `network_error`       | parent  | browser-level fetch failure                                | same as above                                            |
| `submit_failed`       | parent  | non-2xx from `/api/leaderboard/submit` with no error body  | transient; show generic "try again later"                |
| `invalid_board_key`   | SDK/svr | `boardKey` failed `^[a-z0-9][a-z0-9_-]{0,31}$`             | fix the string literal in your code                      |
| `invalid_score`       | SDK/svr | score is NaN / Infinity / wrong type                       | check what you're passing; fix `score_format` if needed  |
| `game_id_required`    | server  | parent failed to inject `gameId` (bug, should be rare)     | reload the page; file a bug if reproducible              |
| `game_not_found`      | server  | game not public and caller is not the owner                | expected on unlisted/removed games; hide the UI          |
| `board_unavailable`   | server  | board could not be auto-created                            | contact support — indicates a DB-side problem            |
| `board_disabled`      | server  | game owner turned the board off                            | hide UI for that `boardKey`                              |
| `play_too_short`      | server  | submitted faster than the board's `min_play_ms`            | start your timer on first input; don't batch-submit      |
| `hard_cap_exceeded`   | server  | score > board's `max_score`                                | your game is producing impossible scores — investigate   |
| `rate_limited`        | server  | > N submits/minute from this user+game                     | back off `retry_after_seconds` (default 60)              |
| `unauthorized`        | server  | session was invalidated between bridge handshake and write | call `init()` again; ask user to re-auth                 |
| `insert_failed`       | server  | DB write error                                             | transient; surface as generic "try again"                |
| `update_failed`       | server  | DB update error                                            | same                                                     |
| `server_misconfigured`| server  | deployment is missing env vars                             | platform-side; nothing the game can fix                  |
| `method_not_allowed`  | server  | wrong HTTP method                                          | indicates an SDK regression; report it                   |

### From `getTop()`

| code                  | origin  | meaning                                                    |
| --------------------- | ------- | ---------------------------------------------------------- |
| `unavailable`         | SDK     | not inside G-Less iframe                                   |
| `bridge_timeout`      | SDK     | parent page didn't answer within 5s                        |
| `invalid_board_key`   | SDK/svr | bad `boardKey` format                                      |
| `board_not_found`     | server  | no board with that key exists on this game                 |
| `board_disabled`      | server  | owner disabled the board                                   |
| `query_failed`        | server  | DB read error                                              |

### Non-error success signals to also branch on

| field in a 200 response | meaning                                                 |
| ----------------------- | ------------------------------------------------------- |
| `submitted: false`      | accepted, but your score was not a new personal best    |
| `hidden: true`          | saved but flagged as an outlier; not on public board    |
| `preview: true`         | owner-preview write against an unapproved game          |
| `rank: null`            | score was accepted but not ranked (hidden or preview)   |

Treating all three of `error`, `hidden`, and `submitted === false` as
distinct outcomes is what turns "I pushed to prod and silently got no data"
into "I pushed to prod and my UI told me exactly why."

## Full minimal example (Phaser-style pseudocode)

```js
<script src="https://g-less.com/sdk/leaderboard.js"></script>
<script>
  let lbReady = false;
  window.addEventListener("load", async () => {
    const session = await window.GlessLeaderboard.init();
    lbReady = session.available;
  });

  game.events.on("gameover", async ({ score }) => {
    if (!lbReady) return;
    const res = await window.GlessLeaderboard.submit("main", score);
    if (!res.error && !res.hidden) {
      scene.showRank(res.rank, res.best);
    }
  });
</script>
```

That's the whole integration. No keys, no config, no build step. The hard work is in the parent page and the API — the game's responsibility is just to send honest scores at honest times.
