Mark
Prijzen Aanmelden
← Alle artikelen

Achievements bijhouden in een Phaser-browsergame

Mike Dalton Door Mike Dalton ·

Een browservenster met een game met vallende sterren naast het Game Stats AI-logo, met een pijl naar een dashboardkaart met een trofee en een staafgrafiek.

Achievement-tracking legt de mijlpalen vast die spelers in je game bereiken, zoals een hoge score, een uitgespeeld level of een verborgen geheim. Bij een browsergame komen die gegevens van JavaScript dat op de computer van de speler draait, dus de game roept de API rechtstreeks vanaf de pagina aan.

Om te laten zien hoe dat eruitziet, hebben we Star Catcher gebouwd, een kleine Phaser 4-game waarin de speler vallende sterren vangt en bommen ontwijkt, en daar de API van Game Stats AI in ingebouwd. Het afgeronde project staat in onze voorbeeldrepository. Het gebruikt Phaser 4.2, Vite 8 en JavaScript zonder framework.

Stap 1: bepaal wat telt als achievement

Koppel achievements aan momenten die al in je game bestaan. In Star Catcher stijgt de score met één voor elke ster die de speler vangt, dus de achievements zijn 10, 25 en 50 sterren vangen in één potje.

De scène verwerkt al een gevangen ster, dus daar komt de aanroep die de achievement ontgrendelt:

const STAR_ACHIEVEMENTS = {
  10: "Caught 10 Stars",
  25: "Caught 25 Stars",
  50: "Caught 50 Stars",
}

catchStar(_player, star) {
  star.destroy()
  this.score += 1
  this.scoreText.setText(`Stars: ${this.score}`)

  if (this.score in STAR_ACHIEVEMENTS) {
    unlockAchievement(STAR_ACHIEVEMENTS[this.score])
  }
}

unlockAchievement komt uit src/game-stats.js, de module die de rest van deze gids bouwt.

Stap 2: maak een client-token aan

Game Stats AI ontvangt achievement-events op één endpoint:

POST https://gamestats.ai/api/v1/accounts/{account_id}/achievement_events
Authorization: Bearer <token>
Content-Type: application/json

Elke speler downloadt het JavaScript van een browsergame, en iedereen kan het lezen in de ontwikkelaarstools van de browser. Behandel het token dus als openbaar en maak een client-token aan, geen server-token. Kies bij Instellingen > API-tokens voor Client wanneer je het token aanmaakt. Een client-token kan alleen telemetrie versturen, dus een gelekt token kan de rapporten van je account niet lezen.

Het scherm voor een nieuw API-token, met een naamveld en een keuze voor tokentype Client of Server

Stap 3: sta de origin van je game toe

Een webpagina kan een API op een ander domein alleen aanroepen als die API de origin van de pagina toestaat. Dit heet CORS (cross-origin resource sharing). Game Stats AI staat alleen de origins toe die je aan je account toevoegt, dus een pagina die je niet hebt toegevoegd kan geen events versturen met jouw token.

Een origin is het schema, de host en de poort waarvandaan de game wordt geserveerd, zonder pad. Voeg er een toe voor elke plek waar je game draait: http://localhost:5173 voor de ontwikkelserver van Vite, en je productieadres, zoals https://play.yourgame.com. Voeg ze toe bij Instellingen > Browseroorsprongen:

De instellingenpagina Browseroorsprongen met http://localhost:5173 toegevoegd

Als de origin ontbreekt, blokkeert de browser het verzoek voordat het wordt verstuurd, en de console toont een fout zoals deze:

Access to fetch at 'https://gamestats.ai/api/v1/accounts/8/achievement_events' from origin 'http://localhost:5173' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Om te zien op welke origin je game draait, open je de browserconsole op de pagina van de game en voer je location.origin uit. Als de game in een iframe is ingesloten, voer het dan uit in de context van de iframe: de origin van de iframe is degene die de API aanroept.

Stap 4: voeg het account-id en het token toe

Vite leest omgevingsvariabelen uit een bestand .env.local in de hoofdmap van het project. Variabelen die met VITE_ beginnen, zijn in je code beschikbaar via import.meta.env:

VITE_GAME_STATS_ACCOUNT_ID=your_account_id
VITE_GAME_STATS_API_TOKEN=client_your_token

De standaard-.gitignore die Vite voor nieuwe projecten aanmaakt, sluit *.local-bestanden al uit, zodat het token buiten je repository blijft. Voor spelers blijft het zichtbaar: vite build schrijft beide waarden in de JavaScript-bundle.

De versie komt uit package.json. vite.config.js maakt die voor de game beschikbaar als __GAME_VERSION__:

import { defineConfig } from "vite"
import packageJson from "./package.json" with { type: "json" }

export default defineConfig({
  define: {
    __GAME_VERSION__: JSON.stringify(packageJson.version),
  },
})

Werk de version in package.json bij voordat je elke build uitbrengt, want Game Stats AI groepeert unlocks per versie.

Het begin van src/game-stats.js leest deze waarden één keer. Als het account-id of het token ontbreekt, schakelt de integratie zichzelf uit en draait de game gewoon door:

