Achievements and stats
Full contracts: achievement inputs, results and definitions · stat types and operations · offline receipts and status. These link to the exact source revision, not a guarantee that pending features have reached npm.
Achievements belong to one game and persist against a signed-in player. Games award their own milestones; Inkwell stores them, shows progress on game pages, and groups earned achievements on profiles.
In a game
import { achievements } from '@silicon-jungle/inkwell-sdk/achievements'
import { stats } from '@silicon-jungle/inkwell-sdk/stats'
await achievements.unlock('first_win') // repeat calls keep the first unlock date
await stats.set('coins', 42)
await stats.increment('wins', 1, { requestId: crypto.randomUUID() })
await stats.updateAverage('points_per_second', 120, 30)
const mine = await achievements.list()
const completion = await achievements.summary() // { total, unlocked }
const count = await achievements.count()
const coins = await stats.get('coins') // null if unavailable; no catalogue scan
const global = await achievements.percentages()
// Ordered by percentage, with name, unlockedPlayers and own unlocked state.
// Display-only progress: does NOT write a stat or unlock the achievement.
await achievements.indicateProgress('collector', 3, 10)
const unsubscribe = achievements.onNotification(notice => {
// { kind: 'unlocked' | 'progress', name, title, ... }
updateGameUI(notice)
})
// Call unsubscribe() when leaving the scene.
// Read another published game's achievements for the current player:
const available = await achievements.games({ query: 'adventure' })
// { games: [{ slug, title, publisherUsername, achievementCount }], nextCursor }
// If nextCursor is not null, continue with games({ query: 'adventure', after: available.nextCursor }).
const visitor = await achievements.get('explorer', { game: 'other-game' })
const rarity = await achievements.percentage('explorer', { game: 'other-game' })
// { name, percent, unlockedPlayers, unlocked }, or null if unavailable.
if (visitor?.unlocked) await achievements.unlock('well_travelled')
// Public player achievements: list({ username: 'jungle', game: 'other-game' })Cross-game access is read-only. Other games must be public and published; drafts and taken-down games are excluded. Locked hidden achievements expose neither their title, description, icon nor progress. Blocked profiles are excluded from authenticated player queries.
Linked progress values remain private unless the stat is marked publicRead, except for the current player or backend within the stat's own game. Cross-game achievement reads still return unlock status and dates, but never grant access to a private linked stat. Discovery lists only public, published games with enabled achievements and hides blocked creators. Counts include enabled hidden achievements without exposing their locked details.
Define achievements and stats
In Manage games, open your game’s Achievements and stats section to create or edit definitions. The form supports locked/unlocked icon URLs, existing game images, translations, linked stat targets, visibility and write policies. Upload images in Game images, then use Refresh game images to select them. API names and stat types stay fixed after creation. The testing section accepts an exact username for awards, clearing achievements, or resetting that player’s game data; destructive actions require confirmation.
Unlock notices appear in the player header after a saved award. Backend awards use the account WebSocket; game code only receives achievement events for its current game, not other account events. Progress notices are display-only, throttled, and suppressed for locked hidden or already-unlocked achievements. Delivery is best-effort; query achievements for authoritative state after reconnecting.
// In a hosted backend's start(context):
await context.stats.define({
name: 'coins', title: 'Coins collected', kind: 'int',
minValue: 0, maxValue: 1000000, incrementOnly: true, aggregated: true,
})
await context.achievements.define({
name: 'collector', title: 'Pocket change', description: 'Collect 100 coins.',
progressStat: 'coins', progressMin: 0, progressTarget: 100,
iconUrl: 'https://your-assets.example/collector.png',
hidden: false, serverWritesOnly: false,
translations: { fr: { title: 'Collectionneur', description: 'Collectez 100 pièces.' } },
})
// In a player handler:
await context.stats.forPlayer(connection.identity.username).increment('coins')
await context.achievements.unlockFor(connection.identity.username, 'first_win')Define operations find an existing definition without changing it. Use update with a complete definition to change titles, icons, constraints, or policies. Localised reads accept a locale such as fr-FR, falling back to fr and then the default text.
Persistence, retries and resets
Stat writes accept set, increment, and average modes. Reuse the same requestId when retrying one logical update; reusing it with different input is rejected. A successful stat update and its linked achievement awards are committed together. Await the result to know it has been saved, or inspect the queued receipt when offline support is enabled.
Optional offline progress
await Inkwell.offline.enable() // Requires an online, signed-in session first.
const result = await Inkwell.stats.increment('coins', 1)
if (result.queued) {
// Stored on this device, not yet confirmed by the server.
}
const status = await Inkwell.offline.status()
// { enabled, pending, failures }
await Inkwell.offline.flush() // Also retried automatically after reconnecting.Offline mode queues current-player stat writes and achievement unlocks in browser storage on the platform origin, separately for each player and game. It survives game-build changes and page reloads on this device. Enable it again when a new play session starts. Own-player reads can return cached results marked offline, cachedAt and pendingWrites; cached values do not include unconfirmed writes.
Queues hold at most 1,000 writes and 512,000 serialized characters for seven days. Rejected or expired writes appear in status.failures. Resetting progress invalidates older queued stat writes. Clears, resets, cross-game reads and backend operations are never queued. This does not download game assets for offline play; clearing browser data also removes unsubmitted progress. Call offline.disable() to stop queueing without deleting pending progress.
Game saves and conflict resolution belong to the creator. Offline support only retries explicit stat writes and achievement unlocks; it does not merge game snapshots or choose which device has more progress. Accepted set operations overwrite values subject to the stat rules; independent increments each apply once per retry ID. Creators choose how to store and reconcile their own game state.
Use achievements.clear(name) to clear a client-writable achievement for testing. Use stats.reset({ achievements: true }) to reset client-writable stats to defaults and clear client-writable achievements. Backend-only values require a backend credential to change or reset. Reset preserves retry receipts so delayed retries cannot replay an old increment.
Live state, notices and error handling
import { Inkwell, GameServiceError } from '@silicon-jungle/inkwell-sdk'
let active = true
let revision = 0
async function refresh() {
const current = ++revision
const [coins, collector] = await Promise.all([
Inkwell.stats.get('coins'), Inkwell.achievements.get('collector'),
])
if (active && current === revision) console.log({ coins, collector })
}
const changed = () => { void refresh().catch(console.error) }
const stopStats = Inkwell.stats.onChange(changed)
const stopAchievements = Inkwell.achievements.onChange(changed)
changed() // Subscriptions are hints, not an initial snapshot.
// Keep this identifier if retrying the same logical award.
export async function collectCoin(requestId: string) {
try {
const result = await Inkwell.stats.increment('coins', 1, { requestId })
if (result.queued) console.log('Pending server confirmation:', result.requestId)
else console.log('Saved:', result.value, 'Unlocked:', result.unlocked)
await refresh()
} catch (error) {
if (error instanceof GameServiceError) console.warn(error.status, error.code)
throw error
}
}
export async function inspectCoinRules() {
const result = await Inkwell.stats.schema({ name: 'coins' })
return result.stats[0] ?? null // Metadata, never another player's value.
}
export async function inspectCoinHistory(startDate: string, endDate: string) {
return Inkwell.stats.aggregate({ names: ['coins'], startDate, endDate })
}
window.addEventListener('pagehide', () => {
active = false
stopStats()
stopAchievements()
}, { once: true })onNotification is for unlocked/progress presentation. onChange is a separate browser-only hint to reload state after backend writes, clears, resets, linked stat changes or reconnect. It carries an id, kind (updated/reset/refresh) and names, not authoritative values. An empty names list can request a broad refresh. The host invalidates confirmed offline caches before dispatching the hint; this is not a reliable event log, and it does not replay every missed mutation.
Subscribe before the initial read, unsubscribe when leaving the scene, and prevent older async reads overwriting newer results. Catch errors inside your callbacks. A direct write result already tells you what was saved; re-read after it instead of depending on a later event. Check queued receipts separately from confirmed writes.
GameServiceError exposes status and code. Invalid inputs may also throw TypeError before a request. Authentication/access denial must not be retried as an offline award; rate limits require backing off. The browser bridge waits up to 25 seconds. A timeout does not prove a write was rolled back: retry a stat mutation only with the same logical requestId and unchanged payload. Unlock preserves the first unlock timestamp and reports newlyUnlocked; do not award an extra reward when it is false.
Definitions, read shapes and permissions
Achievement definitions contain name, title, description, iconUrl, lockedIconUrl, hidden, enabled, serverWritesOnly, translations and optional progressStat/progressMin/progressTarget. Read results contain name, localized title/description, the selected icon, hidden/unlocked, unlockedAt and optional progress current/min/target/percent. Missing or unavailable entries return null from get; do not interpret missing as a successful locked read.
Stat definitions contain name/title, kind (int/float/avgrate), defaultValue, minValue/maxValue, maxChange, incrementOnly, serverWritesOnly, publicRead, aggregated and windowSeconds. Player reads contain name/title/kind/value/updatedAt, with cache provenance when applicable. Use stats.schema({ name: 'coins' }) for exact-name metadata, or schema({ offset: 0 }) to enumerate name-sorted pages of up to 100 definitions using nextOffset. Guests with valid current-game access may read this metadata, but it contains no saved values or management rights. publicRead controls other-player values, not schema visibility. Creating or editing definitions remains a creator/backend privilege; definitions() is still backend-only. No cross-game stat selector is supported.
List and percentages use zero-based offsets and nextOffset until null; discovery uses its separate nextCursor/after pair. summary returns total/unlocked and count returns the total. percentage(name) performs a direct named lookup; percentages enumerates completion percentages with player unlocked state.
Browser writes apply only to the signed-in player and current game. Backend forPlayer/unlockFor/clearFor selects a player within its own game. Cross-game achievement queries are read-only and must pass game visibility and creator-block rules. publicRead opts a stat into other-player reads; it does not make it writable. Client-writable values are not cheat-proof. Use backend-only policies and validate outcomes server-side when integrity matters.
A linked-stat write and its resulting unlocks are atomic. For mixed changes, the backend batch API below commits one player's stats and achievements together. Separate SDK calls remain separate operations. Offline mode does not promise automatic highest-progress-wins merging across different devices. It is not a replacement for your own D1 world data.
Atomic backend progress batches
import type { BackendContext } from '@silicon-jungle/inkwell-sdk/server'
// Call only after your server has verified the outcome. Not a public action.
// Define the wins stat and first_win achievement before using this helper.
export async function recordVerifiedWin(
context: BackendContext, username: string, requestId: string,
) {
const result = await context.stats.batchFor(username, {
stats: [{ name: 'wins', mode: 'increment', value: 1 }],
achievements: [{ name: 'first_win', unlocked: true }],
}, { requestId })
// A duplicate returns the original receipt, including its original unlock list.
// Do not repeat external rewards just because that list is nonempty.
if (!result.duplicate) console.log('Progress committed:', result.stats)
return result
}Use context.stats.batchFor(username, changes, options) or context.stats.forPlayer(username).batch(changes, options). Only backend/creator authority may submit batches; browser calls are denied, and batches are not queued offline. Validate the game outcome before calling this API.
changes contains stats and/or achievements: between 1 and 100 changes total, each stat name and each achievement name at most once, within the 16 KiB request-body limit. Stats accept set/increment, or average with a positive seconds duration. Achievements specify name and unlocked as a boolean. Every definition, numeric bound and policy must pass or the whole batch fails without partial progress.
The result contains stats (name/value), newly unlocked names, explicitly cleared names, and duplicate. Linked-stat awards are included; an explicit clear of the same achievement wins over its automatic award. A batch containing clears advances the player's save epoch once, preventing older offline writes from restoring cleared progress.
Keep one UUID requestId for retries of the same logical operation. The SDK creates one if omitted, but repeated fresh calls then represent new operations. Input ordering is canonicalized, so reordered copies can retrieve the same receipt. Reusing an ID for different contents or a conflicting single-stat write fails with 409. There is no per-item retry.
options.epoch optionally rejects a new write based on an older save epoch. An already committed receipt remains retrievable even after that epoch changes; retrieval performs no new writes or notifications and returns duplicate:true. Its unlocked array still describes the original commit, so check duplicate before initiating any external effect. The batch transaction does not include outbound webhooks or another service's database.
Stats and limits
- INT stats use signed 32-bit integer bounds. FLOAT stats accept finite fractional values. AVGRATE blends sample rates using an exponential moving average, with a configurable windowSeconds.
- Definitions support min/max/default values, maximum change, increment-only, backend-only writes, and opt-in public reads for other players’ stats.
- Aggregated stats expose totals and up to 60 days of daily deltas through stats.aggregate({ historyDays: 30 }). Use totalExact and history[].deltaExact decimal strings when precision matters; total and delta are approximate JavaScript numbers. History is newest first (today, yesterday, and so on by default), includes zero-activity UTC days, and measures net changes including resets. Exact sums preserve recorded contributions, not precision already lost in individual floating-point writes. Unlock percentages count signed-in players observed playing or receiving an award, excluding suspended accounts.
- Select 1–100 stat names with names, filtered before pagination. For historical windows, pass both startDate and endDate as UTC YYYY-MM-DD strings instead of historyDays: ranges are inclusive and span 1–60 days. History ends on endDate and is newest first; totalExact remains the current total, not a subtotal for that date range.
- For aggregated stats, maxChange caps each upload's contribution to the global total in either direction without rejecting an otherwise-valid player value. A first upload of 1002 with a cap of 5 stores 1002 for the player and adds only 5 globally. Later uploads contribute the capped difference from the previous player value; repeating a value adds nothing. For non-aggregated stats, maxChange rejects excessive player-value changes instead. Resets remove the full recorded contribution as an administrative correction. Existing pre-migration totals are preserved; future changes adopt the cap.
- Up to 1,000 achievement definitions and 1,000 stat definitions per game. Reads are paginated with at most 100 entries.
- No private names, email addresses or account IDs are exposed in game queries. Account exports include your achievements, stats and leaderboard entries.
HTTP endpoints
POST runtime-authenticated JSON to /api/v1/game-services/achievements or /api/v1/game-services/stats. Approved creators can use /api/v1/games/:slug/achievements with their API key; include service: 'stats' for stat operations.
{ operation: 'define', name: 'first_win', definition: { title: 'First win', description: 'Win once' } }
{ operation: 'unlock', name: 'first_win', username: 'jungle' }
{ operation: 'list', game: 'other-game', username: 'jungle', locale: 'en', offset: 0 }
{ service: 'stats', operation: 'write', name: 'coins', username: 'jungle', mode: 'increment', value: 1, requestId: 'a-fresh-uuid' }
// Achievement operations: list/get, percentages, define/update/delete, unlock/clear.
// Stat operations: list, definitions, define/update, write, aggregate, reset.