# SouthGames Game SDK — referencia completa para agentes
> Documento autocontenido para asistentes de código. Todo lo necesario para
> construir, probar y empaquetar un juego HTML5 del marketplace de SouthGames
> está aquí: no hace falta abrir ningún otro archivo. Para publicarlo e
> instalarlo, el documento hermano es https://southgames.ai/llms-marketplace.txt
>
> Fuente de verdad: el código de la plataforma (bridge del host web, host
> Flutter/WebView, pipeline del embed, validador de versiones) y los dos juegos
> de producción del repositorio. Cada regla de este documento sale de una línea
> de código o de un incidente real, no de una recomendación genérica.
>
> Última revisión: 2026-08-28.
---------------------------------------------------------------------------
## 0. Modelo mental en 12 líneas
---------------------------------------------------------------------------
- Un juego de marketplace es **un ZIP con un `index.html`**. Nada más.
- El jugador lo ve dentro de un **iframe sandboxed** (dashboard web) o de un
**WebView** (app móvil con el SDK Flutter). En los dos casos el juego es un
documento aislado: no tiene cookies de sesión, ni API keys, ni acceso a
Firestore, ni credenciales de ningún tipo.
- La única vía de comunicación es **`postMessage` contra `window.parent`**, con
un protocolo de sobres (`southgames:request` / `southgames:response`).
- La plataforma **reescribe tu ZIP a un solo HTML autocontenido** antes de
servirlo: JS y CSS locales quedan inline y los binarios se convierten en data
URLs. Eso condiciona cómo referencias tus archivos (§2).
- El juego **no decide el premio**. Reporta `won` y `score`; el servidor
resuelve y responde con un código promocional o `null` (§1.5).
- Ciclo de una partida: `ready` → `getConfig`/`getPlayer` → el jugador juega →
`submitResult` → (mostrar resultado) → `close`.
- El host que te toca puede ser lento, puede no existir, o puede instalarse
DESPUÉS de que tu script arranque. Todo el §6 existe por eso.
Cómo te monta exactamente el host web (el harness del §5 usa lo mismo):
Lo que ese sandbox implica: hay JS, hay `localStorage`, hay descargas y hay
pantalla completa; **no** hay `allow-popups` (`window.open` no funciona), ni
`allow-forms`, ni `allow-modals` (`alert`/`confirm`/`prompt` no funcionan), ni
`allow-top-navigation` (no puedes navegar la página contenedora). En la app
móvil el juego es el documento principal de un WebView a pantalla completa.
Aunque la política permita `autoplay`, **el navegador sigue exigiendo un gesto
del usuario** para arrancar audio: crea o reanuda tu `AudioContext` en el
primer toque, nunca al cargar.
---------------------------------------------------------------------------
## 1. Contrato del bridge (exhaustivo)
---------------------------------------------------------------------------
### 1.1 Sobres
Cuatro tipos de mensaje forman el protocolo base. Todo va por `postMessage`
con objetos planos serializables (structured clone en el iframe; JSON en el
WebView — no metas funciones, `Date`, `Map`, ni referencias circulares).
**Juego → host: request**
{ type: "southgames:request",
id: "sg_1_1724700000000", // string único; obligatorio
method: "getConfig", // uno de los 7 de §1.3
payload: undefined } // solo submitResult y trackEvent lo usan
`id` es obligatorio: el host Flutter descarta cualquier request sin `id`
(no responde nada y tu promesa muere por timeout). Formato libre; la
convención de los SDK oficiales es `sg__`.
**Host → juego: response** (siempre con el mismo `id` del request)
{ type: "southgames:response", id: "sg_1_...", success: true, payload: {...} }
{ type: "southgames:response", id: "sg_1_...", success: false, error: "Permiso 'config' no concedido" }
`payload` puede venir ausente en respuestas exitosas sin datos (`ready`,
`trackEvent`, `close`). Nunca asumas que existe.
**Host → juego: event** (empujado, sin request previo)
{ type: "southgames:event", event: "pause", payload: undefined }
**Host → juego: init** (anuncio del origen del host)
{ type: "southgames:init", origin: "https://southgames.ai" }
`init` cumple DOS funciones y la segunda es la importante:
1. Te dice a qué `targetOrigin` dirigir tus mensajes (en vez de `"*"`).
2. **Es la señal de que el bridge del WebView acaba de instalarse.** En la app
móvil el host inyecta un `window.parent` falso DESPUÉS de que tu script ya
corrió, y anuncia esa instalación con este mensaje. Si perdiste el
handshake por llegar antes, este mensaje es tu segunda oportunidad (§6.2).
### 1.2 Handshake obligatorio y su orden
1. **Registra el listener de `message` ANTES de cualquier `await`.** Si lo
registras después de tu primer `await`, un `southgames:init` que llegue
durante esa espera se pierde para siempre.
2. Llama `ready` **primero y lo antes posible**, antes de cargar assets
pesados. El host web muestra un spinner hasta recibirlo y aborta con
"El juego tardó demasiado en cargar" a los **15 segundos**. El host Flutter
oculta su spinner 3 s después de terminar de cargar la página, aunque no
haya `ready` (no aborta, pero el jugador ve el juego tapado hasta entonces).
3. Recién después pide `getConfig` y `getPlayer` (se pueden pedir en paralelo).
4. **Pon un tope de 3 s a todo el handshake y arranca con defaults si vence**
(§6.2). El timeout del bridge es de 30 s: sin tope propio, un host mudo deja
tu juego colgado en "Cargando…" medio minuto o para siempre.
5. `getSession` es opcional; pídelo solo si vas a usar `campaignId`,
`attemptId` o `remainingAttempts`.
Timeouts que existen de verdad, todos medidos en el código:
| Reloj | Valor | Quién lo aplica |
| --------------------------------------- | -------- | --------------- |
| Timeout de un request del bridge | 30 000 ms| SDK del juego (npm y `lib.js`) |
| Spinner del host web sin `ready` → error| 15 000 ms| Dashboard web |
| Spinner del host Flutter sin `ready` | 3 000 ms tras `onPageFinished` | SDK Flutter (solo oculta el spinner) |
| Tope recomendado del handshake propio | 3 000 ms| **Tu juego** (§6.2) |
| Tope recomendado del flush al cerrar | 2 500 ms| **Tu juego** (§6.3) |
| Ack de `requestClose` antes del fallback| 600 ms| SDK Flutter (§1.6) |
### 1.3 Métodos: payload exacto, respuesta exacta, permiso exacto
| Método | Permiso requerido | Payload que envías | Payload que recibes |
| -------------- | ------------------- | ------------------ | ------------------- |
| `ready` | ninguno | — | ninguno (`success: true` a secas) |
| `getConfig` | `config` | — | objeto plano de configuración (puede ser `{}`) |
| `getPlayer` | `user.displayName` | — | `{ displayName: string, locale: string }` |
| `getSession` | ninguno | — | `{ campaignId: string, attemptId: string, remainingAttempts: number }` |
| `submitResult` | `scoring` | `{ score?: number, won: boolean, metadata?: object }` | `{ promoCode: string\|null, rewardType: string\|null, message: string\|null }` |
| `trackEvent` | `analytics` | `{ eventName: string, data?: object }` | ninguno |
| `close` | ninguno | — | ninguno |
Detalles que importan de cada uno:
- **`ready`** — idempotente en los SDK oficiales (la segunda llamada no manda
nada). Su respuesta es la prueba de que hay un host escuchando.
- **`getConfig`** — devuelve la mezcla `{...config instalada por la org,
...overrides de la campaña}`. Puede ser `{}` perfectamente (la vista previa
de campaña no pasa config, y la instalación por defecto guarda `{}`).
**El servidor NO valida esta config contra tu `configSchema`**: en el
dashboard la organización puede editarla como JSON libre. Normaliza y acota
todo lo que leas (`Number(x) || default`, `clamp`, lista blanca de strings).
Claves que ya no uses: ignóralas en silencio, siguen viniendo de configs
viejas.
- **`getPlayer`** — `displayName` **no es necesariamente un nombre humano**.
En la app móvil el host responde literalmente con el `externalUserId` de la
integración: puede ser `usuario@correo.com`, `user_8a3f…` o un UUID. Es PII.
Ver la regla de §6.7 antes de pintarlo en pantalla.
`locale` es informativo (el host Flutter siempre responde `"es"`).
- **`getSession`** — `remainingAttempts` puede venir **`-1`** (host Flutter:
"no sé"). Nunca lo pintes crudo ni lo uses en una resta. `attemptId` en el
host Flutter es un timestamp en milisegundos como string; no lo uses como
identificador estable ni lo muestres.
- **`submitResult`** — ver §1.5 completo. `won` es obligatorio y booleano;
`score` es opcional pero **es la única vía por la que el puntaje llega al
ranking** (si lo omites, la sesión se guarda con `score: 0` para siempre).
`metadata` se guarda junto a la sesión; útil para depurar, no lo uses para
nada que deba ser confiable (el jugador controla el cliente).
**Forma exacta de `score` (esto lo hace el servidor, no es una
recomendación):** se acepta si es `number` y `>= 0`, y se guarda
**`Math.floor(score)`**; cualquier otra cosa —negativo, `NaN`, `undefined`,
string, `null`— se guarda como **`0`**, sin error ni aviso. Consecuencias que
cambian cómo diseñas tu puntuación:
| Mandas | Se guarda | Nota |
| ------ | --------- | ---- |
| `1840` | `1840` | el caso normal |
| `12.7` | `12` | **los decimales se truncan**: si necesitas precisión, escala (×10, ×100) y trabaja en enteros |
| `-5` | `0` | no hay puntajes negativos; desplaza tu escala para que el piso sea 0 |
| `"1840"` | `0` | manda un `number`, no un string |
| omitido / `NaN` | `0` | |
No hay techo definido, pero **manda siempre un entero finito**: un `Infinity`
(por ejemplo de una división por cero en tu cálculo) pasa el filtro `>= 0` y
llega a la base de datos como un valor que ningún ranking puede ordenar.
Regla práctica: **entero ≥ 0, finito, calculado con `Math.round`/`Math.floor`
antes de enviarlo.** Sobre en qué ranking cae ese número y si compite contra
otros juegos, ver §9.
- **`trackEvent`** — dispara y olvida. Su respuesta no trae nada. Nunca
bloquees el juego esperándola; `.catch(() => {})` siempre.
- **`close`** — pide al host que cierre o transicione. **El host Flutter
procesa CADA mensaje `close` sin deduplicar**: dos `close` seguidos producen
dos cierres (doble `Navigator.pop` en la integración típica). Emítelo una
sola vez y bloquea reenvíos durante ~1,5 s (§6.3).
Un método desconocido responde `success: false` con
`Método '' no soportado` (host web) o `Método no soportado` (host
Flutter). No inventes métodos: no hay `getLeaderboard`, ni `saveState`, ni
`vibrate`, ni `share`, ni nada fuera de esos siete.
### 1.4 Permisos
El juego declara sus permisos **al crearse en el portal** (no en el ZIP, no en
un manifiesto). El host web los aplica en runtime; llamar a un método sin su
permiso responde `success: false` con el texto exacto
`Permiso 'config' no concedido` (o `'scoring'`, `'user.displayName'`,
`'analytics'`).
Permisos **funcionales** (gatean métodos):
| Permiso | Habilita |
| ------------------ | -------------- |
| `scoring` | `submitResult` |
| `config` | `getConfig` |
| `user.displayName` | `getPlayer` |
| `analytics` | `trackEvent` |
Permisos **declarativos**: `rewards`, `timer`, `audio`. No gatean nada, no
cambian ningún comportamiento; son etiquetas que la organización ve en la
ficha del juego. Declararlos es opcional.
Default al crear un juego: `["scoring", "config"]`. Pide solo los funcionales
que uses de verdad.
**No hay forma de consultar qué permisos te concedieron**: no existe un método
`getPermissions`. Te enteras por el rechazo. Por eso todo método gateado se
llama con su `catch` y un camino alternativo (`getConfig().catch(() => ({}))`).
Dos verdades del código que cambian cómo programas:
- **El host Flutter no aplica permisos**: responde los siete métodos siempre.
Un juego que funciona en la app puede fallar en el dashboard web por un
permiso no declarado. Programa contra el host más estricto (el web).
- **La vista previa de campaña concede solo `["config", "user.displayName"]`**.
Ahí un `submitResult` o `trackEvent` sería rechazado por permiso. Es una
razón más para no llamarlos en modo demo (§6.4).
### 1.5 `submitResult` en detalle: quién decide el premio
Lo que mandas:
{ score: 1840, won: true, metadata: { nivel: 3, duracion: 62.4 } }
Lo que recibes:
{ promoCode: "SG-AB2C-XY9Z" | null,
rewardType: "promo_code" | "points" | "none" | "discount" | null,
message: "Ganaste un 20% de descuento" | null }
Reglas duras del resultado:
1. **`won: true` NO garantiza premio.** En campañas que entregan premio el
servidor sortea el resultado con la probabilidad configurada por la
organización y tu `won` se ignora. Solo en campañas con
`rewardType: "none"` (compiten únicamente por ranking) el servidor honra
el `won` que reportas.
2. **Nunca anuncies el premio antes de leer la respuesta.** Pinta "ganaste"
solo con `promoCode` en la mano, o usa el `message` del servidor.
3. **`message` del servidor gana sobre tu copy local.** Es la única forma de
que el jugador de una campaña sin premio no vea un "¡Ganaste!" que promete
algo que no existe. Si viene `message`, muéstralo; si viene `null`, usa tu
propio texto.
4. **`rewardType` es cadena abierta.** El host web reenvía el del servidor
(`promo_code`, `points`, `none`); el host Flutter manda literalmente
`"discount"` cuando se ganó y `null` cuando no. No ramifiques tu lógica
por este campo: úsalo a lo sumo para telemetría.
5. En modo prueba del portal ("Probar juego") la respuesta siempre es
`{ promoCode: null, rewardType: null, message: "Modo prueba — resultado no
registrado" }`. Tu pantalla final debe verse bien con `promoCode: null`.
6. `submitResult` puede **rechazar**. Trátalo como "no se pudo guardar" y
muéstralo; nunca lo conviertas en una victoria local ni sortees un premio
por tu cuenta (§6.8).
### 1.6 Extensiones del protocolo: cierre negociado (`selfClose`)
Además de los cuatro sobres base hay tres mensajes que implementa el host
Flutter (SDK `southgames_flutter` >= 0.18.2) y que un juego serio debe
soportar. Los hosts que no los entienden los ignoran sin romperse.
**Juego → host, una sola vez, después de que el handshake respondió:**
{ type: "southgames:capabilities",
id: "sgcap_1724700000000",
capabilities: { selfClose: true } }
`selfClose: true` significa "traigo mi propia X y mi propio cierre seguro".
El host Flutter **oculta su X nativa** al recibirlo. La X nativa cierra el
WebView directo, saltándose tu pantalla de abandono y matando cualquier
`submitResult` en vuelo: anunciar capacidades es cómo evitas eso.
Si no lo anuncias, la X nativa sigue visible — por eso, si dibujas tu propia
X, ponla **arriba a la izquierda** (la nativa vive arriba a la derecha) para
que nunca queden apiladas en hosts viejos.
**Host → juego, cuando el jugador toca la X nativa:**
{ type: "southgames:requestClose", id: "sgclose_1724700000000" }
**Juego → host, inmediatamente (dentro de 600 ms):**
{ type: "southgames:requestCloseAck",
id: "sgcloseack_1724700000000",
requestId: "sgclose_1724700000000" }
El ack cancela el fallback del host: sin ack en 600 ms, el host asume un juego
viejo y cierra directo. Con ack, el control es tuyo: si hay partida en curso,
muestra tu pantalla de abandono; si no, cierra por tu camino seguro (§6.3).
Estos mensajes viajan por el mismo canal (`window.parent.postMessage`) y NO
llevan `type: "southgames:request"`, así que no pasan por el enrutador de
métodos ni esperan `southgames:response`.
### 1.7 Eventos del host: qué esperar de verdad
El protocolo define `southgames:event` con eventos como `pause` y `resume`.
**Ningún host del ecosistema los emite hoy** (ni el dashboard web ni el SDK
Flutter tienen una sola línea que los envíe). Los SDK del lado del juego sí los
saben recibir.
Consecuencia práctica: registra `on("pause")` / `on("resume")` porque son tres
líneas y el día que existan funcionarán, pero **no dependas de ellos** para
pausar cuando la app pasa a segundo plano. Para eso usa las APIs del navegador,
que sí funcionan hoy en el WebView:
document.addEventListener("visibilitychange", function () {
// En attract mode (?demo=1) la pausa es NO-OP: no hay jugador que
// reanude y la vitrina se congelaría para siempre (§6.4).
if (document.hidden && !DEMO) pausar();
});
window.addEventListener("blur", function () { if (!DEMO) pausar(); });
Dos cosas que la regla "no reanudes solo: exige gesto del jugador" no dice y
hay que decidir:
- **Qué pasa con la ronda que estaba en curso.** No hay una respuesta impuesta
por la plataforma; hay una que evita reclamos: si la ronda depende del tiempo
o de reflejos, **repítela desde el principio** en vez de continuarla, porque
el jugador vuelve sin contexto y perdería una partida por una llamada
entrante. Si es por turnos o sin reloj, continuar está bien. Lo que no vale
es descartar la partida sin avisar ni dejar el reloj corriendo mientras la
pantalla está oculta.
- **Un `submitResult` en vuelo no se cancela al pausar.** Pausar es cosa del
bucle de juego; el envío sigue su camino y el cierre lo espera igual (§6.3).
---------------------------------------------------------------------------
## 2. El bundle: qué valida el servidor y qué le hace a tu ZIP
---------------------------------------------------------------------------
### 2.1 Reglas que el validador aplica de verdad
Al enviar una versión (`marketplace.submitVersion`) el servidor comprueba, en
este orden, y rechaza con un error explícito:
| Regla | Detalle exacto |
| ----- | -------------- |
| Autoría | Solo el `developerId` dueño del juego puede subir versiones |
| Versión única | Ya existir esa cadena de versión en el juego → `CONFLICT: La versión X.Y.Z ya existe` |
| Semver estricto | `/^\d+\.\d+\.\d+$/`. `1.0`, `v1.0.0`, `1.0.0-beta` y `1.0.0.1` se rechazan |
| Changelog | Obligatorio, entre **5 y 2000** caracteres |
| Ruta del bundle | Debe ser exactamente `marketplace-bundles/{gameId}-v{version}-{timestamp}.zip`. La arma el portal al subir; no es un campo que se invente |
| Bundle existente | El objeto debe existir en Storage; si no, `BAD_REQUEST` |
| Integridad | El servidor descarga el ZIP y calcula el SHA-256 él mismo. Si el hash declarado por el cliente no coincide → subida corrupta, hay que reintentar |
| ZIP válido | Debe parsear como ZIP; si no, `El archivo no es un ZIP válido` |
| `index.html` | Debe existir una entrada `index.html` en la raíz **o** terminada en `/index.html` dentro de una carpeta. Si no → `El ZIP no contiene index.html` |
Y lo que el validador **NO** hace (no te confíes):
- **No valida el tamaño.** El límite de **50 MB** lo aplica el formulario del
portal en el navegador, no el servidor. Respétalo igual, y apunta MUCHO más
abajo (§6.9).
- No mira tu HTML, ni tu JS, ni tus permisos, ni tu `configSchema`.
- No comprueba que llames a `ready` ni que el juego funcione. Eso lo ve la
revisión humana.
- No valida `minSdkVersion` (default `"1.0"`, campo informativo).
Estructura recomendada (con `index.html` **en la raíz** del ZIP: si está dentro
de una carpeta, todas las rutas de assets se resuelven relativas a esa carpeta):
mi-juego/
index.html <- obligatorio
game.js
style.css
assets/
sprite.png
coin.wav
Comando para generar el ZIP correcto — se comprime **el contenido**, no la
carpeta, y se excluye el harness de prueba y la basura del sistema:
cd mi-juego
zip -r ../mi-juego.zip . -x "harness*.html" ".*" "__MACOSX/*" "*/.DS_Store"
Verifica antes de subir:
unzip -l ../mi-juego.zip # index.html debe aparecer sin prefijo de carpeta
### 2.2 El pipeline del embed: tu ZIP se convierte en UN solo HTML
Cuando un jugador abre el juego, el servidor descarga el ZIP, verifica el hash
sellado y construye un único documento HTML:
1. Cada `` se reemplaza por
``. Las URLs externas (`http://`, `https://`,
`//`) se dejan intactas.
2. Cada `` se reemplaza por
`` (funciona con `href` antes o después de `rel`).
3. **Todos** los binarios del ZIP con estas extensiones se convierten en data
URL base64: `.png .jpg .jpeg .gif .webp .svg .mp3 .wav .ogg .mp4 .woff
.woff2 .ttf`.
4. Cada referencia **entre comillas** a esos archivos —en el HTML, en el CSS ya
inlineado y en el JS ya inlineado— se reemplaza por su data URL. Las claves
reconocidas son: el nombre de archivo pelado (`"sprite.png"`), la ruta
dentro del ZIP (`"assets/sprite.png"`) y la misma con `./` delante. Se
reemplaza primero la clave más larga.
5. Se inyecta un pequeño script puente antes de `
Cargando…
0
Guardando resultado…
Abandonaste la partida
No obtuviste puntos. El resultado solo cuenta si terminas.
` y se sirve con
`X-Frame-Options: SAMEORIGIN`.
### 2.3 Consecuencias prácticas — el bloque que más juegos rompe
- **Referencia cada asset como un literal de string entre comillas.**
`img.src = "assets/sprite.png"` funciona. `img.src = "assets/" + nombre +
".png"` **no**: la ruta compuesta en runtime no existe en el HTML final, el
reemplazo nunca ocurrió y el asset no carga jamás. Si necesitas elegir
dinámicamente, declara un mapa de literales y elige la clave:
const SPRITES = { perro: "assets/perro.png", gato: "assets/gato.png" };
img.src = SPRITES[tipo]; // correcto
Lo mismo aplica a plantillas: `` `assets/${n}.png` `` no se reemplaza.
- **Los nombres de archivo deben ser únicos en todo el ZIP.** El mapa de
reemplazo indexa también por nombre pelado: dos `icon.png` en carpetas
distintas colisionan y una gana.
- **Solo esas 13 extensiones se inlinean.** Un `.json`, `.glb`, `.gltf`,
`.fnt`, `.atlas`, `.csv`, `.txt`, `.m4a`, `.aac` o `.otf` **no** viaja al
HTML final: cualquier `fetch()` o `XMLHttpRequest` a un archivo propio
devolverá 404 (no hay servidor de archivos detrás; el juego es un documento
suelto). **Todo dato no binario va embebido en tu JS** como objeto o string.
Un atlas de sprites se declara como objeto JS, no como `.json` cargado.
- **Tu JS jamás puede contener la subcadena de cierre de etiqueta script.**
Como el archivo se pega dentro de un ``
queda inlineado como cualquier otro JS tuyo — solo cuida el orden (la
librería antes que tu juego) y el peso.
- **Base64 pesa ~33% más que el binario**, y el resultado es un solo documento
que el teléfono descarga y parsea de una vez.
- **Escribe el ``; un `` autocerrado no
coincide y quedaría como una petición muerta.
### 2.4 Cacheo y versionado
- Una versión es **inmutable**: aprobada, sus bytes quedan sellados por hash y
cualquier arreglo es una versión nueva.
- El HTML construido se cachea con `Cache-Control: public, max-age=3600,
s-maxage=3600` y un `ETag` derivado del hash del ZIP. El dispositivo y el CDN
revalidan cada hora.
- La vista previa (`preview`) nunca se cachea (`no-store`).
- Traducción práctica: **si acabas de aprobar una versión y ves la anterior,
espera o fuerza recarga** — y revisa antes lo de §"versión instalada" en
https://southgames.ai/llms-marketplace.txt, que es la causa real el 90% de
las veces.
---------------------------------------------------------------------------
## 3. Esqueleto mínimo funcional (postMessage puro, sin dependencias)
---------------------------------------------------------------------------
Este es un juego COMPLETO y publicable: un solo `index.html`, sin bundler, sin
npm, sin CDN. Implementa el protocolo entero (requests, eventos, `init`,
capacidades y cierre negociado) y **todas** las reglas duras del §6. Cópialo
tal cual y reemplaza la sección "LÓGICA DEL JUEGO" por la tuya.
```html