Caso de Uso del SDK de TypeScript
Mirko Franichevic · 27 de agosto de 2026
¿Qué vamos a construir?
En esta guía vas a construir un Buscaminas completo, de atrás para adelante, sobre Praxsuite: las Tables que lo guardan, las Automations que lo gobiernan, y un cliente de React con cuentas, tres dificultades y un marcador global. Todas las reglas del juego viven en el servidor. El navegador pide un tablero, manda coordenadas y pinta lo que le devuelven. Nunca se entera de dónde están las minas.
Al terminar esta guía vas a saber:
Cómo modelar el juego en dos Tables, y por qué el campo minado tiene que ser una de sus columnas
Cómo construir tres Automations nodo por nodo, desde el disparador hasta la respuesta
Cómo conectar una aplicación de React y TypeScript con ese backend usando el SDK (Software Development Kit)
Cómo registrar jugadores, iniciar su sesión y mantenerla entre recargas
Cómo dibujar un tablero en el que no se puede hacer trampa, y leer un marcador compartido
Construyes las dos mitades. Los pasos 1 al 6 son el backend, dentro del portal de Praxsuite. Los pasos 7 al 13 son el cliente.
Nivel requerido: Necesitas manejarte con TypeScript y con los hooks de React. No hace falta experiencia previa con Praxsuite. Si nunca llamaste a un endpoint de Praxsuite, lee primero la guía TypeScript SDK Implementation: esta asume que ya tienes un cliente hablando con un workspace.
Cómo funciona
La mayoría de los tutoriales generaría el campo minado en el navegador. Eso es justamente lo único que esta aplicación nunca hace, y entender por qué es el sentido de toda la guía.
Si el navegador sabe dónde están las minas, el jugador también. Basta con abrir la consola y escribir una línea de JavaScript para ganar siempre. Por eso el campo minado se genera dentro de Praxsuite, se guarda en una Table y no sale nunca. Lo que recibe el navegador es una vista enmascarada: un carácter por celda.
Carácter | Significado |
| oculta |
| con bandera |
| revelada, con la cantidad de minas adyacentes |
| una mina, y sólo aparece cuando la partida ya está perdida |
Esa división decide dónde va cada pieza de la lógica:
Corre en el cliente | Corre en Praxsuite |
Dibujar la vista enmascarada que recibió | Colocar las minas |
Enviar | Decidir qué abre cada clic, incluido el flood fill |
Mostrar un reloj local mientras juegas | Detectar la victoria y detectar la mina |
Pedir el marcador | Calcular el puntaje y escribir la fila del marcador |
Golden rule: Si un cliente modificado enviando un payload arbitrario pudiera obtener algo que no le corresponde, esa operación va en un endpoint. Dibujar un tablero es seguro. Decidir que no pisaste una mina, no.
En esta aplicación no hay resolutor local, y no puede haberlo, porque escribirlo exigiría conocer las minas.
Qué hace distinto hoy la demo que se publicó
Esta guía enseña el patrón de arriba porque es la forma más clara de aprender la regla que importa: nunca confiarle al cliente nada que decida el resultado. Los pasos 3 a 6 construyen justo eso: una Automation, Buscaminas: Jugar, que corre en cada jugada y decide victoria, derrota y puntaje ella misma.
El juego de demostración publicado en el repositorio Praxsuite-AppSource-TypescriptGame se movió más allá de esa base, por latencia: cada clic pagaba antes una vuelta de red completa (navegador → gateway → Automation → tabla → Automation → navegador), y eso se notaba con clics seguidos. Su src/game/motor.ts es ahora un puerto 1:1 del script de Buscaminas: Jugar, corriendo en el navegador en su lugar, y Praxsuite se llama sólo dos veces por partida: una para abrirla (Nueva Partida) y otra al final, mediante un endpoint Validar Resultado que recalcula todo el resultado desde cero contra la fila que guardó Nueva Partida — la misma verificación que Buscaminas: Jugar hubiera hecho en cada clic, hecha una sola vez, al final. Buscaminas: Jugar sigue existiendo en ese workspace; el cliente publicado simplemente ya no la llama.
Las dos formas son válidas, y esta guía sigue construyendo la que enseña la regla de fondo de la manera más directa. La forma de menor latencia que realmente usa la aplicación de referencia se construye más adelante en esta guía, en La Automation `Validar Resultado`.
Requisitos previos
Requisito | Descripción |
Node.js 18 o superior | El SDK usa el |
Un workspace de Praxsuite | Necesitas su workspace id, un UUID que copias del portal |
Acceso al portal | Necesitas poder crear Tables y Automations en ese workspace, no sólo leerlas |
| Se instala con |
Un navegador | Chrome, Firefox o Safari. La aplicación es React puro, nada exótico |
¿Qué es un workspace? Piensa en él como una carpeta grande en la nube donde viven todas tus Tables, Automations y usuarios. El workspace id es lo único que esta aplicación necesita configurar: la clave pública la busca el SDK por su cuenta.
El modelo de datos
Todo lo que el juego recuerda vive en dos Tables. Constrúyelas antes de tocar el cliente, porque todas las Automations de abajo escriben en ellas.
Buscaminas Partidas
Una fila por partida. Aquí es donde vive el secreto.
Columna | Tipo | Qué guarda |
| ShortText (clave) | El código de 8 caracteres que identifica la partida. El cliente lo reenvía en cada jugada |
| ShortText | El id del jugador en la plataforma que lo hospeda: un UserId de Roblox, un id de Steam, o el claim subject de un JWT de Praxsuite |
| ShortText | El nombre visible que va a llegar al marcador |
| Status |
|
| Integer | Dimensiones del tablero y cantidad de minas |
| ShortText |
|
| Json | El campo minado. |
| Json | La misma forma, con |
| Json | La misma forma, con |
| Integer | Cuántas celdas están abiertas, sirve para detectar la victoria |
| Integer | Puntaje final, se escribe sólo cuando la partida cierra |
| DateTime | Cuándo arrancó el reloj y cuándo se detuvo |
| Integer | El tiempo transcurrido que calculó el servidor |
| ShortText | Qué cliente jugó: |
Important: `Matriz` es todo el modelo de seguridad. La escriben y la leen únicamente las Automations, y nunca debe quedar legible a través de un table scope otorgado al rol del jugador. Si un jugador puede leer esa columna, todo lo demás en esta guía es decorativo.
Demos Leaderboard
Una fila por partida terminada, compartida por todos los clientes que hablan con este workspace.
Columna | Tipo | Qué guarda |
| ShortText (clave) | Una etiqueta legible, por ejemplo |
| ShortText | El mismo id que en la fila de la partida |
| ShortText | Nombre visible |
| Integer | El puntaje |
| ShortText | Qué preset se jugó |
| Integer | Cuánto tardó |
| ShortText |
|
| ShortText | Qué cliente produjo la fila |
¿Por qué la matriz es una columna y no memoria?
Una corrida de Automation no tiene estado. Arranca, resuelve una petición y termina, así que no hay dónde guardar un tablero entre jugadas. Escribir la matriz en una fila es lo que hace que la partida sobreviva de un clic al siguiente.
Eso tiene un efecto secundario agradable: la partida es retomable e inspeccionable. Puedes abrir la fila en el portal a mitad del juego y ver exactamente qué cree el servidor.
Paso 1 - Crear las tablas
En tu Workspace, crea una Table llamada Buscaminas Partidas. Praxsuite te pide dos cosas: el nombre, y la Key Column, que es el identificador visible de cada fila. Pon Codigo como columna clave, de tipo ShortText.
Después agrega el resto de las columnas de la tabla de arriba con Create Column, eligiendo el tipo del desplegable. Tres merecen que te detengas:
Estadoes una columna Status, no texto. Crea los tres estadosEn curso,GanadayPerdida. Una columna Status rechaza cualquier valor fuera de su lista, que es justo lo que quieres para una máquina de estados.Matriz,ReveladoyBanderasson columnas Json. Aceptan cualquier valor JSON válido, y aquí guardan arreglos de arreglos.Jugadorpuede ser una columna Enduser si quieres atar la fila a una cuenta de Praxsuite. Esta guía usaJugador Externo Iden su lugar, un ShortText común, para que el mismo backend también sirva a clientes cuyos jugadores no son usuarios de Praxsuite, como el de Roblox.
Repite lo mismo para Demos Leaderboard, con Record como columna clave.
Tip: Los nombres de columna en las Automations tienen que coincidir exactamente, mayúsculas y espacios incluidos. `Celdas Reveladas` con espacio es una columna distinta de `CeldasReveladas`. Esta es la causa más común de una fila que se guarda con campos vacíos y sin ningún error.
El rol del jugador, y qué NO puede tocar
Las tablas ya existen, y ahora mismo el rol de un jugador no alcanza ninguna de las dos. Antes de seguir conviene decidir qué puede ver, porque la respuesta fácil — darle acceso a la tabla y seguir — es exactamente la que vacía de sentido al resto de esta guía.
En Settings → API Gateway → Roles, crea un rol llamado `Buscaminas Jugador` y dale un scope sobre Buscaminas Partidas:
Row filter:
__SELF__sobre la columnaJugador, para que cada jugador alcance únicamente sus propias partidas.Valor por defecto de la columna
Jugador:{{claim:sub}}, para lo que escriba el propio jugador.Acceso de columna, que es donde está todo el asunto:
Columna | Lectura | Escritura |
| No | No |
| Sí | No |
El resto ( | Sí | No |
`Matriz` es el modelo de seguridad entero. Si el rol del jugador puede leerla, un cliente modificado pide su propia fila por el Gateway y sabe dónde están todas las minas antes del primer clic. Si además puede escribirla, puede reescribir el campo minado para que un resultado inventado valide. Las Automations no pasan por estos scopes — corren con la autoridad del workspace — así que cerrarle `Matriz` al jugador no les saca nada a ellas.
Para Demos Leaderboard, el mismo criterio: el rol del jugador lee (el juego muestra el top) y no escribe nunca. La única escritura la hace la Automation que valida el resultado.
Una cuenta de end user recién creada arranca con los roles por defecto que tenga configurados el workspace, y si no hay ninguno se queda sin acceso a nada: toda query vuelve vacía o con 403. Asigna Buscaminas Jugador al registrar al jugador, o desde una Automation que valide su token.
Comprobación rápida: entra a Gateway → Playground, elige el rol `Buscaminas Jugador` y pide la columna `Matriz` de una partida. Tiene que fallar. Si te devuelve la grilla, el scope quedó abierto y cualquier jugador puede leer las minas.
Paso 2 - Cómo se arma una Automation
Una Automation es un grafo: un disparador, y después nodos de acción conectados por aristas. La editas como borrador y la publicas cuando valida.
Para todo este backend necesitas apenas seis tipos de nodo:
Nodo | Categoría | Qué hace aquí |
| Triggers | Arranca la corrida cuando el endpoint recibe un POST |
| Code | Ejecuta JavaScript. Toda la lógica del juego vive en tres de estos |
| Database | Lee filas de una Table |
| Database | Crea una fila |
| Database | Modifica una fila |
| Logic | Bifurca según una condición |
| Logic | Define qué devuelve el endpoint |
Los nodos se pasan valores mediante una plantilla de contexto, escrita entre llaves dobles. Con tres formas alcanza para todo lo de abajo:
{{context.request.body}} el cuerpo JSON completo que envió el cliente
{{context.request.body.codigo}} un campo de ese cuerpo
{{context.steps.generar.matriz}} la salida "matriz" del nodo con id "generar"Un nodo Script declara explícitamente sus inputs y sus outputs. Los inputs asocian una plantilla de contexto a un nombre de variable que tu código puede leer; los outputs nombran los valores que tu objeto de return expone a los nodos siguientes.
Golden rule: La salida de un nodo Script siempre se consume como cadena en una plantilla. Por eso todos los scripts de abajo devuelven JSON con `JSON.stringify()` y todos los que reciben uno lo parsean de vuelta. No pelees con esto; apóyate en ello.
Paso 3 - Construir la Automation de partida nueva
Crea una Automation llamada Buscaminas: Nueva Partida. Son cuatro nodos en línea recta.
trigger -> generar -> guardar -> responderEl disparador. Agrega un nodo EndpointTrigger con id trigger. Créale un endpoint en modo Sync. Sync significa que la conexión queda abierta mientras la Automation corre y devuelve su respuesta, así que el cliente ve una llamada HTTP común. Copia el id del endpoint; la aplicación de React lo va a necesitar.
El nodo Script. Agrega un nodo Script con id generar. Declara un input:
Nombre | Tipo | Origen |
| object |
|
Y estos outputs, todos de tipo string salvo los numéricos: codigo, filas, columnas, minas, dificultad, matriz, revelado, banderas, inicio, alias, jugadorId, motor, sdk, respuesta.
Después pega esto como código del nodo. Reserva el tablero y, deliberadamente, no coloca ni una mina:
// -- Buscaminas: apertura de la partida ---------------------------------------
// Entrada: payload (object) <- {{context.request.body}}
//
// Este endpoint es agnostico del motor: lo llaman por igual el SDK de Lua
// (Roblox), el de C# (Unity) y el de TypeScript. Por eso el cuerpo trae
// `motor` y `sdk`, que se guardan con la partida y viajan al leaderboard.
//
// Aca NO se siembran las minas. Solo se reserva el tablero: medidas, cuantas
// minas va a tener, y las grillas de revelado y banderas en cero. Las minas se
// siembran en la primera jugada (ver `buscaminas-jugar`), cuando ya sabemos que
// celda toco el jugador y podemos dejarla libre.
//
// Sembrarlas aca obliga a que la primera jugada sea una apuesta: en dificil hay
// 45 minas en 256 celdas, asi que una de cada seis partidas se termina en el
// primer click sin que el jugador haya podido decidir nada. Eso no es
// dificultad, es una moneda al aire antes de empezar.
//
// La matriz sigue sin salir nunca del workspace. Al juego solo se le devuelve
// `respuesta`, con el tablero entero oculto.
const body = payload || {};
const PRESETS = {
facil: { filas: 8, columnas: 8, minas: 10 },
medio: { filas: 12, columnas: 12, minas: 24 },
dificil: { filas: 16, columnas: 16, minas: 45 }
};
const dificultad = String(body.dificultad || "facil").toLowerCase();
const preset = PRESETS[dificultad] || PRESETS.facil;
const R = Math.max(4, Math.min(20, Number(body.filas) || preset.filas));
const C = Math.max(4, Math.min(20, Number(body.columnas) || preset.columnas));
const M = Math.max(1, Math.min(R * C - 1, Number(body.minas) || preset.minas));
const revelado = Array.from({ length: R }, () => new Array(C).fill(0));
const banderas = Array.from({ length: R }, () => new Array(C).fill(0));
const vista = Array.from({ length: R }, () => "?".repeat(C));
const ALFA = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let codigo = "";
for (let i = 0; i < 8; i++) codigo += ALFA[Math.floor(Math.random() * ALFA.length)];
// `Inicio` queda con la hora de apertura para que la columna nunca este vacia,
// pero todavia no es el reloj de la partida: `buscaminas-jugar` lo pisa con la
// hora de la primera jugada. Lo que se cronometra es el juego, no el menu.
const inicio = new Date().toISOString();
const alias = String(body.alias || "anonimo");
// Id del jugador en la plataforma que lo hospeda: UserId de Roblox, id de
// Steam, uuid del navegador. Neutro a proposito.
const jugadorId = String(body.jugadorId || body.robloxUserId || "0");
const motor = String(body.motor || "desconocido").toLowerCase();
const sdk = String(body.sdk || "desconocido").toLowerCase();
const respuesta = {
ok: true,
codigo: codigo,
filas: R,
columnas: C,
minas: M,
dificultad: dificultad,
motor: motor,
sdk: sdk,
estado: "En curso",
celdasReveladas: 0,
puntaje: 0,
// El tablero existe pero todavia no tiene minas. Un cliente que quiera
// mostrar "empeza cuando quieras" tiene aca la senal; el que lo ignore ve
// exactamente lo mismo que antes.
sembrado: false,
vista: vista,
inicio: inicio
};
return {
codigo: codigo,
filas: R,
columnas: C,
minas: M,
dificultad: dificultad,
matriz: "[]",
revelado: JSON.stringify(revelado),
banderas: JSON.stringify(banderas),
inicio: inicio,
alias: alias,
jugadorId: jugadorId,
motor: motor,
sdk: sdk,
respuesta: JSON.stringify(respuesta)
};Ejecútalo y devuelve una respuesta donde vista es todo ? y sembrado es false. El tablero existe; el campo minado todavía no.
Guardar la fila. Agrega un nodo InsertRows con id guardar, apuntando a la tabla Buscaminas Partidas, y mapea sus campos a las salidas del script:
Codigo {{context.steps.generar.codigo}}
Alias {{context.steps.generar.alias}}
Jugador Externo Id {{context.steps.generar.jugadorId}}
Estado En curso
Filas {{context.steps.generar.filas}}
Columnas {{context.steps.generar.columnas}}
Minas {{context.steps.generar.minas}}
Dificultad {{context.steps.generar.dificultad}}
Matriz {{context.steps.generar.matriz}}
Revelado {{context.steps.generar.revelado}}
Banderas {{context.steps.generar.banderas}}
Celdas Reveladas 0
Puntaje 0
Inicio {{context.steps.generar.inicio}}
Motor {{context.steps.generar.motor}}
SDK {{context.steps.generar.sdk}}Fíjate que Estado es la cadena literal En curso, no una plantilla. Las columnas Status aceptan directamente el nombre del estado.
Responder. Agrega un nodo Response con id responder, código 200, content type application/json, y esta plantilla de cuerpo:
{{context.steps.generar.respuesta}}El script ya produjo exactamente el JSON que el cliente debe recibir, y por eso al nodo Response no le queda nada que armar. Conecta trigger -> generar -> guardar -> responder, valida y publica.
Paso 4 - Construir la Automation de jugada
Esta es la que guarda las reglas. Crea Buscaminas: Jugar, con ocho nodos y una bifurcación.
trigger -> buscar -> resolver -> actualizar -> termino --[true]--> cerrar -> marcador -> responder
\--[false]-------------------------> responderEncontrar la partida. Agrega un nodo QueryRows con id buscar sobre Buscaminas Partidas, con límite 1 y un filtro:
Columna | Operador | Valor |
|
|
|
Expone la fila como {{context.steps.buscar.row}}.
Resolver la jugada. Agrega un nodo Script con id resolver y dos inputs:
Nombre | Tipo | Origen |
| object |
|
| object |
|
Sus outputs son rowId, codigo, alias, jugadorId, motor, sdk, dificultad, estado, matriz, revelado, banderas, inicio, celdasReveladas, puntaje, segundos, fin, terminada y respuesta.
Este es el fragmento de código más largo de la guía, y es el juego. Coloca las minas en la primera revelación, corre el flood fill, detecta la victoria, puntúa la partida y arma la vista enmascarada:
// -- Buscaminas: resolver una jugada ------------------------------------------
// Entradas (source = valor con plantilla, NO una ruta de contexto):
// payload <- {{context.request.body}} { codigo, accion, fila, columna }
// partida <- {{context.steps.buscar.row}} fila de 'Buscaminas Partidas'
//
// Toda la autoridad vive aqui: el cliente solo manda coordenadas y recibe una
// vista enmascarada. Las minas nunca viajan al juego, salvo al perder, cuando
// ya no importa. Vale igual para Roblox, Unity o un navegador.
//
// Las minas se siembran en la PRIMERA jugada de revelar, no al abrir la
// partida, para poder excluir la celda que toco el jugador. Ver `sembrar`.
//
// OJO: en el contexto de pasos los nombres de columna llegan con guion bajo
// ('Jugador Externo Id' -> Jugador_Externo_Id).
const body = payload || {};
const p = partida || {};
function parseJ(v, fallback) {
if (v === null || v === undefined) return fallback;
if (typeof v === "string") {
try { return JSON.parse(v); } catch (e) { return fallback; }
}
return v;
}
function isoDe(v, porDefecto) {
if (!v) return porDefecto;
const t = Date.parse(String(v));
return Number.isFinite(t) ? new Date(t).toISOString() : porDefecto;
}
let matriz = parseJ(p.Matriz, []);
const revelado = parseJ(p.Revelado, []);
const banderas = parseJ(p.Banderas, []);
// Las medidas salen de la fila, NO de la matriz: hasta la primera jugada la
// matriz esta vacia a proposito y `matriz.length` seria 0, lo que antes hacia
// que la partida se reportara como inexistente.
const R = Number(p.Filas) || 0;
const C = Number(p.Columnas) || 0;
const M = Number(p.Minas) || 0;
const existe = Boolean(p.Codigo) && R > 0 && C > 0;
// Una partida abierta antes de este cambio ya trae su matriz completa: se
// respeta tal cual y se juega como siempre. Solo se siembra lo que esta vacio.
let sembrado = Array.isArray(matriz) && matriz.length > 0;
// La columna Status puede llegar como objeto { Id, Name, ... } o como texto
let estado = (p.Estado && p.Estado.Name) ? p.Estado.Name : String(p.Estado || "En curso");
const accion = String(body.accion || "revelar").toLowerCase();
const fila = Number(body.fila);
const col = Number(body.columna);
let mensaje = "";
function dentro(r, c) { return r >= 0 && r < R && c >= 0 && c < C; }
// Siembra las minas dejando libre la celda que el jugador acaba de tocar y, si
// entran, sus ocho vecinas.
//
// Excluir solo la celda ya alcanzaria para que no pierda en el primer click,
// pero lo dejaria mirando un numero suelto y adivinando igual. Con el 3x3 libre
// la celda tocada tiene cero minas alrededor, asi que el flood-fill abre un
// hueco y la partida empieza con informacion. Es lo que hace el buscaminas
// moderno; para volver a la version minima, dejar solo la celda tocada en
// `prohibidas`.
function sembrar(rSeguro, cSeguro) {
const m = Array.from({ length: R }, () => new Array(C).fill(0));
const prohibidas = {};
let cuantasProhibidas = 0;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
const rr = rSeguro + dr, cc = cSeguro + dc;
if (rr >= 0 && rr < R && cc >= 0 && cc < C && !prohibidas[rr * C + cc]) {
prohibidas[rr * C + cc] = true;
cuantasProhibidas++;
}
}
}
// Tablero chico y muy minado: si dejando el 3x3 libre no entran todas las
// minas, la zona segura se achica a la celda tocada, que es la unica garantia
// que el jugador realmente necesita. Sin esto el while de abajo no terminaria.
if (R * C - cuantasProhibidas < M) {
for (const k in prohibidas) delete prohibidas[k];
prohibidas[rSeguro * C + cSeguro] = true;
}
let puestas = 0;
while (puestas < M) {
const r = Math.floor(Math.random() * R);
const c = Math.floor(Math.random() * C);
if (prohibidas[r * C + c]) continue;
if (m[r][c] === -1) continue;
m[r][c] = -1;
puestas++;
}
for (let r = 0; r < R; r++) {
for (let c = 0; c < C; c++) {
if (m[r][c] === -1) continue;
let n = 0;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const rr = r + dr, cc = c + dc;
if (rr >= 0 && rr < R && cc >= 0 && cc < C && m[rr][cc] === -1) n++;
}
}
m[r][c] = n;
}
}
return m;
}
let inicio = isoDe(p.Inicio, new Date().toISOString());
if (!existe) {
mensaje = "Partida no encontrada";
} else if (estado !== "En curso") {
mensaje = "La partida ya termino";
} else if (!dentro(fila, col)) {
mensaje = "Coordenada fuera del tablero";
} else if (accion === "bandera") {
// Poner banderas antes del primer click es legal y no siembra nada: todavia
// no hay ninguna celda que se pueda garantizar segura.
if (revelado[fila][col] === 0) {
banderas[fila][col] = banderas[fila][col] ? 0 : 1;
}
} else {
if (banderas[fila][col] === 1) {
mensaje = "Esa celda tiene bandera";
} else if (revelado[fila][col] === 1) {
mensaje = "Ya estaba revelada";
} else {
// Primera jugada valida: recien aca sabemos que celda hay que dejar libre.
if (!sembrado) {
matriz = sembrar(fila, col);
sembrado = true;
// El reloj arranca con la primera jugada, no al abrir la partida. Con el
// tablero sembrado al abrir daba lo mismo, pero ahora la partida no
// existe hasta este click: cronometrar desde antes cobraria el rato que
// el jugador estuvo mirando el menu.
inicio = new Date().toISOString();
}
if (matriz[fila][col] === -1) {
revelado[fila][col] = 1;
estado = "Perdida";
mensaje = "Pisaste una mina";
} else {
// Flood-fill iterativo: al abrir un 0 se abre todo el bloque vacio
const pila = [[fila, col]];
while (pila.length > 0) {
const par = pila.pop();
const r = par[0], c = par[1];
if (!dentro(r, c)) continue;
if (revelado[r][c] === 1 || banderas[r][c] === 1) continue;
revelado[r][c] = 1;
if (matriz[r][c] === 0) {
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
pila.push([r + dr, c + dc]);
}
}
}
}
}
}
}
let reveladas = 0;
for (let r = 0; r < R; r++) {
for (let c = 0; c < C; c++) if (revelado[r] && revelado[r][c] === 1) reveladas++;
}
const seguras = R * C - M;
if (existe && estado === "En curso" && sembrado && reveladas >= seguras) {
estado = "Ganada";
mensaje = "Campo despejado";
}
const ahora = Date.now();
// Sin sembrar no hay partida que cronometrar: son las banderas que alguien puso
// antes de decidirse a empezar.
const segundos = sembrado
? Math.max(0, Math.round((ahora - Date.parse(inicio)) / 1000))
: 0;
const terminada = existe && estado !== "En curso";
let puntaje = 0;
if (terminada) {
puntaje = reveladas * 10;
if (estado === "Ganada") {
puntaje += M * 50 + Math.max(0, 600 - segundos) * 2;
}
}
// Vista enmascarada: '?' oculta | 'F' bandera | '0'-'8' revelada | '*' mina (solo al perder)
const vista = [];
for (let r = 0; r < R; r++) {
let s = "";
for (let c = 0; c < C; c++) {
if (sembrado && estado === "Perdida" && matriz[r][c] === -1) s += "*";
else if (sembrado && revelado[r][c] === 1) s += String(matriz[r][c]);
else if (banderas[r][c] === 1) s += "F";
else s += "?";
}
vista.push(s);
}
const motor = String(p.Motor || "desconocido");
const sdk = String(p.SDK || "desconocido");
const respuesta = {
ok: existe,
codigo: String(p.Codigo || ""),
filas: R,
columnas: C,
minas: M,
estado: estado,
celdasReveladas: reveladas,
puntaje: puntaje,
segundos: segundos,
terminada: terminada,
sembrado: sembrado,
mensaje: mensaje,
motor: motor,
sdk: sdk,
vista: vista
};
return {
rowId: String(p.ID || ""),
codigo: String(p.Codigo || ""),
alias: String(p.Alias || "anonimo"),
jugadorId: String(p.Jugador_Externo_Id || p["Jugador Externo Id"] || "0"),
motor: motor,
sdk: sdk,
dificultad: String(p.Dificultad || "facil"),
estado: estado,
matriz: JSON.stringify(matriz),
revelado: JSON.stringify(revelado),
banderas: JSON.stringify(banderas),
inicio: inicio,
celdasReveladas: String(reveladas),
puntaje: String(puntaje),
segundos: String(segundos),
fin: new Date(ahora).toISOString(),
terminada: terminada ? "si" : "no",
respuesta: JSON.stringify(respuesta)
};Dos detalles ahí adentro son fáciles de pasar por alto y caros de equivocar.
Las dimensiones salen de p.Filas y p.Columnas, no de matriz.length. Hasta la primera revelación la matriz está vacía, así que medir el tablero por la matriz reportaría toda partida recién creada como inexistente.
Y los nombres de columna llegan con guiones bajos. Jugador Externo Id te llega al código como p.Jugador_Externo_Id.
Persistir el tablero. Agrega un nodo UpdateRows con id actualizar sobre Buscaminas Partidas, con rowId en {{context.steps.resolver.rowId}} y estos campos:
Estado {{context.steps.resolver.estado}}
Matriz {{context.steps.resolver.matriz}}
Revelado {{context.steps.resolver.revelado}}
Banderas {{context.steps.resolver.banderas}}
Inicio {{context.steps.resolver.inicio}}
Celdas Reveladas {{context.steps.resolver.celdasReveladas}}Matriz tiene que escribirse aquí. Está vacía hasta la primera revelación, y si te olvidas de guardarla de vuelta el tablero se vuelve a sembrar en cada jugada, lo que significa que el jugador nunca puede perder.
Bifurcar. Agrega un nodo IfElse con id termino y una sola regla: {{context.steps.resolver.terminada}} == si.
Cerrar la partida. Sobre la arista true, agrega un nodo UpdateRows con id cerrar que escribe los tres campos que sólo tienen sentido cuando la partida terminó:
Fin {{context.steps.resolver.fin}}
Puntaje {{context.steps.resolver.puntaje}}
Segundos {{context.steps.resolver.segundos}}Escribir el puntaje. Después de ese, un nodo InsertRows con id marcador sobre Demos Leaderboard:
Record Buscaminas {{context.steps.resolver.codigo}} ({{context.steps.resolver.estado}})
Jugador Externo Id {{context.steps.resolver.jugadorId}}
Alias {{context.steps.resolver.alias}}
Points {{context.steps.resolver.puntaje}}
Dificultad {{context.steps.resolver.dificultad}}
Segundos {{context.steps.resolver.segundos}}
Juego Buscaminas
Motor {{context.steps.resolver.motor}}
SDK {{context.steps.resolver.sdk}}Este es el nodo que vuelve inútil hacer trampa. El puntaje llega al marcador desde adentro de la misma Automation que acaba de calcularlo, así que el cliente nunca envía un número.
Responder. Las dos ramas convergen en un único nodo Response con cuerpo {{context.steps.resolver.respuesta}}. Conecta la arista false de termino directo a él, y la arista true pasando por cerrar y marcador.
Paso 5 - Construir la Automation del marcador
La más simple de las tres: leer, formatear, responder.
trigger -> consultar -> formatear -> responderAgrega un nodo QueryRows con id consultar sobre Demos Leaderboard, con límite 50, filtrado por Juego eq Buscaminas, y ordenado por Points descendente.
Why 50 and not 10? La Automation devuelve un top diez, pero primero deduplica, quedándose con la mejor partida de cada jugador. Si lees sólo diez filas, un jugador con diez buenas partidas llena el marcador entero y el resto desaparece. Lee más de lo que piensas mostrar.
Después un nodo Script con id formatear, con dos inputs:
Nombre | Tipo | Origen |
| array |
|
| object |
|
y una sola salida, respuesta:
// Toma las filas crudas del leaderboard y devuelve un top listo para pintar.
//
// La clave de deduplicacion es alias + motor, no solo alias: la gracia de este
// marcador es ver al mismo jugador entrando desde Roblox, desde Unity y desde
// el navegador, y comparar. Si dedujeramos solo por alias, la mejor partida
// taparia a las otras dos.
//
// El cuerpo acepta { limite, motor }: pasando `motor` se filtra a un solo
// front-end, util para las demos individuales de cada SDK.
const body = payload || {};
const limite = Math.max(1, Math.min(25, Number(body.limite) || 10));
const filtroMotor = body.motor ? String(body.motor).toLowerCase() : null;
const rows = Array.isArray(filas) ? filas : [];
const mejorPorClave = new Map();
let contados = 0;
for (const r of rows) {
const motor = String(r.Motor || "desconocido").toLowerCase();
if (filtroMotor && motor !== filtroMotor) continue;
contados++;
const alias = String(r.Alias || r.Jugador_Externo_Id || "anonimo");
const clave = alias + "|" + motor;
const puntos = Number(r.Points) || 0;
const previo = mejorPorClave.get(clave);
if (!previo || puntos > previo.puntos) {
mejorPorClave.set(clave, {
alias: alias,
puntos: puntos,
motor: motor,
sdk: String(r.SDK || "desconocido"),
dificultad: String(r.Dificultad || ""),
segundos: Number(r.Segundos) || 0,
jugadorId: String(r.Jugador_Externo_Id || r["Jugador Externo Id"] || "0")
});
}
}
const top = Array.from(mejorPorClave.values())
.sort((a, b) => b.puntos - a.puntos)
.slice(0, limite)
.map((e, i) => Object.assign({ posicion: i + 1 }, e));
// Cuantas partidas aporto cada motor, para el pie del marcador.
const porMotor = {};
for (const r of rows) {
const m = String(r.Motor || "desconocido").toLowerCase();
if (filtroMotor && m !== filtroMotor) continue;
porMotor[m] = (porMotor[m] || 0) + 1;
}
return {
respuesta: JSON.stringify({
ok: true,
total: contados,
porMotor: porMotor,
top: top
})
};Cierra con un nodo Response que lea {{context.steps.formatear.respuesta}}.
La clave de deduplicación es alias + motor, no alias solo, y es deliberado. El mismo backend atiende a Roblox, a Unity y al navegador, así que un jugador debería aparecer una vez por cliente y poder compararse entre ellos.
La Automation `Validar Resultado`
Hasta acá el juego llama a Jugar en cada jugada, y Praxsuite es la autoridad en todo momento. Las demos publicadas de este juego — Roblox, Unity, la web y el plugin de Minecraft — dieron un paso más por latencia: resuelven la jugada en el cliente y llaman a Praxsuite solo dos veces por partida, al abrirla y al cerrarla. Esta automation es lo único que hace que ese atajo siga siendo seguro.
No vuelve a jugar la partida: compara lo que el cliente reporta contra las dimensiones y la cantidad de minas que Praxsuite generó al abrirla, y verifica que el resultado sea internamente consistente con eso. Si algo no cierra, no escribe nada — ni el cierre de la partida ni el leaderboard.
¿La necesito si me quedo con el diseño por clic? No, y las dos conviven sin problema en el mismo workspace. Pero en cuanto muevas el motor al cliente pasa a ser obligatoria: sin ella, "local" significa "el cliente decide y Praxsuite anota".
Son diez nodos, con una bifurcación:
Trigger → Buscar partida → Top 3 actual → Script: validar → ¿Es valido?
├── true → Cerrar partida → Registrar en leaderboard → Vault → Publicar al bus → Responder
└── false → ResponderNodo 1 — Endpoint Trigger, apuntado a Buscaminas: Validar Resultado. Payload de prueba, con un tablero de 2×2 que se puede verificar a mano:
{
"codigo": "2K2Z5B64",
"estado": "Ganada",
"matriz": [[0,0],[0,-1]],
"revelado": [[1,1],[1,0]],
"banderas": [[0,0],[0,1]],
"puntaje": 0,
"segundos": 12,
"alias": "mirko",
"jugadorId": "1",
"motor": "web",
"sdk": "typescript"
}Nodo 2 — Query Rows, llamado buscar, sobre Buscaminas Partidas. Filtro Codigo eq {{context.request.body.codigo}}, límite 1. Esta fila es la única fuente de verdad sobre cuántas minas tenía la partida.
Nodo 3 — Query Rows, llamado top3, sobre Demos Leaderboard. Filtro Juego eq Buscaminas, orden Points descendente, límite 3. Se consulta antes de insertar esta partida, para saber contra qué umbral compararla.
Nodo 4 — Script, llamado validar, JavaScript. Tres entradas: payload desde {{context.request.body}}, partida desde {{context.steps.buscar.row}} y top3rows desde {{context.steps.top3.rows}}.
// -- Buscaminas: validar resultado final ---------------------------------------
// Entradas:
// payload <- {{context.request.body}} resultado final del motor local
// partida <- {{context.steps.buscar.row}} fila de 'Buscaminas Partidas'
// top3rows <- {{context.steps.top3.rows}} top 3 actual, ANTES de insertar esta partida
//
// El motor local (Roblox/Unity/web/Minecraft) ya jugo la partida entera sin red
// por click. Esto no la vuelve a jugar: usa filas/columnas/minas que Praxsuite
// genero en 'Buscaminas: Nueva Partida' -nunca lo que manda el cliente- como el
// unico punto de comparacion, y verifica que el resultado reportado sea
// internamente consistente con eso. Si algo no cierra no se escribe nada: ni el
// cierre de la partida ni el leaderboard.
const body = payload || {};
const p = partida || {};
function parseJ(v, fallback) {
if (v === null || v === undefined) return fallback;
if (typeof v === "string") {
try { return JSON.parse(v); } catch (e) { return fallback; }
}
return v;
}
const R = Number(p.Filas) || 0;
const C = Number(p.Columnas) || 0;
const M = Number(p.Minas) || 0;
const rowId = String(p.ID || "");
const codigo = String(p.Codigo || "");
const estadoActual = (p.Estado && p.Estado.Name) ? p.Estado.Name : String(p.Estado || "En curso");
const motivos = [];
function fallar(m) { motivos.push(m); }
const existe = Boolean(codigo) && R > 0 && C > 0;
if (!existe) fallar("partida_no_encontrada");
if (existe && estadoActual !== "En curso") fallar("partida_ya_cerrada");
const estadoReportado = String(body.estado || "").trim();
if (estadoReportado !== "Ganada" && estadoReportado !== "Perdida") fallar("estado_invalido");
const matriz = parseJ(body.matriz, null);
const revelado = parseJ(body.revelado, null);
const banderas = parseJ(body.banderas, null);
function formaValida(g) {
return Array.isArray(g) && g.length === R && g.every(function (fila) {
return Array.isArray(fila) && fila.length === C;
});
}
if (existe && motivos.length === 0) {
if (!formaValida(matriz) || !formaValida(revelado) || !formaValida(banderas)) {
fallar("forma_de_tablero_invalida");
}
}
let minasReportadas = 0;
let reveladas = 0;
let minaRevelada = false;
let numerosConsistentes = true;
if (motivos.length === 0) {
for (let r = 0; r < R; r++) {
for (let c = 0; c < C; c++) {
if (matriz[r][c] === -1) minasReportadas++;
}
}
for (let r = 0; r < R; r++) {
for (let c = 0; c < C; c++) {
if (matriz[r][c] !== -1) {
let n = 0;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const rr = r + dr, cc = c + dc;
if (rr >= 0 && rr < R && cc >= 0 && cc < C && matriz[rr][cc] === -1) n++;
}
}
if (matriz[r][c] !== n) numerosConsistentes = false;
}
if (revelado[r][c] === 1) {
reveladas++;
if (matriz[r][c] === -1) minaRevelada = true;
}
}
}
if (minasReportadas !== M) fallar("cantidad_de_minas_no_coincide");
if (!numerosConsistentes) fallar("numeros_de_la_matriz_inconsistentes");
if (estadoReportado === "Ganada") {
if (minaRevelada) fallar("gano_pero_hay_una_mina_revelada");
if (reveladas !== R * C - M) fallar("gano_pero_no_revelo_todas_las_celdas_seguras");
} else if (estadoReportado === "Perdida") {
if (!minaRevelada) fallar("perdio_pero_ninguna_mina_esta_revelada");
}
}
const segundos = Math.max(0, Math.min(36000, Number(body.segundos) || 0));
let puntajeEsperado = 0;
if (motivos.length === 0) {
puntajeEsperado = reveladas * 10;
if (estadoReportado === "Ganada") {
puntajeEsperado += M * 50 + Math.max(0, 600 - segundos) * 2;
}
if (Number(body.puntaje) !== puntajeEsperado) fallar("puntaje_no_coincide");
}
const valido = motivos.length === 0;
const alias = String(body.alias || p.Alias || "anonimo");
const jugadorId = String(body.jugadorId || p.Jugador_Externo_Id || p["Jugador Externo Id"] || "0");
const motor = String(body.motor || p.Motor || "desconocido");
const sdk = String(body.sdk || p.SDK || "desconocido");
const dificultad = String(p.Dificultad || "facil");
// Umbral de top 3 ANTES de insertar esta partida: si hay menos de 3 filas
// todavia, cualquier puntaje valido entra.
const top3 = Array.isArray(top3rows) ? top3rows : [];
const umbral = top3.length >= 3 ? (Number(top3[top3.length - 1].Points) || 0) : -1;
const esTop3 = valido && Number(body.puntaje) > umbral;
const fin = new Date().toISOString();
const respuesta = {
ok: valido,
codigo: codigo,
estado: valido ? estadoReportado : estadoActual,
puntaje: valido ? Number(body.puntaje) : 0,
motivos: motivos,
mensaje: valido ? "Resultado validado" : ("Resultado rechazado: " + motivos.join(", "))
};
return {
valido: valido ? "si" : "no",
rowId: rowId,
codigo: codigo,
alias: alias,
jugadorId: jugadorId,
motor: motor,
sdk: sdk,
dificultad: dificultad,
estado: estadoReportado,
matriz: JSON.stringify(matriz),
revelado: JSON.stringify(revelado),
banderas: JSON.stringify(banderas),
celdasReveladas: String(reveladas),
puntaje: String(valido ? Number(body.puntaje) : 0),
segundos: String(segundos),
fin: fin,
esTop3: esTop3 ? "si" : "no",
respuesta: JSON.stringify(respuesta)
};Declara las dieciocho salidas: valido, rowId, codigo, alias, jugadorId, motor, sdk, dificultad, estado, matriz, revelado, banderas, celdasReveladas, puntaje, segundos, fin, esTop3 y respuesta.
Nodo 5 — If/Else, llamado esvalido. Una regla: {{context.steps.validar.valido}} == si.
Nodo 6 — Update Rows (rama true), llamado cerrar, sobre Buscaminas Partidas, con rowId = {{context.steps.validar.rowId}}:
Columna | Valor |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Nodo 7 — Insert Rows, llamado marcador, sobre Demos Leaderboard:
Columna | Valor |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Nodo 8 — Vault, llamado vaultbus. Alias busKey, apuntado al secreto donde guardaste una API key con permiso de publicar al Event Bus.
Nodo 9 — HTTP Request, llamado publicartop3. POST a la ruta de publicación del bus de tu workspace:
https://gateway.praxsuite.com/api/v1/gateway/<tu-workspace-uuid>/bus/leaderboard/buscaminas/publishCabeceras x-api-key: {{vault.busKey}} y Content-Type: application/json. Cuerpo:
{"event":"game_completed","payload":{"alias":"{{context.steps.validar.alias}}","puntaje":{{context.steps.validar.puntaje}},"estado":"{{context.steps.validar.estado}}","dificultad":"{{context.steps.validar.dificultad}}","motor":"{{context.steps.validar.motor}}","codigo":"{{context.steps.validar.codigo}}","esTop3":"{{context.steps.validar.esTop3}}"}}Activa `continueOnError` en este nodo. Es lo que hace que cerrar la partida y escribir el leaderboard nunca dependan de que el bus esté disponible: el aviso es estrictamente un extra sobre una escritura que ya ocurrió.
El evento se llama `game_completed`, y se publica en toda partida validada — no solo en las del podio. Quién entró al top viaja como el campo `esTop3` del payload, no como el nombre del evento. Un suscriptor que quiera anunciar solo los récords filtra por ese campo; uno que quiera mostrar toda la actividad no filtra nada. Si vienes de una versión anterior de estas guías que hablaba de un evento `topscore`, ese es el nombre que hay que corregir del lado del suscriptor.*
Nodo 10 — Response. Estado 200, application/json, cuerpo {{context.steps.validar.respuesta}}. Las dos ramas terminan acá: la inválida responde con ok: false y la lista de motivos, sin haber escrito nada.
Publica la automation y enlázala al endpoint Buscaminas: Validar Resultado.
¿Qué previene esto en realidad? No a un cliente que mienta en todo — un tramposo decidido igual podría inventar un tablero internamente consistente. Lo que sí previene es exactamente el modo de falla de "confiar en el cliente": un error del motor local, o un binario modificado a propósito, reportando un tablero imposible (más minas de las que la partida abrió, números que no coinciden con el trazado, un puntaje que la aritmética no sostiene) y que eso entre en silencio a un leaderboard donde aparecen las partidas reales de todos los demás.
Paso 6 - Publicar y probar los endpoints
Valida cada borrador y publícalo. Publicar convierte esa versión en la viva y archiva la anterior, así que una publicación equivocada está a una llamada de volver atrás.
Pruébalos antes de escribir una línea de cliente. Los endpoints no piden autenticación, así que alcanza con curl:
curl -X POST https://gateway.praxsuite.com/<workspaceId>/endpoint/<nuevaPartidaId> \
-H "Content-Type: application/json" \
-d '{"alias":"probe","jugadorId":"1","dificultad":"facil","motor":"web","sdk":"typescript"}'Deberías recibir un codigo, una vista de ocho cadenas de ocho ?, y sembrado: false. Toma ese codigo y juega una jugada:
curl -X POST https://gateway.praxsuite.com/<workspaceId>/endpoint/<jugarId> \
-H "Content-Type: application/json" \
-d '{"codigo":"<codigo>","accion":"revelar","fila":0,"columna":0}'La respuesta llega con números en la vista y más de una celda abierta, porque la primera revelación sembró las minas lejos de donde hiciste clic. Si obtienes "Partida no encontrada", el codigo no coincidió. Si la vista queda toda en ?, al nodo actualizar le falta el campo Matriz.
Con esto el backend está terminado, y de aquí en adelante todo es cliente.
Paso 7 - Crear el proyecto
Repositorio: https://github.com/TesseractSoftwares/Praxsuite-SDK-TypeScript
Parte de una plantilla estándar de Vite y agrega el SDK. Todavía no hay nada específico de Praxsuite.
npm create vite@latest buscaminas -- --template react-ts
cd buscaminas
npm install @praxsuite/sdkYa tienes una aplicación de React y TypeScript funcionando, que todavía no habla con nada. Ejecuta npm run dev y deberías ver la página inicial de Vite.
Paso 8 - Configurar el cliente de Praxsuite
Ten los identificadores en un solo archivo. El workspace id es el único valor obligatorio; los ids de endpoint salen del portal.
// src/praxsuite/config.ts
export const WORKSPACE_ID = 'ffd80539-a1e2-4a9e-8b33-f716bf690281'
export const ENDPOINTS = {
nuevaPartida: '3d6a3601-511e-4a82-8c7c-eacbe8ea68ba',
jugar: 'c145d99d-a9d7-4f97-842c-7e4816d46b82', // sigue viva en el workspace, pero la demo publicada ya no la llama
leaderboard: '5e4cecb3-00c3-458c-8803-2a69742bfc8e',
} as const
// El mismo backend atiende a Roblox, Unity y esta aplicación. Estas dos
// etiquetas son las que separan nuestras filas de las suyas en el marcador.
export const MOTOR = 'web'
export const SDK = 'typescript'Ahora crea el cliente una sola vez y expórtalo. Una instancia sirve para toda la aplicación, porque además guarda la sesión.
// src/praxsuite/client.ts
import { createClient } from '@praxsuite/sdk'
import { WORKSPACE_ID } from './config'
export const prax = createClient({
workspaceId: WORKSPACE_ID,
persistSession: true,
fetch: (...args) => globalThis.fetch(...args),
})En esas pocas líneas pasan tres cosas, y cada una merece una frase.
workspaceId es la única opción obligatoria. No pasas ninguna clave: el SDK busca la publishable key del workspace en la ruta pública /auth/config la primera vez que necesita una.
persistSession guarda la sesión en localStorage, así que recargar la página no expulsa al jugador. Es una decisión con costo y conviene tomarla a conciencia.
Important: `localStorage` lo puede leer cualquier JavaScript que corra en tu origen, así que un bug de cross site scripting se convierte en una sesión robada. Lo que lo hace aceptable aquí es que una sesión robada vale muy poco: el tablero, el puntaje y el marcador los escriben las Automations, nunca el navegador. Si la autoridad queda en el servidor, una sesión robada casi no le sirve a nadie.
La opción fetch es un workaround, no un adorno. Míralo en la tabla de errores comunes del final: la versión 1.0.1 del SDK llama a fetch de una forma que los navegadores rechazan. Si estás en 1.0.2 o superior, puedes borrar esa línea.
Paso 9 - Iniciar sesión de los jugadores
El SDK maneja las cuentas directamente. No necesitas construir un endpoint de login.
// Registra un jugador nuevo e inicia su sesión.
const r = await prax.auth.register({
email: 'player@example.com',
password: 'atLeast8Chars',
username: 'mirko',
})
if (r.isSignedIn) {
console.log(r.user?.displayName) // "mirko"
}register devuelve un PraxAuthResult. Verifica isSignedIn antes de dejar pasar al jugador: si el workspace exige confirmar el email, la cuenta se crea pero no se emite sesión, y requiresEmailConfirmation te dice que eso fue lo que ocurrió.
Iniciar la sesión de un jugador existente es una sola llamada, y logout limpia la sesión local aunque falle la llamada de red.
await prax.auth.login(email, password)
await prax.auth.logout()Para mantener React sincronizado, suscríbete a los eventos del propio SDK. Las dos funciones de suscripción devuelven una función para darse de baja, así que la limpieza es directa.
// src/hooks/useAuth.ts
import { useEffect, useState } from 'react'
import type { PraxUser } from '@praxsuite/sdk'
import { prax } from '../praxsuite/client'
export function useAuth() {
// El SDK carga la sesión persistida de forma perezosa, así que en el primer
// render currentUser ya devuelve al jugador de la pestaña anterior.
const [usuario, setUsuario] = useState<PraxUser | null>(() => prax.auth.currentUser)
useEffect(() => {
const fueraEntrada = prax.auth.onSignedIn(setUsuario)
const fueraSalida = prax.auth.onSignedOut(() => setUsuario(null))
return () => {
fueraEntrada()
fueraSalida()
}
}, [])
return { usuario }
}Con ese hook en su lugar, la aplicación puede mostrar una pantalla de acceso cuando usuario es null, y el juego cuando no lo es.
!Captura de pantalla 2026-08-31 001522.png !Captura de pantalla 2026-08-31 001539.png
¿Por qué el SDK y no la Automation de login?
El workspace también tiene las Automations buscaminas-login y buscaminas-registro, y es razonable preguntarse por qué esta aplicación las ignora.
Existen para clientes que no pueden guardar una clave. El servidor de Roblox llama a ese endpoint, y la petición al Auth Gateway sale desde adentro de la Automation, con la clave leída del vault. Un navegador no tiene ese problema: el SDK ya trabaja con la publishable key, que es pública por diseño.
Y más importante: esos envoltorios descartan los tokens a propósito y devuelven sólo { ok, usuario }. Sin sesión no hay token que renovar, ni claim sub verificado para usar como id del jugador.
Paso 10 - Pedirle un tablero al servidor
Una partida nueva es una sola llamada a un endpoint. call<T>() envía tu payload y devuelve lo que respondió la Automation, tipado como T.
// src/praxsuite/api.ts
import { prax } from './client'
import { ENDPOINTS, MOTOR, SDK } from './config'
export type Dificultad = 'facil' | 'medio' | 'dificil'
export interface RespuestaNuevaPartida {
ok: boolean
codigo: string
filas: number
columnas: number
minas: number
dificultad: Dificultad
estado: 'En curso' | 'Ganada' | 'Perdida'
celdasReveladas: number
puntaje: number
sembrado: boolean
vista: string[]
inicio: string
}
export function nuevaPartida(
jugador: { jugadorId: string; alias: string },
dificultad: Dificultad,
): Promise<RespuestaNuevaPartida> {
return prax.endpoints.call<RespuestaNuevaPartida>(ENDPOINTS.nuevaPartida, {
alias: jugador.alias,
jugadorId: jugador.jugadorId,
dificultad,
motor: MOTOR,
sdk: SDK,
})
}La respuesta llega con vista llena de ? y con sembrado: false, que es exactamente lo que debes esperar: el tablero está reservado pero las minas todavía no están puestas.