const ACCOUNT_ID = import.meta.env.VITE_GAME_STATS_ACCOUNT_ID
const API_TOKEN = import.meta.env.VITE_GAME_STATS_API_TOKEN
const BASE_URL = import.meta.env.VITE_GAME_STATS_BASE_URL ?? "https://gamestats.ai"
const VERSION_NAME = __GAME_VERSION__
const STORAGE_KEY = "game_stats"
const RETRY_DELAY_MS = 30_000

const enabled = Boolean(ACCOUNT_ID && API_TOKEN)
const endpointUrl = `${BASE_URL}/api/v1/accounts/${ACCOUNT_ID}/achievement_events`
const state = loadState()
let requestInFlight = false

if (enabled) {
  window.addEventListener("online", sendNextEvent)
  sendNextEvent()
} else {
  console.warn("Game Stats AI is disabled: VITE_GAME_STATS_ACCOUNT_ID or VITE_GAME_STATS_API_TOKEN is missing.")
}

Stap 5: bouw de payload

De body bestaat uit vier verplichte velden:

export function unlockAchievement(achievementName) {
  if (!enabled || state.unlockedAchievements.includes(achievementName)) {
    return
  }

  state.unlockedAchievements.push(achievementName)
  state.pendingEvents.push({
    version_name: VERSION_NAME,
    player_username: state.playerUsername,
    achievement_name: achievementName,
    occurred_at: new Date().toISOString(),
  })
  saveState()
  sendNextEvent()
}

toISOString() geeft de huidige tijd in UTC terug, bijvoorbeeld 2026-10-11T16:42:07.913Z. Achievements, spelers en versies worden aangemaakt bij het eerste gebruik, dus je hoeft niets vooraf te registreren.

Het event krijgt zijn occurred_at één keer, op het moment dat de speler de achievement ontgrendelt, en wordt met dat tijdstempel opgeslagen. Game Stats AI negeert een unlock die al ontvangen is alleen als speler, achievement, versie en occurred_at allemaal overeenkomen. Een nieuwe poging die de payload opnieuw opbouwt met een nieuw tijdstempel zou een tweede unlock vastleggen.

Stap 6: verstuur events met fetch

De module verstuurt het eerste wachtende event en wacht op het antwoord voordat hij het volgende verstuurt:

async function sendNextEvent() {
  if (requestInFlight || state.pendingEvents.length === 0) {
    return
  }

  requestInFlight = true
  const status = await postEvent(state.pendingEvents[0])
  requestInFlight = false

  if (status === 201 || status === 422) {
    state.pendingEvents.shift()
    saveState()
    sendNextEvent()
  } else {
    setTimeout(sendNextEvent, RETRY_DELAY_MS)
  }
}

async function postEvent(event) {
  try {
    const response = await fetch(endpointUrl, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${API_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(event),
    })
    if (response.status === 422) {
      console.warn("Game Stats AI rejected an event:", await response.text())
    }
    return response.status
  } catch (error) {
    console.warn("Game Stats AI could not send an event:", error.message)
    return null
  }
}

Een 201 betekent dat het event is geaccepteerd. Een 422 betekent dat de payload zelf ongeldig is, bijvoorbeeld {"errors":["Version name can't be blank"]}, dus opnieuw versturen helpt niet: log het en ga door met het volgende. Elk ander resultaat laat het event vooraan in de wachtrij staan en probeert het na 30 seconden opnieuw.

fetch gooit een fout in plaats van een statuscode terug te geven in twee gevallen: de speler is offline, of de browser heeft het verzoek geblokkeerd omdat de origin niet is toegestaan. Beide komen in het pad voor een nieuwe poging terecht, dus unlocks wachten in de wachtrij tot het probleem is opgelost. De online-listener uit stap 4 verstuurt ze zodra de verbinding terug is.

Stap 7: bewaar unlocks in localStorage

De module bewaart drie dingen in localStorage: een gegenereerde spelersnaam, de achievements die al ontgrendeld zijn en de events die nog niet verstuurd zijn.

function loadState() {
  const savedState = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")
  return {
    playerUsername: savedState.playerUsername ?? `player_${crypto.randomUUID().replaceAll("-", "").slice(0, 12)}`,
    unlockedAchievements: savedState.unlockedAchievements ?? [],
    pendingEvents: savedState.pendingEvents ?? [],
  }
}

function saveState() {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
}

Door de lijst met ontgrendelde achievements wordt elke achievement één keer per speler verstuurd, niet één keer per potje, ook nadat de pagina opnieuw is geladen. Door de lijst met wachtende events gaat een unlock niet verloren als de speler het tabblad sluit voordat die is verstuurd: die wordt met het oorspronkelijke tijdstempel verstuurd zodra de game weer laadt.

localStorage hoort bij één browser op één apparaat, dus een speler die zijn browsergegevens wist of van apparaat wisselt, krijgt een nieuwe spelersnaam. Heeft je game al spelersaccounts, gebruik dan dat id voor player_username in plaats van een gegenereerde naam.

De volledige integratie staat in de voorbeeldrepository.

Lees verder

© 2026 Rowhome Labs, LLC