unified_ game_ services
Una sola API Dart para Steam, Epic, Game Center, Google Play, GameJolt y PlayFab, sin Flutter obligatorio.
Si publicas un juego en Steam, en Epic y en móvil, hoy integras tres SDK distintos para hacer lo mismo: desbloquear un logro. Cada uno tiene su autenticación, sus errores, sus identificadores y su forma de entender qué es un "logro". Unified Game Services es una sola API en Dart que habla con todas esas tiendas a la vez. Escribes
unlockAchievement('first_win') y el logro se desbloquea en todas las tiendas donde está publicado el juego.
| Mi papel | Autor (proyecto personal open source, licencia MIT) |
| Tiendas | Steam, Epic, Game Center, Google Play, GameJolt, PlayFab y backends propios |
| Funciona en | Dart puro: juegos Flutter, motores propios, servidores y CLIs |
| Tamaño | 12 paquetes |
El problema
Las tiendas de juegos ofrecen servicios parecidos (logros, marcadores, estadísticas, guardado en la nube, amigos, presencia), pero cada una a su manera:
- SDK distintos y en lenguajes distintos. Steam y Epic son librerías en C, Game Center es Objective-C, Google Play es Java en Android y REST fuera de él, GameJolt y PlayFab son APIs web.
-
Identificadores distintos. El mismo logro se llama
ACH_WIN_ONE_GAMEen Steam yCgkI…en Google Play. - Capacidades distintas. Unas tiendas tienen estadísticas escribibles y otras no. Unas guardan en la nube y otras no.
En el ecosistema Dart, además, las librerías existentes dependen de Flutter y de sus platform channels. Un juego con motor propio, una herramienta de servidor o una CLI se quedaban fuera.
Qué escribe un desarrollador de juegos
GameServices.initialize(
providers: [
SteamProvider(appId: 480),
EpicProvider(credentials: epicCredentials),
GameJoltProvider(gameId: '…', privateKey: '…', username: '…', userToken: '…'),
],
achievements: UnifiedAchievementTable({
'first_win': ProviderIdMap(steam: 'ACH_WIN_ONE_GAME', epic: 'ACH_WIN_ONE_GAME', gameJolt: '123456'),
}),
);
await GameServices.signIn();
await GameServices.unlockAchievement('first_win'); // Steam + Epic + GameJolt
await GameServices.submitScore(leaderboardId: 'global', score: 1500);
Con eso la librería:
- Reparte cada escritura a todas las tiendas que soportan esa función.
-
Traduce los identificadores con una tabla central: el juego solo conoce
first_win. -
Lee de una tienda (la principal o la que elijas con
from:) y etiqueta cada dato con su origen. - Unifica los errores ("no has iniciado sesión", "sin red", "función no soportada") vengan de donde vengan.
-
Te deja salir de la abstracción cuando la necesitas:
GameServices.provider<GameJoltProvider>()devuelve el proveedor concreto con todos sus extras.
Cómo creció el proyecto
timeline
title Unified Game Services
Jun 2026 : Monorepo Dart puro : Steam por FFI y GameJolt por REST el primer día : Game Center con Objective-C
Jul 2026 : Epic por FFI : Preparación de la 0.1.0 para pub.dev
Sep 2026 : Landing 3D : Juego de karts de demostración : Errores nativos de Steam mapeados
Oct 2026 : API estable para la 0.2.0 : Identificadores unificados : Resultados por proveedor : Salas e invitaciones
Nov 2026 : Skills para agentes : Skill de migración desde games_services : Versión 0.2.2
Fase 1 — Un contrato y dos proveedores opuestos
El primer día (21 de junio de 2026) monté el monorepo con melos y dos proveedores elegidos a propósito por ser opuestos: Steam, una librería nativa en C a la que se llega por FFI, y GameJolt, una API REST. Si el contrato común servía para los dos extremos, serviría para lo que viniera en medio.
graph TD
JUEGO["Tu juego"] --> FACHADA["GameServices<br/>una sola API"]
FACHADA --> CONTRATO["Contrato común<br/>modelos · eventos · capacidades · errores"]
CONTRATO --> STEAM["Steam<br/>FFI (C)"]
CONTRATO --> EPIC["Epic<br/>FFI (C)"]
CONTRATO --> GC["Game Center<br/>Objective-C"]
CONTRATO --> GP["Google Play<br/>JNI en Android · REST en el resto"]
CONTRATO --> REST["GameJolt · PlayFab<br/>REST"]
CONTRATO --> CUSTOM["Tu backend<br/>(Supabase, Firebase…)"]
style FACHADA fill:#0175C2,color:#fff
style CONTRATO fill:#01579B,color:#fff
style CUSTOM fill:#37474F,color:#fff
La arquitectura es la de un plugin federado: una fachada para la app, un contrato compartido y un paquete por proveedor. Las dependencias van en un solo sentido: los proveedores conocen el contrato, nunca a la fachada ni entre ellos. Por eso cualquiera puede escribir su propio proveedor (por ejemplo, logros guardados en su propia base de datos) y la fachada lo trata igual que a Steam.
Fase 2 — Dart puro hasta el SDK nativo
La regla del proyecto es estricta: ningún paquete depende de Flutter. Cada proveedor llega al SDK oficial con las herramientas nativas de Dart:
| Proveedor | Cómo llega al SDK |
|---|---|
| Steam | dart:ffi sobre la API en C de Steamworks |
| Epic |
dart:ffi
con
bindings
generados (ffigen) sobre el SDK en C de EOS
|
| Game Center | package:objective_c + ffigen sobre GameKit |
| Google Play (Android) | package:jni + jnigen sobre el SDK Java de Play Games v2 |
| Google Play (resto, incluida la web) | REST |
| GameJolt, PlayFab | REST |
Cada SDK enseñó algo distinto:
-
Epic responde de forma asíncrona dentro de su propio bucle. Las llamadas se completan cuando el SDK "late", así que bombeo ese latido con un temporizador y casamos cada respuesta con su
Futuremediante un token. El inicio de sesión recorre el flujo completo de cuentas de Epic, incluida la creación del usuario la primera vez. - Game Center exigió afinar ffigen para no generar mil interfaces de más y limpiar anotaciones que el analizador rechazaba.
-
Google Play en Android necesita la
Activityde la app, que en Flutter cambia al rotar la pantalla. El proveedor la pide de nuevo antes de cada llamada en lugar de guardarla.
La única excepción a la regla es un adaptador opcional para Flutter que resuelve esa
Activity automáticamente. Está aislado tras importaciones condicionales, así que la web sigue compilando (comprobado con
flutter build web).
Sin redistribuir lo que no es mío. Los SDK de Steam y Epic no se pueden redistribuir. La librería los enlaza al compilar y es el juego quien los aporta, igual que ya hace con el cliente de Steam instalado. Nunca están en el repositorio.
Fase 3 — Capacidades, no mínimo común denominador
Cada proveedor declara qué sabe hacer: logros, marcadores, estadísticas, nube, amigos, presencia… La API común solo incluye lo que tiene sentido en la mayoría de tiendas, y cada llamada se reparte únicamente a quien la soporta. Si ninguna tienda la soporta, el error lo dice con claridad.
Lo que es propio de una sola tienda no se fuerza en la API común. Las sesiones de GameJolt, por ejemplo, son presencia en línea (jugando, activo o inactivo), no el texto libre que modela la API unificada, así que viven como extra de
GameJoltProvider. Prefiero unificar, pero nunca a la fuerza: si una función pierde su sentido en alguna tienda, no entra en la API común.
Fase 4 — Estabilizar una API pública para la 0.2.0
Antes de dar la API por estable asumí todos los cambios incompatibles necesarios de una vez:
Un solo punto de entrada. GameServices.initialize(providers: [...])
crea la instancia global, y GameServices.signIn(), GameServices.unlockAchievement()… la usan desde cualquier parte. Para tests o inyección de dependencias sigue existiendo
UnifiedGameServices(providers: [...]), que no toca la global.
Identificadores unificados. Antes cada proveedor recibía sus propios ids. Ahora hay tablas centrales (UnifiedAchievementTable,
UnifiedLeaderboardTable, UnifiedStatTable) que se pasan una sola vez. Y funcionan también al leer: un logro leído de Steam y el mismo leído de Epic llegan con el mismo id unificado, y el id nativo sigue disponible por si hace falta.
Que una tienda falle no tumba a las demás. En la 0.1.0, si una tienda fallaba al desbloquear un logro, se lanzaba una excepción agregada. En la 0.2.0, cada escritura devuelve un resultado por proveedor:
final result = await GameServices.submitScore(leaderboardId: 'global', score: 4200);
for (final r in result.failed) {
print('${r.providerId} falló: ${r.error.message}');
}
switch (result['steam']) {
case OperationSuccess(): print('Steam OK');
case OperationFailure(:final error): print(error.message);
case null: print('Steam no se intentó');
}
ProviderOperationResult es una sealed class
con dos casos, éxito y fallo, así que el compilador obliga a tratar los dos. Quien prefiera "todo o nada" tiene
throwIfAnyFailed().
Logros entre tiendas. getAchievementOverview() junta los logros de todas las tiendas por su id unificado y dice en cuáles está desbloqueado cada uno.
syncAchievements() desbloquea en cada tienda lo que ya está desbloqueado en otra. Es un caso real: un jugador que se pasó el juego en Steam y lo empieza en Epic.
Leer de todas a la vez. Las variantes ...FromAll (getLeaderboardFromAll,
getStatsFromAll…) devuelven lo que dice cada tienda, con los fallos como datos. Es muy útil para depurar las tablas de ids o comparar marcadores entre tiendas.
Salas, invitaciones y tokens de identidad. En la 0.1.0 eran huecos reservados; en la 0.2.0 ya funcionan. Una sala pertenece a
un solo proveedor y, a diferencia de un logro, nunca se reparte: createLobby,
joinLobby y findLobbies usan el proveedor que indiques con
on: (o el primero que lo soporte), y el resto de llamadas se enrutan por el
providerId de la propia sala. Steam y PlayFab tienen salas; Steam, además, invitaciones. El
joinToken de una sala puede viajar en la presencia, en una invitación o por tu propio canal, y
joinLobby lo acepta. getIdentityToken() devuelve algo que tu servidor puede verificar: el ticket de Steam o el JWT de Epic. Las superposiciones nativas (showAchievementsUI()) abren las pantallas de Google Play en Android. El emparejamiento sigue reservado: sus métodos existen y lanzan
CapabilityNotSupportedException hasta que un proveedor lo implemente.
Una API pública que no cambia sin querer. Los modelos son clases inmutables escritas a mano (copyWith,
==, hashCode), sin generación de código. La superficie pública de cada paquete está registrada en un fichero de referencia (api.txt): cualquier cambio en la API hace fallar las comprobaciones, y el diff de ese fichero es el registro de cambios de cada PR.
Fase 5 — Un juego por tienda sin reescribir nada
Una duda habitual: "quiero una build solo para Steam y otra solo para Epic". No hacen falta flavors
de Flutter. La fachada solo reparte a los proveedores que registras, así que "solo Steam" es registrar solo
SteamProvider.
Lo que sí cambia entre tiendas es la librería nativa que hay que empaquetar. Para eso documenté cuatro estrategias, de la más simple a la más estricta. La más interesante usa constantes de compilación (bool.fromEnvironment) para que el compilador
elimine por completo el código FFI de las tiendas que no se activan. Lo medí: activar o desactivar un proveedor cambiaba el binario compilado en unos 260 KB.
Fase 6 — Migrar desde games_services con una skill
games_services es el paquete que casi todos usan hoy para logros y marcadores en Flutter, y solo habla con Game Center y Google Play. Quien quiera llegar a Steam o Epic tiene que migrar, y una migración mal explicada es la mejor excusa para no hacerla. La 0.2.2 trae una
skill para agentes de código, unified-game-services-migrate-from-games-services, que guía el cambio paso a paso:
-
Tabla de traducción llamada a llamada.
Achievements.unlock(...),Leaderboards.submitScore(...),GameAuth.signIn(),Player,SaveGame: cada una con su equivalente. -
Ids una sola vez. Los
androidIDeiOSIDque se repetían en cada llamada pasan a una tabla central que se declara al arrancar. -
Errores como datos. Las escrituras devuelven un resultado por tienda, así que el código que comprobaba un
String?de error se convierte en un recorrido porresult.failed. -
El único cambio de datos. El guardado en la nube de
games_servicesera texto; el de la librería son bytes (Uint8List), así que hay que decidir cómo serializar. - Una lista de comprobación final, para saber cuándo has terminado.
En un proyecto que ya depende de la librería, las skills se instalan con un comando:
dart run skills@ get
Es una de las cinco skills que lleva el paquete principal (integración en un juego, progresión entre tiendas, diferencias de comportamiento por proveedor, builds por tienda y esta). Las escribí porque un agente que no conoce una API reciente inventa llamadas que no existen; con la skill delante, usa las reales.
Estado, sin maquillaje
| Tienda | Estado |
|---|---|
| Steam | Probado en ejecución real en macOS: foto de perfil, amigos con su estado (conectado) y el juego al que están jugando, entre otros |
| Epic | Probado en ejecución real en macOS: perfil, amigos, logros, marcadores, estadísticas, nube y presencia |
| Google Play (REST) | Verificado con tests |
| Google Play (Android nativo) | Inicio de sesión real y pantallas nativas de logros y marcadores; escrituras verificadas en dispositivo. Las lecturas esperan un arreglo de genéricos en el generador de bindings |
| GameJolt | Verificado con tests y en vivo |
| PlayFab | Verificado con tests |
| Salas de Steam | Probadas en ejecución real en Linux y también en macOS |
| Salas de PlayFab | Verificadas con tests; sin probar contra un título real |
| Game Center | Implementado; falta probarlo en ejecución (requiere cuenta de desarrollador de Apple) |
| Xbox en PC | En investigación |
Prefiero una tabla honesta a una lista de logos: quien vaya a usar la librería tiene que saber qué puede esperar de cada tienda.
La web y el juego de demostración
La landing cuenta la librería como un viaje en kart. Cada tramo presenta una función (logros, estadísticas, nube, social, tiendas) en una escena 3D que avanza con el scroll. Está hecha con Next.js, React Three Fiber y GSAP, y probada de extremo a extremo con Playwright en escritorio, móvil, horizontal, scroll inverso y sin WebGL.
El juego de demostración, en desarrollo, es una carrera de karts en Flutter con 3D real (flutter_scene
y física Rapier): circuitos fijos o generados a partir de una semilla, rivales con IA que recuperan la trazada cuando los sacas de pista, turbos, monedas, peligros, embestidas, minimapa y un filtro para daltonismo. Todavía no usa la librería; cuando la integre será su banco de pruebas, con el tiempo por vuelta como marcador, los hitos de carrera como logros y los circuitos favoritos guardados en la nube.
Stack
| Área | Tecnología |
|---|---|
| Librería | Dart, melos (12 paquetes) |
| Nativo | dart:ffi, ffigen, package:jni / jnigen, package:objective_c |
| Calidad |
package:test, ficheros de referencia de API (api_summary),
dart analyze --fatal-infos
|
| Documentación para agentes |
Skills (
SKILL.md
) instaladas con
dart run skills@ get
|
| Landing | Next.js, React Three Fiber, GSAP, Playwright |
| Juego de demostración | Flutter, flutter_scene, Rapier |
Steam, Epic Games, Game Center, Google Play, GameJolt y PlayFab son marcas de sus respectivos propietarios. Su mención no implica afiliación ni respaldo.