El id del jugador merece atención. Pasa el claim subject del JWT (JSON Web Token), no algo que haya elegido el cliente:
const jugador = {
jugadorId: prax.auth.currentUserId ?? '0',
alias: prax.auth.currentUser?.displayName ?? 'anonimo',
}Así la partida guardada y su fila en el marcador quedan atadas a la cuenta que realmente jugó.

Paso 11 - Dibujar la vista enmascarada
Todo el trabajo del cliente con el tablero es traducir cadenas a celdas. No deduce, no completa y no adivina nada.
// src/game/tablero.ts
export type Celda =
| { tipo: 'oculta' }
| { tipo: 'bandera' }
| { tipo: 'mina' }
| { tipo: 'revelada'; vecinas: number }
export function leerCelda(caracter: string): Celda {
if (caracter === '?') return { tipo: 'oculta' }
if (caracter === 'F') return { tipo: 'bandera' }
if (caracter === '*') return { tipo: 'mina' }
const vecinas = Number(caracter)
return Number.isFinite(vecinas) ? { tipo: 'revelada', vecinas } : { tipo: 'oculta' }
}
export function leerVista(vista: string[]): Celda[][] {
return vista.map((fila) => Array.from(fila, leerCelda))
}Ahora la grilla se dibuja directamente a partir de eso. El clic izquierdo revela, el derecho marca, y una celda revelada queda deshabilitada para que no se pueda volver a tocar.
// src/components/Tablero.tsx
<div
className="tablero"
style={{ '--columnas': columnas } as React.CSSProperties}
onContextMenu={(e) => e.preventDefault()}
>
{celdas.map((fila, f) =>
fila.map((celda, c) => (
<button
key={`${f}-${c}`}
type="button"
disabled={bloqueado || celda.tipo === 'revelada' || celda.tipo === 'mina'}
onClick={() => onJugada(f, c, 'revelar')}
onContextMenu={(e) => {
e.preventDefault()
if (!bloqueado) onJugada(f, c, 'bandera')
}}
>
{contenido(celda)}
</button>
)),
)}
</div>Ese onContextMenu en el contenedor importa: sin él, marcar una bandera abre el menú del navegador encima de tu tablero.

