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_src | What the API returns | When to use |
|---|---|---|
'custom' | Composed SVG of the claimed game-icon + chosen colors | User 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 |
null | Composed SVG of a deterministic icon seeded from player.id | Default 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].
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>. Thevquery parameter is an opaque cache-buster that changes whenever the player updates their avatar (derived fromavatar_updated_at). Always pass it through fromplayer.avatar_urlorentry.avatar_vso 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.
{
"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:
// 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:
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:
- User clicks "Link Discord" in the avatar dashboard.
- Client calls
katforge.auth.oauth.getUrl ('discord', redirect)and sends the user there. - Provider redirects back with
?code=&state=. - Client calls
katforge.auth.oauth.link ('discord', code, state). - The resulting
user_oauth.provider_avataris 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.