# 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 `` 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 ` ``` Notas sobre el esqueleto: - Está escrito en ES5 a propósito (WebViews viejos de Android). Si tu público es solo el dashboard web, ES2020 está bien. - `empezar()` se llama al final del arranque: la partida corre con defaults si el handshake tardó, y la config real se aplica cuando llegue **entre** partidas, nunca a mitad de una. - El re-handshake (`reintentar`) NO repite `ready`: pide `getConfig`/`getPlayer` directamente. `ready` ya cumplió su función (apagar el spinner del host) y repetirlo antepone un viaje completo de ida y vuelta a la petición que de verdad importa — con un host lento, la config real de la campaña tardaría el doble y la primera partida se jugaría y reportaría contra los defaults. Es la misma razón por la que el §4 lo desaconseja para el paquete npm, donde además `ready()` queda latcheada y ni siquiera cruzaría el bridge. - Todo texto que se muestra se **asigna** cada vez que se muestra, no solo se oculta y se revela: `#saving` se usa tanto para "Guardando resultado…" como para el error de guardado, y si no se restaura, un fallo deja el indicador mintiendo en todas las partidas siguientes. - No hay `innerHTML` en ninguna línea. Es deliberado. - Los `catch` vacíos son deliberados: el bridge puede rechazar y ningún rechazo debe romper la partida. --------------------------------------------------------------------------- ## 4. Variante con el paquete npm --------------------------------------------------------------------------- Si tu juego se compila con un bundler (Vite, esbuild, webpack), existe un paquete oficial que implementa el protocolo base: npm install @southgames-sdk/game-sdk@0.2.0 import { SouthGames } from "@southgames-sdk/game-sdk"; await SouthGames.ready(); // primero, siempre const [config, player] = await Promise.all([ SouthGames.getConfig().catch(() => ({})), SouthGames.getPlayer().catch(() => ({ displayName: "", locale: "es" })), ]); // ... el jugador juega ... const res = await SouthGames.submitResult({ score: 42, won: true, metadata: { nivel: 3 } }); if (res.promoCode) mostrarCodigo(res.promoCode); if (res.message) mostrarMensaje(res.message); await SouthGames.close(); // recién después del submitResult SouthGames.on("pause", () => { pausado = true; }); SouthGames.off("pause", handler); Superficie completa del paquete: `ready`, `getConfig`, `getPlayer`, `getSession`, `submitResult`, `trackEvent`, `close`, `on`, `off`. Sin dependencias, menos de 2 KB, tipos TypeScript incluidos (`GameConfig`, `PlayerInfo`, `SessionInfo`, `GameResult`, `SubmitResultResponse`). **Cuatro límites del paquete que debes cubrir tú** (por eso el esqueleto del §3 no lo usa): 1. **No implementa `selfClose`.** Ni anuncia `southgames:capabilities` ni responde `southgames:requestClose`. Si tu juego corre en la app móvil, añade esos dos mensajes a mano (§1.6) o la X nativa matará partidas. 2. **Registra su listener de `message` dentro de `ready()`.** Un `southgames:init` que llegue antes de tu primera llamada a `ready()` se pierde. Llama `ready()` en la primera línea de tu módulo. 3. **`ready()` queda "latcheada" aunque falle.** Está marcado como inicializado antes de enviar nada: si rechazó porque el bridge todavía no existía, reintentar `ready()` resuelve al instante sin volver a hablar con el host. Para el re-handshake usa `getConfig()`/`getPlayer()`, que sí cruzan el bridge. 4. **Rechaza de inmediato si `window.parent === window`** al momento de la llamada. Es la misma carrera del §6.1 vista desde el paquete. Salida del bundler: el resultado debe ser **un archivo JS plano** referenciado con ``. Sin `type="module"`, sin imports dinámicos, sin code splitting: el inliner pega un solo archivo y un `import` en runtime buscaría una URL que no existe. --------------------------------------------------------------------------- ## 5. Harness: cómo se prueba en local --------------------------------------------------------------------------- Tu juego necesita un padre que hable el protocolo. Sin él, cada request muere a los 30 segundos. Guarda esto como `harness.html` junto a tu `index.html` (y **exclúyelo del ZIP**): ```html Harness SouthGames
``` Sírvelo con cualquier servidor estático — `file://` no funciona con iframes: npx serve . # o python3 -m http.server 8080 Y abre `http://localhost:8080/harness.html` (**el harness, nunca el `index.html` suelto**). El panel derecho acumula **todos** los mensajes con hora, y los deja también en `window.__LOG` (un array de strings) para poder auditarlos desde la consola. Es lo que hace verificable el checklist del §8: afirmar "cero envíos" necesita un log completo, no el último mensaje. Las siete pruebas que el harness permite y que hay que hacer siempre: 1. **Victoria y derrota.** Marca "forzar derrota": la pantalla final no debe asumir que hay código. 2. **Modo demo.** Abre `harness.html?demo=1` (el harness propaga el flag al iframe): el juego debe jugarse solo y en el panel NO debe aparecer ni `submitResult` ni `trackEvent`. Verificación exacta, en la consola: __LOG.filter(function (l) { return /submitResult|trackEvent/.test(l); }) // debe devolver [] 3. **Host mudo.** Abre `harness.html?mute=1`: el harness **no envía `southgames:init` y no responde ningún request**. Tienen que faltar las dos cosas — silenciando solo el listener, el `init` del handler de `load` sigue anunciando el bridge y el juego se recupera por la segunda oportunidad (§6.2), que es precisamente lo que esta prueba no quiere ejercitar. Con el host realmente mudo, tu juego debe arrancar igual a los 3 segundos, con defaults, y seguir jugable. 4. **Bridge tardío.** Retrasa el `southgames:init` y las respuestas 5 segundos con un `setTimeout`: el juego arranca con defaults y debe aplicar la config real cuando llegue, sin romperse. En el log, el re-handshake debe verse como `getConfig`/`getPlayer` **sin** un segundo `ready` delante (§4, límite 3). 5. **Cierre con envío en vuelo.** Retrasa 4 s la respuesta de `submitResult` y toca "Volver" apenas termina la partida: debe verse "Guardando resultado…" y el `close` debe llegar DESPUÉS (o a los 2,5 s, no antes). 6. **X nativa.** Toca "X nativa (requestClose)" en partida: debe llegar el `requestCloseAck` en el log y aparecer tu pantalla de abandono, sin `submitResult`. 7. **Fallo de guardado y revancha.** Haz que `submitResult` rechace una vez (por ejemplo, quita `"scoring"` de `PERMISOS`): debe verse "No se pudo guardar el resultado", sin premio inventado. Vuelve a poner el permiso, toca "Volver a jugar" y termina otra partida: el indicador debe volver a decir "Guardando resultado…". Si sigue mostrando el error mientras guarda bien, te falta restaurar el texto al empezar la partida. --------------------------------------------------------------------------- ## 6. REGLAS DURAS aprendidas en producción --------------------------------------------------------------------------- Cada una viene de un incidente real. Están en imperativo y con el porqué en una línea. No son estilo: son la diferencia entre un juego que funciona en el dashboard y uno que pierde partidas en el teléfono de un cliente. **6.1 — Evalúa "estoy embebido" al momento de usarlo, jamás en una `const` al cargar.** En el WebView el host inyecta el `window.parent` falso DESPUÉS de que tu script arrancó; una `const EMBEBIDO = window.parent !== window` evaluada al cargar queda en `false` para siempre y el juego descarta `submitResult` en silencio — partidas ganadas que nunca llegaron al servidor. function estaEmbebido() { return window.parent !== window || !!window.SouthGamesNative; } **6.2 — Ponle tope de 3 s al handshake y arranca con defaults si vence; deja abierta la segunda oportunidad.** El timeout del bridge es de 30 s: sin tope propio, un host que no responde deja el juego colgado en "Cargando…" para siempre. Y como el bridge del WebView puede instalarse tarde, escucha `southgames:init` (registrado antes de cualquier `await`) para rehacer el handshake: sin eso, la partida se juega y reporta puntaje contra la config por defecto en vez de la de la campaña. **6.3 — Cerrar debe ESPERAR el `submitResult` en vuelo: tope ~2,5 s e indicador visible.** Cerrar directo mata el WebView con el envío a medias y la partida del jugador se pierde. Registra la promesa ANTES del `await`, muestra "Guardando resultado…", corre `Promise.race([todos, timeout(2500)])` y recién entonces emite `close()`. Bloquea reenvíos ~1,5 s: **el host no deduplica `close`** y un doble toque produce doble cierre. Si el jugador tocó "Volver a jugar" durante el flush, **aborta el cierre**: matarías la partida nueva. **6.4 — Modo demo (`?demo=1`): jamás envíes resultados ni eventos.** El dashboard previsualiza los juegos en attract mode agregando `?demo=1` a la URL; léelo de tu propio `location.search`. Un bot de demo contaminando puntajes, ranking o analítica reales es un incidente. Silencia también el audio (no hubo gesto del usuario) y no toques el récord local. Corta en UN solo lugar (un wrapper `track()` y un `if (DEMO) return` antes del envío), no repartido por el código. La vista previa además solo concede `config` y `user.displayName`, así que un envío sería rechazado igual. **En attract mode NO hay jugador, y eso desactiva las reglas que dependen de uno.** Concretamente, en `?demo=1`: - **La pausa por `visibilitychange`/`blur` es no-op.** La regla de §1.7 ("no reanudes solo: exige gesto del jugador") supone que hay alguien para hacer ese gesto; en la vitrina no lo hay. Aplicarla al pie de la letra deja la vista previa de campaña congelada detrás de un overlay de "Continuar" que nadie va a tocar nunca. Deja correr el bot, o reinícialo al volver: lo que no puede es quedar bloqueado. - **Ninguna pantalla espera una acción para avanzar.** El fin de partida se encadena solo con un `setTimeout` a una partida nueva (el esqueleto del §3 lo hace con 2,5 s). Nada de "toca para continuar" en el bucle de la demo. - **No dibujes la X ni el botón de volver**: no hay a dónde volver, y el dashboard ya pone su propio marco. **6.5 — Abandonar a mitad de partida: pantalla explícita y CERO envío.** No otorgues puntos por abandono. La X en partida no cierra: pausa, muestra "Abandonaste — no obtuviste puntos" y deja el cierre para un segundo toque, por el camino seguro de 6.3. Si no lo haces, cualquiera farmea puntaje entrando y saliendo. **6.6 — `textContent` para todo texto que venga de la config o del servidor; jamás `innerHTML`.** Los strings de `getConfig` los escribe la organización, no tú, y el dashboard permite editarlos como JSON libre. Lo mismo para `message` y `promoCode` de `submitResult`. **6.7 — Nunca pintes en pantalla el identificador del jugador.** `displayName` es lo que el host tenga a mano: en la app móvil es literalmente el `externalUserId`, que suele ser un correo o un id opaco. Es PII y quedaría visible en capturas y en la vitrina del dashboard. Si el valor no parece un nombre humano (contiene `@`, o es una sola palabra de más de ~16 caracteres), **no muestres nada**. **6.8 — Un rechazo del bridge no es una victoria.** Si `submitResult` rechaza, muestra "No se pudo guardar el resultado" y ya. Nunca sortees un premio local, nunca reintentes en bucle, nunca inventes un código. Vale doble para el veredicto de baneo del servidor (§6.11). **6.9 — Presupuesto móvil: lo que duele son las draw calls, no los triángulos.** En 3D, fusiona geometría estática por material (un draw call por material, no por objeto), usa `InstancedMesh` para lo repetido, evita sombras dinámicas (una mancha bajo el personaje alcanza) y limita el device pixel ratio a 2. Objetivo medible: **menos de 100 draw calls** en gama media a 60 fps. En 2D, lo caro es el tamaño del documento: apunta a **menos de 2-3 MB de ZIP** (el límite de 50 MB es para no bloquearte, no una meta), texturas WebP y audio corto. **6.10 — `viewport-fit=cover` + safe-areas con piso mínimo.** y en el layout `max(env(safe-area-inset-top, 0px), 24px)`. El piso mínimo no es paranoia: **en los WebView de Android `env(safe-area-inset-top)` resuelve 0 aunque haya barra de estado**, y el HUD queda pegado al borde o tapado. Si necesitas el valor en JS, léelo de un elemento sonda con `padding-top: env(safe-area-inset-top, 0px)` vía `getComputedStyle`, y aplica el mismo `Math.max(inset, 26)`. **6.11 — Maneja el 403 `PLAYER_BANNED` cuando tu código hable con la API REST.** La organización puede banear jugadores; el endpoint de partidas responde **403** con `{ "error": "Tu cuenta no puede participar en los juegos", "code": "PLAYER_BANNED" }`. Es un **veredicto del servidor, no un fallo de red**: muestra el `error` del body tal cual, trátalo como estado terminal, sin reintento, sin respaldo offline y sin sorteo local. Un baneado "ganando" premios fantasma es inaceptable. *Alcance exacto*: esto aplica al host o al juego que llama `POST /api/v1/games/play` directamente. **Un juego del marketplace dentro del iframe/WebView no ve este código**: el host traduce el 403 a un rechazo genérico del bridge (`Error al procesar resultado` en la app) o a una respuesta sin premio. Por eso 6.8 es la regla que te protege ahí: ante cualquier rechazo, estado terminal y nada de premios inventados. **6.12 — No dependas de nada que el host no te dé.** No hay almacenamiento en servidor para tu juego, no hay partidas guardadas, no hay leaderboard vía bridge, no hay red. `localStorage` funciona (úsalo para el récord personal, como hacen los juegos de producción) pero es por dispositivo y puede fallar en modo privado: envuélvelo en `try/catch` siempre. **6.13 — Una partida, un envío.** Manda `submitResult` exactamente una vez por partida terminada. Pon un guard de reentrada en tu `terminar()` (un mismo frame puede disparar dos condiciones de fin) y un token de partida (`runToken`) para que la respuesta de la partida N no pinte el premio sobre la pantalla de la partida N+1. --------------------------------------------------------------------------- ## 7. Modos de falla: síntoma → causa → arreglo --------------------------------------------------------------------------- | Síntoma | Causa real | Arreglo | | ------- | ---------- | ------- | | El juego se queda en "Cargando…" y a los 15 s el host muestra "El juego tardó demasiado en cargar" | Nunca llamaste `ready`, o lo llamaste después de cargar assets pesados | Llama `ready` en la primera línea ejecutable; carga assets después | | El juego arranca pero siempre con la configuración por defecto | El bridge del WebView se instaló después de tu handshake y nadie escuchó el `southgames:init` | Registra el listener antes de cualquier `await` y rehaz `getConfig`/`getPlayer` al recibir `init` (§6.2) | | Las partidas ganadas no aparecen en el dashboard | `const EMBEBIDO` evaluada al cargar quedó en `false` y el envío se descartó en silencio | Función `estaEmbebido()` evaluada al usarse (§6.1) | | El jugador cierra y su partida se pierde | `close()` emitido con el `submitResult` en vuelo | Cierre seguro con flush y tope de 2,5 s (§6.3) | | Al cerrar, la app hace dos veces "atrás" | Doble `close` (el host no deduplica) | Guard `cerrando` + ventana muerta de ~1,5 s (§6.3) | | Las imágenes no cargan en producción pero sí en local | Ruta compuesta en runtime (`"assets/" + n + ".png"`) que el inliner no pudo reemplazar | Mapa de literales entre comillas (§2.3) | | Falta un asset concreto y el resto sí carga | Extensión fuera de la lista inlineable, o dos archivos con el mismo nombre pelado en carpetas distintas | Usa una de las 13 extensiones soportadas y nombres únicos (§2.3) | | Un `fetch("data/niveles.json")` devuelve 404 en producción | El JSON no se inlinea; el juego es un documento suelto sin servidor detrás | Embebe los datos en el JS como objeto (§2.3) | | Media página se ve como texto plano | Tu JS contiene la subcadena de cierre de `script` y cortó el bloque | Pártela: `"<" + "/script>"` (§2.3) | | El HUD queda tapado por la barra de estado en Android | `env(safe-area-inset-top)` resuelve 0 en ese WebView | Piso mínimo: `max(env(...), 24px)` (§6.10) | | `submitResult` rechaza con "Permiso 'scoring' no concedido" | El juego no declaró ese permiso al crearse en el portal | Añádelo en la ficha del juego (no en el ZIP) | | Todo funciona en la app pero falla en el dashboard | El host Flutter no aplica permisos; el web sí | Declara todos los permisos funcionales que uses (§1.4) | | Todo request muere con "timeout" a los 30 s | Abriste `index.html` suelto, sin harness ni plataforma | Abre siempre el harness (§5) | | Ganó pero no hay `promoCode` | La campaña sortea el premio server-side; `won` solo se honra con `rewardType: "none"` | No prometas premio antes de leer la respuesta; muestra `message` (§1.5) | | El puntaje del jugador no aparece en el ranking | Enviaste `submitResult` sin `score` | `score` es la única vía al ranking (§1.3) | | El ranking muestra 0 aunque enviaste puntaje | Mandaste un string, un negativo o un `NaN`: el servidor los guarda como `0` en silencio | Entero finito ≥ 0 (§1.3) | | Los puntajes se ven "aplastados" o todos iguales | Tu escala es decimal y el servidor trunca con `Math.floor` | Escala entera (×10/×100 si necesitas fracciones) (§1.3) | | El attract mode del dashboard mete puntajes reales | El bot de demo llamó `submitResult`/`trackEvent` | Corte único por `?demo=1` (§6.4) | | La vista previa de campaña se queda congelada tras un overlay | El juego pausó por `visibilitychange`/`blur` y espera un gesto que en attract mode nadie hará | En `?demo=1` la pausa es no-op (§6.4) | | Con un host lento la config real llega al doble de tarde y la primera partida usa defaults | El re-handshake vuelve a llamar `ready` antes de `getConfig` | Reintenta con `getConfig`/`getPlayer` directamente (§3, §4 límite 3) | | El indicador dice "No se pudo guardar" en partidas que guardaron bien | El texto de error nunca se restauró al empezar la partida siguiente | Asignar el texto cada vez que se muestra (§3) | | El premio de la partida anterior aparece sobre la pantalla nueva | Respuesta tardía de un envío viejo | Token de partida (`runToken`) (§6.13) | | Publicaste una versión nueva y los jugadores siguen viendo la vieja | La organización tiene una versión INSTALADA distinta de la publicada | Ver https://southgames.ai/llms-marketplace.txt, sección "instalado vs publicado" | --------------------------------------------------------------------------- ## 8. Checklist de autoverificación --------------------------------------------------------------------------- Antes de dar por listo un juego, verifica **cada punto ejecutándolo**, no leyéndolo. Protocolo - [ ] El listener de `message` se registra antes de cualquier `await`. - [ ] `ready` es la primera llamada al bridge; los assets pesados cargan después. - [ ] El handshake tiene tope de 3 s y el juego arranca con defaults si vence. - [ ] Al recibir `southgames:init` tardío se rehace `getConfig`/`getPlayer`, **sin repetir `ready`** (§4, límite 3). - [ ] Se anuncia `southgames:capabilities` con `selfClose: true` tras el handshake. - [ ] Se responde `southgames:requestCloseAck` a `southgames:requestClose`. - [ ] `submitResult` se manda exactamente una vez por partida, con `score` y `won`. - [ ] `close` se emite una sola vez, después del flush, con ventana muerta. Robustez - [ ] `estaEmbebido()` es una función, no una constante. - [ ] Todo valor de `getConfig` se normaliza y se acota; el juego funciona con `{}`. - [ ] Nada de `innerHTML`; todo texto externo va por `textContent`. - [ ] El identificador del jugador no se pinta si no parece un nombre humano. - [ ] Un rechazo del bridge no produce premios, reintentos en bucle ni victorias locales. - [ ] `localStorage` está envuelto en `try/catch`. Modo demo y abandono - [ ] Con `?demo=1` no sale ni un `submitResult` ni un `trackEvent` (verificado en el panel de log del harness y con `__LOG.filter(...)`, no de memoria). - [ ] Con `?demo=1` la pausa por `visibilitychange`/`blur` es **no-op**: la vitrina no puede quedarse detrás de un overlay que nadie va a tocar (§6.4). - [ ] Con `?demo=1` el audio está mudo y el récord local no se toca. - [ ] La X en partida abre pantalla de abandono, sin envío y con 0 puntos. Bundle - [ ] `index.html` está en la RAÍZ del ZIP (`unzip -l` lo confirma). - [ ] `harness.html`, `.DS_Store`, `__MACOSX/` y fuentes no usadas están fuera del ZIP. - [ ] Todos los assets se referencian como literales entre comillas. - [ ] Ningún nombre de archivo se repite en carpetas distintas. - [ ] No hay `fetch`/`XHR` a archivos propios ni a CDNs externos. - [ ] El JS no contiene la subcadena de cierre de etiqueta `script`. - [ ] El ZIP pesa menos de 2-3 MB (y en todo caso menos de 50 MB). - [ ] La versión es semver `X.Y.Z` y el changelog tiene entre 5 y 2000 caracteres. Presentación - [ ] `viewport-fit=cover` y safe-areas con piso mínimo de ~24 px. - [ ] Se ve bien en 375×667 y en 430×932, en vertical. - [ ] La pantalla final se ve bien con `promoCode: null` y con `message: null`. - [ ] El `message` del servidor, si viene, se muestra en lugar del copy propio. - [ ] Los textos de estado se **asignan** cada vez que se muestran: un error de guardado no puede quedar pegado en el indicador de las partidas siguientes. Pruebas ejecutadas en el harness - [ ] Victoria, derrota, demo (`?demo=1`), host mudo (`?mute=1`), bridge tardío, cierre con envío en vuelo, `requestClose` en partida y fallo de guardado seguido de revancha (las siete del §5). --------------------------------------------------------------------------- ## 9. Qué NO existe (para no perder tiempo buscándolo) --------------------------------------------------------------------------- - No hay más métodos que los siete de §1.3. - No hay campo `required` en el `configSchema`, ni validación server-side de la config, ni listas de objetos ni campos anidados. Tipos disponibles: `text`, `number` (con `min`/`max`), `boolean` y `select` (con `options`); un `type` desconocido se dibuja como `text`. Atributos comunes: `label`, `default`, `description`. - No hay almacenamiento remoto, ni sesión, ni cookies, ni API key dentro del juego. Todo lo que necesites persistir sale por `submitResult`. - No hay ranking accesible desde el juego: el ranking se alimenta del `score` que reportas y se consulta desde la app del cliente. Cómo se usa ese número, porque condiciona la escala que elijas: · Cada partida escribe en **tres alcances a la vez**: `campaign` (la campaña concreta), `gametype` (tu juego, con `scopeId` = `marketplace:{gameId}`) y `org` (**todos los juegos de la organización mezclados**). Cada alcance en los períodos `all`, semana y mes; el de campaña además lleva período diario. · En cada entrada se acumula `totalScore` (suma de todos tus `score`), `highScore` (el máximo), `totalPlays`, `totalWins` y `winRate`. Se ordena por un `rankingScore` que la organización pondera: `totalScore*scoreWeight + totalWins*winsWeight + winRate*winRateWeight + highScore*highScoreWeight`, con pesos por defecto **1.0 / 100 / 0 / 0.5**. Es decir: por defecto **una victoria vale ~100 puntos de `score`** y el récord pesa la mitad que el acumulado. Sirve para calibrar el orden de magnitud de tu escala, no para depender de ella (la org puede cambiar los pesos). · **En los alcances `campaign` y `gametype` compites solo contigo mismo**: la escala que inventes da igual mientras sea consistente entre partidas del mismo juego. **En el alcance `org` sí se mezclan juegos distintos**, y ahí una escala inflada le gana a una modesta. Si tu juego va a convivir con otros en la misma organización, mantén el `score` en el mismo orden de magnitud que un puntaje "natural" de partida (decenas o cientos, no millones) y no lo multipliques para verte mejor. · Un jugador baneado no entra al ranking (el servidor descarta la escritura). - No hay eventos del host en producción (§1.7). - No hay hot-reload de versiones: cada arreglo es una versión nueva, sellada e inmutable. --------------------------------------------------------------------------- ## 10. Siguiente paso --------------------------------------------------------------------------- Con el ZIP listo y el checklist en verde, el ciclo de publicación completo —registro de desarrollador, creación del juego, `configSchema`, subida de la versión, revisión, publicación, instalación por organización y la trampa de la versión instalada— está en: https://southgames.ai/llms-marketplace.txt Guía equivalente para humanos (misma información, otra forma): https://southgames.ai/docs/guides/marketplace-games