Paso 12 - Enviar una jugada
Todas las jugadas son la misma llamada. El cliente envía coordenadas y recibe el tablero ya resuelto.
export type Accion = 'revelar' | 'bandera'
export interface RespuestaJugada {
ok: boolean
codigo: string
estado: 'En curso' | 'Ganada' | 'Perdida'
celdasReveladas: number
puntaje: number
segundos: number
terminada: boolean
mensaje: string
vista: string[]
}
export function jugar(
codigo: string,
accion: Accion,
fila: number,
columna: number,
): Promise<RespuestaJugada> {
return prax.endpoints.call<RespuestaJugada>(ENDPOINTS.jugar, {
codigo, accion, fila, columna,
})
}El flood fill al abrir una celda vacía, la detección de la victoria y el puntaje ocurren todos dentro de la Automation. Tu cliente sólo reemplaza su estado con lo que le llegó.
mensaje conviene mostrarlo en la interfaz. Viene vacío en una jugada normal, y si no, explica por qué no pasó nada: "Esa celda tiene bandera", "Ya estaba revelada", "Coordenada fuera del tablero".
¿Por qué las jugadas pueden llegar desordenadas?
Para que el tablero responda al instante, las jugadas se envían sin esperar a que vuelva la anterior. Eso significa que las respuestas pueden llegar desordenadas, y una respuesta atrasada con un tablero viejo reabriría visiblemente celdas que el jugador ya cerró.
La solución es un número de secuencia. Cada petición toma uno, y sólo se pinta la respuesta más nueva.
const secuencia = useRef(0)
const ultimaPintada = useRef(0)
async function resolver(fila: number, columna: number, accion: Accion) {
const mia = ++secuencia.current
const r = await jugar(partida.codigo, accion, fila, columna)
if (mia < ultimaPintada.current) return // llegó tarde, ya hay algo más nuevo
ultimaPintada.current = mia
setPartida((previa) => previa && { ...previa, ...r })
}Sin esas cuatro líneas el tablero parpadea hacia atrás cuando se hace clic rápido, que es justo la clase de bug que aparece delante de una audiencia.
!Captura de pantalla 2026-08-31 002030.png
Paso 13 - Mostrar el marcador
El último endpoint devuelve los mejores puntajes, ya deduplicados.
export interface EntradaLeaderboard {
posicion: number
alias: string
puntos: number
motor: string
sdk: string
dificultad: string
segundos: number
jugadorId: string
}
export function leaderboard(limite = 10, motor?: string) {
return prax.endpoints.call<{
ok: boolean
total: number
porMotor: Record<string, number>
top: EntradaLeaderboard[]
}>(ENDPOINTS.leaderboard, motor ? { limite, motor } : { limite })
}La Automation conserva la mejor partida por alias + motor, no por alias, y ese detalle es todo el sentido del marcador. El mismo backend atiende al juego de Roblox, a la demo de Unity y a esta aplicación, así que un mismo jugador puede aparecer una vez por cada uno y puedes compararlos. Pasando motor filtras a un solo cliente cuando quieres ver únicamente los puntajes de la aplicación.
Pídelo en un efecto, y cancélalo al desmontar para que una respuesta lenta no aterrice sobre un componente que ya no existe.
useEffect(() => {
const ctrl = new AbortController()
leaderboard(10, undefined, ctrl.signal).then(setDatos).catch(() => {})
return () => ctrl.abort()
}, [recargarToken])Incrementa recargarToken cuando termina una partida, porque ese es el único momento en que la Automation escribe una fila nueva.
!Captura de pantalla 2026-08-31 002001.png
Código completo
El proyecto completo, con los componentes, los estilos y las dos suites de pruebas, está en el repositorio de la demo bajo praxsuiteSDKDemo. Su forma es chica:
src/
praxsuite/
config.ts workspace id, ids de endpoint, etiquetas motor y sdk
client.ts createClient(), una instancia para toda la aplicación
api.ts las tres llamadas, tipadas
types.ts las formas que devuelven las Automations
hooks/
useAuth.ts sesión: entrar, registrar, salir
useJuego.ts partida en curso, jugadas, reloj
game/
tablero.ts de las cadenas del servidor a celdas
components/ PanelAuth, Tablero, Hud, Resultado, SelectorDificultad, LeaderboardFíjate en lo que falta en ese árbol: no hay resolutor, ni generador de minas, ni función de puntaje. Esos tres archivos existirían en un Buscaminas del lado del cliente, y su ausencia es el diseño.
Errores comunes y cómo evitarlos
Error | Causa | Solución |
| El SDK 1.0.1 llama a | Actualiza a 1.0.2, o pasa |
| Workspace id equivocado, o sin red | Verifica el workspace id contra el portal. Un workspace vive en exactamente un tier, y el host equivocado devuelve un 404 |
| Se llamó a | Revisa que el id de endpoint en |
| El | Guarda el |
| Intentaste revelar una celda marcada. Es una regla, no un bug | Quita la bandera primero, o deshabilita revelar sobre celdas marcadas en la interfaz |
| Llegó una jugada después de ganar o perder | Bloquea el tablero cuando |
El tablero queda todo en | La respuesta llegó pero el estado nunca se reemplazó | Dibuja siempre desde la |
Consejos para producción
Cuando el juego esté listo para publicarse, ten esto en cuenta:
Nunca pongas una clave `sk_live_` en código de cliente. El SDK lanza un error si lo intentas. Los navegadores usan la publishable key, que el SDK descubre por su cuenta.
Deja las tablas cerradas para el rol del jugador. El cliente no debería poder escribir
Buscaminas Partidasdirectamente; si puede, los endpoints son decorativos.Pasa un `AbortSignal` en las llamadas atadas a un componente, así salir de la pantalla las cancela en vez de resolver sobre un componente desmontado.
Deja que el servidor sea dueño del reloj. Corre un contador local para que se vea fluido, y reemplázalo por los
segundosque devuelve el servidor al cerrar la partida. Ese es el número con el que se calcula el puntaje.Usa `fire()` en lugar de `call()` para telemetría. Nunca lanza, así que un evento de analítica perdido no puede aparecer como un rechazo sin manejar en medio de una partida.
Prueba en un navegador real, no sólo en Node. El
fetchde Node ignora su receptor, así que el bug deIllegal invocationde arriba es invisible desde una suite de consola y evidente desde un navegador.
Próximos pasos
Ahora que el circuito está cerrado, algunas direcciones para seguir:
Agrega los cosméticos que el workspace ya soporta:
buscaminas-mis-cosmeticos,buscaminas-canjear-codigoybuscaminas-equipar-cosmeticoestán publicados y esperando una interfaz.Mueve el motor al navegador para sacar la latencia por clic, como hace la aplicación de referencia de esta guía: la jugada se resuelve en
src/game/motor.ts,Buscaminas: Jugarno se usa, y el endpointValidar Resultadoque construiste más arriba revalida el resultado una sola vez por partida.Permite que un jugador retome una partida sin terminar, guardando el
codigoy llamando ajugarotra vez al cargar.Filtra el marcador por
motory muestra los tres clientes lado a lado, para que un jugador vea su puntaje en el navegador contra el de Roblox.Agrega el clic de acorde, que revela todas las vecinas de un número ya satisfecho, resolviéndolo como varias jugadas en el cliente y dejando que el servidor juzgue cada una.
Ya tienes una aplicación donde hacer trampa exige romper el servidor, no el navegador. Ese es un buen lugar desde donde seguir construyendo. Mucha suerte.