# Achievements

Every game on `play.unikia.es` can award achievements. Achievements live in the
player's browser: a game needs no account and no server for them. The Hub reads them to
show a Steam-like overview across all games.

This page is the contract. The SDK, `@unikia/upe-sdk`, is the reference implementation; a
game may copy it, port it, or write its own as long as it follows the rules below.

## Why this works without a backend

All games are served from one origin (`https://play.unikia.es/<path>/`), so they share
one `localStorage`. A game writes its unlocks there and the Hub page at `/` reads
them. Running a game on its own (for example `vite dev` on `localhost:5173`) still
works; the unlocks just stay on that origin.

## Game id

Each game has a stable **game id**: `id` in its [`unihub.manifest.json`](manifest.md), for
example `heated-heist`. It never changes once published, even if the game is renamed or
moves to another path.

| Game | Game id | Path |
|---|---|---|
| Torture Chamber | `torture-chamber` | `/torture-game/` |
| Embers of Hollowmaw | `embers-of-hollowmaw` | `/rpg-game/` |
| Level Dice | `level-dice` | `/dice-game/` |
| Signal Fire | `signal-fire` | `/metroidvania-game/` |
| Heated Heist | `heated-heist` | `/heist-game/` |
| Re-Elect Me! | `re-elect-me` | `/city-builder-game/` |
| Demonic Maze | `demonic-maze` | `/maze-game/` |
| Orbion | `orbion` | `/orbion/` |

## Definitions: `achievements.json`

Each game ships `achievements.json` at the root of its build, next to its
`index.html` (in a Vite project: `public/achievements.json`), and names it as
`achievements` in the manifest. The Hub embeds the definitions in its catalogue when the
game is published, so it can list locked achievements of games the player has never opened.
The JSON Schema [`achievements.v1.json`](https://play.unikia.es/schema/achievements.v1.json)
holds these rules.

```json
{
  "v": 1,
  "game": "heated-heist",
  "achievements": [
    {
      "id": "first-score",
      "title": "First Score",
      "description": "Escape with the loot from any house.",
      "icon": "💰",
      "points": 10
    },
    {
      "id": "career-criminal",
      "title": "Career Criminal",
      "description": "Steal $500,000 in total.",
      "icon": "🏦",
      "points": 25,
      "goal": 500000,
      "format": "money"
    }
  ]
}
```

| Field | Required | Meaning |
|---|---|---|
| `v` | yes | Spec version, `1`. |
| `game` | yes | The game id. |
| `achievements[].id` | yes | Stable id, `a-z`, `0-9` and `-`, unique within the game. Never reuse or rename a published id: players' unlocks are keyed by it. |
| `title` | yes | Short name, at most 40 characters. |
| `description` | yes | What to do, one sentence. |
| `icon` | no | One emoji. The Hub falls back to a trophy. |
| `points` | no | `5`, `10`, `25` or `50`. Defaults to `10`. Roughly: 5 = happens naturally, 10 = a normal goal, 25 = takes skill or time, 50 = mastery. |
| `hidden` | no | `true` hides the title and description as "???" until unlocked. Use it for spoilers and jokes, not for normal goals. |
| `goal` | no | Makes it a progress achievement that unlocks when the progress reaches this number. |
| `format` | no | How to print progress: `"count"` (default), `"money"` (`$12,500`) or `"time"` (seconds as `m:ss`). Only with a `goal`. |

Order in the file is the display order. Removing an achievement is allowed (old
unlocks are ignored); adding new ones at any time is expected.

With the SDK:

```ts
import { Uniplay } from '@unikia/upe-sdk';

const uniplay = new Uniplay({ game: 'heated-heist' });
uniplay.achievements.unlock('first-score');
uniplay.achievements.addProgress('career-criminal', 12500);
uniplay.achievements.setProgress('career-criminal', 182000);
```

## Platinum

A player who unlocks every achievement of a game earns its **platinum**: the game's badge on their
profile, in colour instead of greyed out, and 100 more points in every points total the Hub
shows. Give the game a badge with `"badge"` in its [manifest](manifest.md); without one the Hub
cuts a round badge from the cover. Adding achievements later takes platinum away again until
they're unlocked too.

## Unlocks: `localStorage`

Key: `uniplay:achievements:<game id>`. Value: JSON.

```json
{
  "v": 1,
  "unlocked": { "first-score": 1790000000000 },
  "progress": { "career-criminal": 182000 }
}
```

- `unlocked` maps an achievement id to the unlock time (milliseconds since epoch).
- `progress` maps a progress achievement id to its current value. Progress only
  moves up; a write never lowers it.
- Writers must re-read the key right before writing and merge (union of `unlocked`,
  keeping the earliest timestamp; maximum of each `progress`), because the same game
  may be open in two tabs.
- Readers must ignore unknown ids, unknown fields and malformed values, and treat a
  missing or unparsable key as "nothing unlocked".
- Storage can be unavailable (private mode, blocked storage). Games must keep working
  and simply not record achievements.

Each game writes only its own key. The Hub page only reads, and copies unlocks to the
player's account and back when they're signed in, merging them the same way.

## Unlock notification

When an achievement unlocks, the game shows a short, non-blocking notification
("Achievement unlocked" + icon + title) for about 5 seconds. The SDK draws one in the
top-right corner on top of the game, or in the Hub's alert corner when the game runs
[inside the Hub](hub.md#the-hub-shell). A game may draw its own instead, in its own
style, as long as it shows the title.

## Live updates

The Hub listens to the `storage` event, so unlocks made in another tab show up
without a reload. Games do not need to do anything for this.
