Cómo registrar logros en un juego de navegador con Phaser
Por Mike Dalton ·
El seguimiento de logros registra los hitos que los jugadores alcanzan en tu juego, como un puntaje alto, un nivel superado o un secreto escondido. En un juego de navegador, esos registros salen de JavaScript que corre en la máquina del jugador, así que el juego llama a la API directamente desde la página.
Para mostrar cómo se ve eso, construimos Star Catcher, un pequeño juego de Phaser 4 en el que el jugador atrapa estrellas que caen y esquiva bombas, y le integramos la API de Game Stats AI. El proyecto terminado está en nuestro repositorio de ejemplo. Usa Phaser 4.2, Vite 8 y JavaScript sin frameworks.
Paso 1: decide qué cuenta como un logro
Asigna los logros a momentos que ya existan en tu juego. En Star Catcher, el puntaje sube en uno por cada estrella que el jugador atrapa, así que los logros son atrapar 10, 25 y 50 estrellas en una misma partida.
La escena ya maneja la estrella atrapada, así que ahí va la llamada que desbloquea el logro:
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 viene de src/game-stats.js, el módulo que el resto de esta guía construye.
Paso 2: crea un token client
Game Stats AI recibe eventos de logros en un único endpoint:
POST https://gamestats.ai/api/v1/accounts/{account_id}/achievement_events
Authorization: Bearer <token>
Content-Type: application/json
Cada jugador descarga el JavaScript de un juego de navegador, y cualquiera puede leerlo en las herramientas de desarrollo del navegador. Trata el token como público y crea un token client, no un token de servidor. En Configuración > Tokens API, elige Client
cuando crees el token. Un token client solo puede enviar telemetría, así que uno filtrado no puede leer los reportes de tu cuenta.


Paso 3: permite el origen de tu juego
Una página web solo puede llamar a una API en otro dominio si esa API permite el origen de la página. Esto se llama CORS (intercambio de recursos de origen cruzado). Game Stats AI solo permite los orígenes que agregas a tu cuenta, así que una página que no agregaste no puede enviar eventos con tu token.
Un origen es el esquema, el host y el puerto desde donde se sirve el juego, sin ruta. Agrega uno por cada lugar donde corre tu juego: http://localhost:5173 para el servidor de desarrollo de Vite y tu dirección de producción, por ejemplo https://play.yourgame.com. Agrégalos en Configuración > Orígenes de navegador:


Si falta el origen, el navegador bloquea la petición antes de enviarla y la consola muestra un error como este:
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.
Para ver en qué origen corre tu juego, abre la consola del navegador en la página del juego y ejecuta location.origin. Si el juego está incrustado en un iframe, ejecútalo en el contexto del iframe: el origen del iframe es el que llama a la API.
Paso 4: agrega el id de la cuenta y el token
Vite lee variables de entorno de un archivo .env.local en la raíz del proyecto. Las variables que empiezan por VITE_ quedan disponibles en tu código como import.meta.env:
VITE_GAME_STATS_ACCOUNT_ID=your_account_id
VITE_GAME_STATS_API_TOKEN=client_your_token
El .gitignore que Vite crea por defecto en proyectos nuevos ya excluye los archivos *.local, lo que evita que el token quede en tu repositorio. No lo oculta de los jugadores: vite build escribe ambos valores dentro del bundle de JavaScript.
La versión viene de package.json. vite.config.js la pone a disposición del juego como __GAME_VERSION__:
import { defineConfig } from "vite"
import packageJson from "./package.json" with { type: "json" }
export default defineConfig({
define: {
__GAME_VERSION__: JSON.stringify(packageJson.version),
},
})
Actualiza la version de package.json antes de publicar cada build, y Game Stats AI agrupa los desbloqueos por esa versión.
El inicio de src/game-stats.js lee estos valores una sola vez. Si falta el id de la cuenta o el token, la integración se desactiva y el juego funciona con normalidad:
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.")
}
Paso 5: construye el payload
El cuerpo tiene cuatro campos obligatorios:
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() devuelve la hora actual en UTC, por ejemplo 2026-10-11T16:42:07.913Z. Los logros, los jugadores y las versiones se crean la primera vez que se usan, así que no hay nada que registrar por adelantado.
El evento recibe su occurred_at una sola vez, cuando el jugador desbloquea el logro, y se guarda con esa marca de tiempo. Game Stats AI ignora un desbloqueo que ya recibió solo cuando coinciden el jugador, el logro, la versión y occurred_at. Un reintento que reconstruyera el payload con una marca de tiempo nueva registraría un segundo desbloqueo.
Paso 6: envía los eventos con fetch
El módulo envía el primer evento pendiente y espera la respuesta antes de enviar el siguiente:
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
}
}
Un 201 significa que el evento fue aceptado. Un 422 significa que el payload en sí no es válido, por ejemplo {"errors":["Version name can't be blank"]}, así que volver a enviarlo no sirve: regístralo en el log y sigue con el siguiente. Cualquier otro resultado deja el evento al frente de la cola y lo intenta de nuevo en 30 segundos.
fetch lanza un error en lugar de devolver un código de estado en dos casos: el jugador no tiene conexión, o el navegador bloqueó la petición porque el origen no está permitido. Ambos terminan en el camino de reintento, así que los desbloqueos esperan en la cola hasta que se resuelva el problema. El listener de online del paso 4 los envía en cuanto vuelve la conexión.
Paso 7: guarda los desbloqueos en localStorage
El módulo guarda tres cosas en localStorage: un nombre de jugador generado, los logros ya desbloqueados y los eventos que aún no se han enviado.
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))
}
La lista de desbloqueados hace que cada logro se envíe una vez por jugador, no una vez por partida, incluso después de recargar la página. La lista de pendientes hace que un desbloqueo no se pierda si el jugador cierra la pestaña antes de que se envíe: se envía con su marca de tiempo original la próxima vez que carga el juego.
localStorage pertenece a un navegador en un dispositivo, así que un jugador que borra los datos del navegador o cambia de dispositivo recibe un nombre de jugador nuevo. Si tu juego ya tiene cuentas de jugador, usa ese id en player_username en lugar de uno generado.
La integración completa está en el repositorio de ejemplo.