Docs/Avatars

Avatars

Every player (guest or registered) has an avatar. The API renders avatars as SVG server-side, so frontends never bundle an icon pack, and cross-realm surfaces (katforge.com header, Stumper.gg leaderboard, Lextris leaderboard) all point at the same URL for the same player.

The three sources

players.avatar_src chooses which renderer the API uses. Resolver priority is enforced server-side; the client just requests the URL.

avatar_srcWhat the API returnsWhen to use
'custom'Composed SVG of the claimed game-icon + chosen colorsUser picked an icon in the dashboard
'discord' / 'google' / 'apple' / 'steam'Proxied bytes from the provider CDN (cached 24h, falls through to the generated icon if upstream fails)User linked the provider and chose to use its avatar
nullComposed SVG of a deterministic icon seeded from player.idDefault for guests and never-customized accounts

The API guarantees a valid image on every request. Provider avatars are proxied through api.katforge.com with a 24h cache keyed on the upstream URL hash — this isolates leaderboards from upstream provider downtime, keeps total provider-CDN traffic minimal, and gives every realm the same TTL. Any upstream failure (timeout, 4xx, 5xx) falls through server-side to the deterministic generated icon. Clients never see a broken image and never need to retry.

Pass ?fallback=1 to always render the auto-generated icon, regardless of the user's selected source. This is for account pickers that want to preview the default; it is not a mechanism for retrying failed provider fetches.

Claiming icons is global

Each game-icon can be held by one player across the entire system. The database enforces this directly — only one row at a time can hold a given avatar_icon value. When a user picks an icon, the API performs an atomic UPDATE and returns 409 Conflict { error: 'icon_taken', icon: '<id>' } on collision.

The claim releases when the user switches to a provider avatar or resets to auto. Guests never consume the pool (the API rejects claim requests for guests with 403).

Icons come from game-icons.net under CC-BY 3.0: 4,000+ curated SVGs organized by author. The id format is {author}/{slug} (for example, lorc/dragon-head).

Image URLs

All avatar routes live on the main API (https://api.katforge.com) and are public. Size is an integer pixel value clamped to [16, 512].

text
GET /v1/players/{id}/avatar?size=128&v={ts}
GET /v1/users/@me/avatar?size=128                 # auth required
GET /v1/avatars/icons/{author}/{slug}?color=amber&bg=slate&size=64
  • /v1/players/{id}/avatar: public; leaderboards embed this directly via <img src>. The v query parameter is an opaque cache-buster that changes whenever the player updates their avatar (derived from avatar_updated_at). Always pass it through from player.avatar_url or entry.avatar_v so browsers refetch on change.
  • /v1/users/@me/avatar: convenience for logged-in headers; renders the current authenticated player.
  • /v1/avatars/icons/{author}/{slug}: renders an arbitrary icon with arbitrary colors. Used by the picker grid. (author, slug, color, bg, size) fully determines the output, so responses are heavily cached (Cache-Control: public, max-age=31536000, immutable).

Generated icons return image/svg+xml. Proxied provider avatars return the upstream content-type (typically image/png or image/jpeg). All responses set Cache-Control so browser dedup is free.

The player payload

UserFormatter.formatPlayerData includes an absolute avatar_url plus a structured avatar object on every player response. Clients get everything they need in one round trip.

JSON
{
   "id": 13,
   "name": "Anders",
   "avatar_url": "https://api.katforge.com/v1/players/13/avatar?v=1776458955",
   "avatar": {
      "source": "custom",
      "icon":   "lorc/dragon-head",
      "color":  "cyan",
      "bg":     "crimson"
   }
}

Leaderboard entries include a lighter shape (just player_id plus an avatar_v timestamp), so entries stay small. The frontend constructs the URL with SparkAvatar :player-id :v.

Changing an avatar

The SDK wraps the PATCH /v1/users/@me/avatar endpoint in three shapes:

TypeScript
// Claim a game-icon. Throws ConflictError if the icon is taken.
await katforge.users.updateAvatar ({
   source: 'custom',
   icon:   'lorc/dragon-head',
   color:  'cyan',
   bg:     'crimson'
});

// Use a linked provider's avatar (must be linked first via katforge.auth.oauth.link)
await katforge.users.updateAvatar ({ source: 'discord' });

// Reset to auto-generated
await katforge.users.updateAvatar ({ source: null });

The catalog

The picker UI fetches the manifest minus globally-claimed icons:

TypeScript
const catalog = await katforge.users.avatarCatalog ();
// { icons: [{ id, author, slug, name }], total, taken_count, palettes }

palettes.colors and palettes.bgs are { key → hex } maps. The keys are what you pass to updateAvatar (color, bg) and to the icon preview URL (/v1/avatars/icons/{...}?color=amber&bg=slate). There are 12 of each.

The response is cacheable for ~30 seconds. For robustness, still handle 409 icon_taken on submit: a stale cache is harmless because the DB enforces uniqueness atomically.

OAuth-avatar linking

Linking a provider is the existing OAuth flow plus one extra step:

  1. User clicks "Link Discord" in the avatar dashboard.
  2. Client calls katforge.auth.oauth.getUrl ('discord', redirect) and sends the user there.
  3. Provider redirects back with ?code=&state=.
  4. Client calls katforge.auth.oauth.link ('discord', code, state).
  5. The resulting user_oauth.provider_avatar is now available as a source: updateAvatar ({ source: 'discord' }).

Unlinking is refused if player.avatar_src === provider; users are asked to switch source first. This avoids surprise avatar resets when managing auth methods.

Rendering on the frontend

Use :realm{name="spark"}'s SparkAvatar component. It handles URL construction, cache-busting, and the shimmer skeleton state. Broken images are impossible by design: the API proxies provider avatars and falls through to a generated icon on any failure, so the component has no retry logic. No realm needs to know how avatars are composed; just pass the player id.