Caso de Uso del SDK de Lua en Roblox
Mirko Franichevic · 27 de agosto de 2026
¿Qué Vamos a Construir?
Un Buscaminas completo en Roblox cuyo cerebro vive en Praxsuite. Tu juego dibuja un tablero de celdas 3D, el jugador hace clic izquierdo para revelar y derecho para poner bandera, y cada decisión - dónde están las minas, qué abre un clic, cuándo se gana - la toma una Automation en el servidor. El cliente solo recibe una vista enmascarada del tablero.
Al terminar esta guía vas a tener:
Dos tablas y tres Automations construidas por ti, en tu propio workspace
Un tablero armado con celdas que el servidor revela de a una
Tres dificultades cuyos tamaños fija Praxsuite, no tu cliente
Un leaderboard que mezcla jugadores de Roblox, Unity y navegador
Un juego donde hacer trampa es imposible por construcción, no por disciplina
Construyes las dos mitades. La Parte 1 es el backend, dentro del portal de Praxsuite. La Parte 2 es el cliente de Roblox.
Nivel requerido: Completaste la guía Lua SDK Implementation en Roblox. Te manejas con Luau, incluyendo ServerScriptService y RemoteFunctions. No hace falta experiencia previa con Automations.
Cómo Funciona
Todo el proyecto se apoya en una sola idea: el cliente nunca se entera de dónde están las minas. Pide un tablero, manda coordenadas y pinta lo que el servidor le devuelve. No hay solver local, porque un solver necesitaría conocer las minas.
Corre en el cliente | Corre en Praxsuite |
Un menú de dificultad y una etiqueta de estado | La matriz de minas y el flood fill |
Dibujar la vista enmascarada que llega | Decidir qué abre cada clic |
Mandar | Detectar victoria y derrota |
Pedir el leaderboard | Puntuar la partida y guardarla |
La matriz se queda en una tabla llamada Buscaminas Partidas y nunca sale del workspace. Lo que viaja a tu juego es una vista enmascarada: un arreglo con una cadena por fila, un carácter por celda.
Carácter | Significado |
| oculta |
| con bandera |
| revelada, con la cantidad de minas vecinas |
| una mina - solo aparece cuando la partida ya está perdida |
Un tablero de 8x8 llega así, que es exactamente lo que tu código tiene que pintar:
["01??????", "01??????", "02?212??", "01?101??", "011102??", "000002??", "011102??", "01?101??"]En Roblox el SDK corre en ServerScriptService, así que tu servidor es la parte de confianza. Un jugador no puede editarlo como sí puede editar un LocalScript.
¿Qué es una vista enmascarada? La imagen completa que solo guarda el árbitro, tapada con papelitos adhesivos. Tu cliente recibe los papelitos, nunca la imagen de abajo.
Requisitos Previos
Requisito | Descripción |
La guía de Implementation | Ya la hiciste y tienes |
Tu propio workspace | Ahí construyes el backend. La Parte 1 crea todo desde cero, así que un workspace nuevo sirve. |
Una key secreta | Una key |
Roblox Studio | Con las peticiones HTTP habilitadas para el place. |
¿Por qué tu propio workspace y no uno de demo compartido? Porque llamar a un workspace exige una key de ese workspace, y una key que permite escribir es una key que no se puede publicar en una guía. Construir el backend tú mismo no es un rodeo para esquivar eso: es la mayor parte de lo que hay para aprender acá.
Parte 1 - El Backend
Tres endpoints exponen el juego, y cada uno corre una Automation. Un endpoint Sync ejecuta su Automation y devuelve el resultado en la misma petición, que es lo que espera Endpoints.Call.
Endpoint | Entrada | Salida |
|
|
|
|
|
|
|
|
|
accion es revelar o bandera.
Paso 1 - Crear las Dos Tablas
En el portal, entra a DataEngine y crea una tabla llamada `Buscaminas Partidas`. Una fila es una partida.
Columna | Tipo | Qué guarda |
| ShortText | El código de 8 caracteres que el cliente devuelve en cada jugada |
| ShortText | Nombre visible, lo usa el leaderboard |
| ShortText | El id del jugador en la plataforma que lo hospeda - acá un UserId de Roblox |
| Status |
|
| Integer | Alto del tablero |
| Integer | Ancho del tablero |
| Integer | Cuántas minas va a tener |
| ShortText |
|
| Json | El campo minado. Este es el secreto. |
| Json | Grilla de 0/1: qué celdas están abiertas |
| Json | Grilla de 0/1: qué celdas tienen bandera |
| Integer | Cuenta corriente, para que detectar la victoria sea una comparación |
| Integer | Puntaje final, se escribe al terminar |
| DateTime | Cuándo ocurrió la primera jugada |
| DateTime | Cuándo terminó la partida |
| Integer | Duración |
| ShortText |
|
| ShortText |
|
Al crear Estado, agrega exactamente tres estados: `En curso`, `Ganada`, `Perdida`. La Automation escribe esos nombres como texto, así que un error de tipeo acá es una escritura rechazada más adelante.
¿Por qué `Matriz`, `Revelado` y `Banderas` son Json y no tres tablas? Porque siempre se leen y escriben enteras, juntas, para una sola partida. Partir una grilla de 16x16 en 256 filas no te daría nada y te costaría una query por jugada. Una columna Json es la forma correcta cuando el valor no tiene vida propia fuera de su fila.
Ahora crea una segunda tabla llamada `Demos Leaderboard`. Una fila es una partida terminada.
Columna | Tipo | Qué guarda |
| ShortText | Una etiqueta legible para la fila |
| ShortText | Nombre visible |
| ShortText | El mismo id de arriba |
| Integer | El puntaje |
| ShortText | Qué preset se jugó |
| Integer | Cuánto tardó |
| ShortText | Siempre |
| ShortText |
|
| ShortText |
|
Anota los UUID de ambas tablas desde Gateway → Playground. Las Automations los necesitan.

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 - Crear los Tres Endpoints
En Gateway → Endpoints, crea tres endpoints, todos en modo Sync:
Buscaminas: Nueva PartidaBuscaminas: JugarBuscaminas: Leaderboard
Déjalos sin enlazar por ahora: enlazas cada uno a su Automation a medida que la construyes. Copia los tres UUID; la Parte 2 los pone en un módulo de Luau.

¿Sync o Async? Sync bloquea hasta que la Automation responde y te entrega la salida de su nodo Response. Async acepta la petición y vuelve de inmediato. Una jugada necesita respuesta antes de que corra la línea siguiente, así que los tres son Sync.
Paso 3 - La Automation `Nueva Partida`
Crea una Automation llamada Buscaminas: Nueva Partida. Son cuatro nodos en línea recta:
Trigger de endpoint → Script: generar tablero → Insertar fila → ResponseNodo 1 - Trigger de endpoint. Apúntalo al endpoint Buscaminas: Nueva Partida. Dale este payload de prueba para poder correr la Automation sin un juego:
{ "alias": "mirko", "jugadorId": "1", "dificultad": "facil", "motor": "roblox", "sdk": "lua" }Nodo 2 - Script, llamado generar. Una entrada: payload, con origen {{context.request.body}}. Lenguaje JavaScript.
// -- 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, 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.
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: la automation de 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,
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)
};Declara las salidas que devuelve el nodo, para que los nodos siguientes puedan referenciarlas: codigo, filas, columnas, minas, dificultad, matriz, revelado, banderas, inicio, alias, jugadorId, motor, sdk y respuesta. Todas cadenas salvo filas, columnas y minas, que son números.
Nodo 3 - Insert Rows, llamado guardar, apuntado a tu tabla Buscaminas Partidas. Mapea los campos:
Columna | Valor |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Nodo 4 - Response. Estado 200, content type application/json, plantilla del cuerpo {{context.steps.generar.respuesta}}.
Publica la Automation y enlázala al endpoint Buscaminas: Nueva Partida.
¿Por qué el Script devuelve una fila y además una `respuesta`? Porque son dos audiencias distintas. La fila guarda todo, incluida `matriz`. La `respuesta` es lo que sale del workspace, y no tiene `matriz` adentro. Construirlas por separado es lo que hace que la omisión sea deliberada y no accidental.

Paso 4 - La Automation `Jugar`
Este es el árbitro. Crea una Automation llamada Buscaminas: Jugar con ocho nodos:
Trigger → Buscar fila → Script: resolver → Actualizar fila → ¿Termino?
├─ no → Response
└─ si → Cerrar partida → Fila de leaderboard → ResponseNodo 1 - Trigger de endpoint, apuntado a Buscaminas: Jugar. Payload de prueba:
{ "codigo": "2K2Z5B64", "accion": "revelar", "fila": 0, "columna": 0 }Nodo 2 - Query Rows, llamado buscar, sobre Buscaminas Partidas. Un filtro: columna Codigo, operador eq, valor {{context.request.body.codigo}}. Límite 1.
Nodo 3 - Script, llamado resolver. Dos entradas: payload desde {{context.request.body}}, y partida desde {{context.steps.buscar.row}}.
// -- 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.
//
// 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.
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;
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.
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. 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.
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.
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();
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)
};Nodo 4 - Update Rows, llamado actualizar, sobre Buscaminas Partidas, con row id {{context.steps.resolver.rowId}}:
Columna | Valor |
|
|
|
|
|
|
|
|
|
|
|
|
Nodo 5 - If/Else, llamado termino. Una regla: {{context.steps.resolver.terminada}} == si.
Nodo 6 - Update Rows en la rama verdadera, llamado cerrar, misma tabla y row id:
Columna | Valor |
|
|
|
|
|
|
Nodo 7 - Insert Rows, llamado marcador, sobre Demos Leaderboard:
Columna | Valor |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Nodo 8 - Response, alcanzado desde las dos ramas. Plantilla del cuerpo {{context.steps.resolver.respuesta}}.
Publica y enlaza al endpoint Buscaminas: Jugar.

¿Por qué el puntaje solo existe cuando la partida termina? Porque un puntaje en curso es un número que el cliente podría mostrar y el jugador podría perseguir a mitad de partida. Calcularlo una sola vez, al final, en el servidor, lo mantiene afuera de todas las respuestas intermedias.
Paso 5 - La Automation `Leaderboard`
Cuatro nodos otra vez:
Trigger de endpoint → Query rows → Script: formatear → ResponseNodo 1 - Trigger de endpoint, apuntado a Buscaminas: Leaderboard. Payload de prueba { "limite": 10 }.
Nodo 2 - Query Rows, llamado consultar, sobre Demos Leaderboard. Filtro: Juego eq Buscaminas. Ordenar por Points descendente. Límite 50.
Nodo 3 - Script, llamado formatear. Dos entradas: filas desde {{context.steps.consultar.rows}} y payload desde {{context.request.body}}. Una 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
})
};Nodo 4 - Response, plantilla del cuerpo {{context.steps.formatear.respuesta}}.
Publica, enlaza, y el backend está listo.
Fíjate en el nombre del campo. Cada entrada de `top` trae `puntos`, no `puntaje`. `puntaje` es el puntaje de la partida dentro de `Jugar`; `puntos` es el de la entrada del leaderboard. Leer el equivocado te da `nil` en Lua, en silencio.
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": "roblox",
"sdk": "lua"
}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.
Punto de Control - Prueba el Backend Antes de Escribir una Línea de Lua
Corre cada Automation desde el portal con su payload de prueba. Nueva Partida debería devolver un codigo y una vista de ocho cadenas de ocho ? cada una. Copia ese codigo al payload de prueba de Jugar y córrelo: el primer revelado abre un hueco, porque las minas se sembraron alrededor de tu clic.
{ "ok": true, "codigo": "3KQQ9KRN", "filas": 8, "columnas": 8, "minas": 10,
"estado": "En curso", "celdasReveladas": 37, "sembrado": true, "mensaje": "",
"vista": ["01??????", "01??????", "02?212??", "01?101??",
"011102??", "000002??", "011102??", "01?101??"] }Las coordenadas empiezan en cero. `fila` y `columna` van de `0` a `R-1`. Manda `fila = 8` en un tablero de 8x8 y obtienes `"Coordenada fuera del tablero"`. Las tablas de Luau empiezan en 1, así que la Parte 2 tiene que restar - esto es lo más común de equivocar al conectar el cliente.
Parte 2 - El Cliente de Roblox
Paso 6 - Configurar el SDK y Nombrar los Endpoints
Repositorio: https://github.com/TesseractSoftwares/Praxsuite-SDK-Lua
Crea el módulo de configuración. No guarda ningún valor secreto: el nombre apunta al Secrets Store de Roblox.
-- ServerScriptService/PraxsuiteConfig (ModuleScript)
return {
workspaceId = "tu-workspace-uuid",
apiKeySecret = "PraxsuiteKey",
baseUrl = "https://gateway.praxsuite.com",
}Agrega un secreto llamado PraxsuiteKey en Game Settings → Security → Secrets Store. Su valor es la key sk_live_... con alcance sobre tus tablas.
Ahora un módulo que nombre los endpoints una sola vez, para que el resto del código nunca repita un UUID:
-- ServerScriptService/BuscaminasEndpoints (ModuleScript)
return {
MOTOR = "roblox",
SDK = "lua",
NuevaPartida = "pega-aqui-el-uuid-del-endpoint-nueva-partida",
Jugar = "pega-aqui-el-uuid-del-endpoint-jugar",
Leaderboard = "pega-aqui-el-uuid-del-endpoint-leaderboard",
}MOTOR y SDK viajan con cada petición para que el leaderboard compartido pueda deduplicar a la misma persona por front-end.
¿Por qué un módulo para los endpoints y no cadenas sueltas? Porque un ID no es significado. `Jugar` dice qué hace; `c145d99d-...` no. Un módulo además te da un solo lugar para cambiar cuando un endpoint se republica con otro ID.
Paso 7 - Crear Primero los Remotes
Todo lo demás depende de que existan, así que constrúyelos antes que cualquier script que los espere:
-- ServerScriptService/BuscaminasRemotes.server.lua (Script)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local folder = Instance.new("Folder")
folder.Name = "BuscaminasRemotes"
folder.Parent = ReplicatedStorage
local function remote(nombre)
local r = Instance.new("RemoteFunction")
r.Name = nombre
r.Parent = folder
return r
end
remote("NuevaPartida")
remote("Leaderboard")El orden importa. Todos los scripts de abajo empiezan con `ReplicatedStorage:WaitForChild("BuscaminasRemotes")`. Si la carpeta no se crea nunca, esa llamada queda esperando para siempre y el script simplemente no corre, sin ningún error que te diga por qué.
Paso 8 - Empezar una Partida
Una partida empieza cuando el jugador elige dificultad. El servidor llama a Nueva Partida y recibe un código de partida y una vista enmascarada:
-- ServerScriptService/BuscaminasServidor (Script)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Praxsuite = require(game.ServerScriptService.PraxsuiteSDK)
local Endpoints = require(game.ServerScriptService.BuscaminasEndpoints)
local remotes = ReplicatedStorage:WaitForChild("BuscaminasRemotes")
local sesiones = {} -- [UserId] = { codigo = "...", dificultad = "..." }
local function llamar(slug, payload)
local ok, res = pcall(Praxsuite.Endpoints.Call, slug, payload)
if not ok then
warn("[Buscaminas] El endpoint '" .. slug .. "' falló: " .. tostring(res))
return nil
end
return res
end
remotes.NuevaPartida.OnServerInvoke = function(player, dificultad)
local res = llamar(Endpoints.NuevaPartida, {
alias = player.Name,
jugadorId = tostring(player.UserId),
dificultad = dificultad,
motor = Endpoints.MOTOR,
sdk = Endpoints.SDK,
})
if not res then
return { error = "No se pudo crear la partida" }
end
sesiones[player.UserId] = {
codigo = res.codigo,
dificultad = dificultad,
}
return res
endres contiene codigo, filas, columnas, minas y un arreglo vista lleno de ?. Las minas todavía no existen: se siembran en el primer revelado.
Importante: `Endpoints.Call` es sincrónico: bloquea el hilo del servidor hasta que la Automation responde. Eso es exactamente lo que quieres para una jugada - la línea siguiente necesita el resultado - y exactamente lo que no quieres en un handler de `PlayerAdded` que no debe demorarse.
¿Por qué el cliente manda la dificultad, si el servidor la fija?
El cliente manda una elección, no una definición. facil es una clave que el servidor busca; el servidor decide el tamaño del tablero y la cantidad de minas. Si el cliente manda dificil recibe 16x16; si manda cualquier cosa recibe el preset facil. El cliente nunca llega a definir el tablero.
Paso 9 - Armar el Tablero desde la Vista Enmascarada
El tablero es geometría 3D en el Workspace, no una interfaz. Esa decisión es la que le quita al cliente la posibilidad de mentir: cada celda es un Part, y los eventos de su ClickDetector se disparan en el servidor - el LocalScript ni siquiera llega a tener una jugada que falsear.
Dos cosas de la vista enmascarada deciden cómo se escribe este código, y las dos son fáciles de equivocar:
`vista` es un arreglo de cadenas, una por fila - no una cadena larga.
vista[1]es la fila de arriba.El servidor cuenta filas y columnas desde cero. Las tablas de Luau cuentan desde uno. Cada coordenada que mandas tiene que bajar en uno.
-- ServerScriptService/BuscaminasTablero (ModuleScript)
local Tablero = {}
local CELDA = 8
local ORIGEN = Vector3.new(80, 0, -60)
local celdas = {} -- [fila][columna] = Part, base 1 como toda tabla de Luau
local function nuevaCelda(fila, columna)
local part = Instance.new("Part")
part.Name = "Celda_" .. fila .. "_" .. columna
part.Size = Vector3.new(CELDA - 0.5, 3, CELDA - 0.5)
part.Anchored = true
part.Position = ORIGEN + Vector3.new(columna * CELDA, 1.5, fila * CELDA)
local click = Instance.new("ClickDetector")
click.MaxActivationDistance = 60
click.Parent = part
part.Parent = workspace
return part
end
function Tablero.Construir(datos)
Tablero.Limpiar()
for fila = 1, datos.filas do
celdas[fila] = {}
for columna = 1, datos.columnas do
celdas[fila][columna] = nuevaCelda(fila, columna)
end
end
Tablero.Pintar(datos.vista)
end
-- `vista` llega como arreglo de cadenas: una entrada por fila, un carácter por
-- columna. vista[1] es la fila de arriba, y vista[1]:sub(3, 3) es su tercera celda.
function Tablero.Pintar(vista)
if typeof(vista) ~= "table" then
warn("[Buscaminas] Se esperaba un arreglo de filas, llegó " .. typeof(vista))
return
end
for fila = 1, #celdas do
local textoFila = vista[fila]
if textoFila then
for columna = 1, #celdas[fila] do
local simbolo = string.sub(textoFila, columna, columna)
local part = celdas[fila][columna]
if part then
if simbolo == "?" then
part.Color = Color3.fromRGB(150, 150, 165)
part.Size = Vector3.new(CELDA - 0.5, 3, CELDA - 0.5)
elseif simbolo == "F" then
part.Color = Color3.fromRGB(240, 120, 120)
part.Size = Vector3.new(CELDA - 0.5, 3, CELDA - 0.5)
elseif simbolo == "*" then
part.Color = Color3.fromRGB(200, 40, 40)
part.Size = Vector3.new(CELDA - 0.5, 1, CELDA - 0.5)
else
part.Color = Color3.fromRGB(90, 90, 100)
part.Size = Vector3.new(CELDA - 0.5, 1, CELDA - 0.5)
end
end
end
end
end
end
function Tablero.Limpiar()
for _, filaCeldas in pairs(celdas) do
for _, part in pairs(filaCeldas) do
part:Destroy()
end
end
celdas = {}
end
-- El tablero es base 1; el servidor es base 0. La resta ocurre acá, una sola
-- vez, para que ningún llamador tenga que acordarse.
function Tablero.Conectar(onJugada)
for fila, filaCeldas in pairs(celdas) do
for columna, part in pairs(filaCeldas) do
part.ClickDetector.MouseClick:Connect(function(player)
onJugada(player, "revelar", fila - 1, columna - 1)
end)
part.ClickDetector.RightMouseClick:Connect(function(player)
onJugada(player, "bandera", fila - 1, columna - 1)
end)
end
end
end
return TableroLas celdas ocultas son altas y claras; las reveladas se aplanan en una losa baja y oscura, y las minas se ponen rojas cuando se pierde. El relieve es lo que permite leer el tablero desde lejos.
El error de uno en uno es el que realmente vas a tener. Manda coordenadas base 1 y el servidor responde `"Coordenada fuera del tablero"` en la última fila y columna, mientras la primera fila queda inalcanzable. No se rompe nada: el tablero simplemente deja de responder en los bordes, que es mucho más difícil de notar que un error. ¿Por qué geometría 3D y no un ScreenGui? Porque un ScreenGui vive en el cliente, donde un LocalScript modificado puede interceptar o inventar clics. Un ClickDetector se dispara en el servidor con el `Player` real. El cliente puede dibujar lo que quiera encima del tablero, pero no puede hacer que el servidor revele una celda.
Paso 10 - Revelar y Poner Bandera
Ahora el ciclo de jugada: un clic en una celda se convierte en una llamada a Jugar, y la vista devuelta repinta el tablero. Agrega esto a BuscaminasServidor:
local Tablero = require(game.ServerScriptService.BuscaminasTablero)
local function jugar(player, accion, fila, columna)
local sesion = sesiones[player.UserId]
if not sesion then
return nil
end
local res = llamar(Endpoints.Jugar, {
codigo = sesion.codigo,
accion = accion,
fila = fila, -- ya viene en base 0: Tablero.Conectar restó
columna = columna,
})
if res and res.vista then
Tablero.Pintar(res.vista)
end
if res and res.terminada then
print("[Buscaminas] " .. tostring(res.estado) .. " - " .. tostring(res.puntaje) .. " pts en " .. tostring(res.segundos) .. "s")
sesiones[player.UserId] = nil
end
return res
endY reemplaza el handler de NuevaPartida del Paso 8 por este, que además arma el tablero y conecta los clics:
remotes.NuevaPartida.OnServerInvoke = function(player, dificultad)
local res = llamar(Endpoints.NuevaPartida, {
alias = player.Name,
jugadorId = tostring(player.UserId),
dificultad = dificultad,
motor = Endpoints.MOTOR,
sdk = Endpoints.SDK,
})
if not res then
return { error = "No se pudo crear la partida" }
end
sesiones[player.UserId] = { codigo = res.codigo, dificultad = dificultad }
Tablero.Construir(res)
Tablero.Conectar(jugar)
return res
endCórrelo y haz clic en una celda. El primer clic siempre abre un hueco, porque la Automation siembra las minas en ese primer revelado y deja libre un recuadro de 3x3 alrededor.
Cuando jugar devuelve terminada = true, la partida terminó. Si perdiste, vista ahora contiene * en las minas: el servidor te las muestra precisamente porque la partida ya está perdida.

Un tablero por servidor. `celdas` es un único tablero a nivel de módulo, así que esta versión asume una partida a la vez. Con dos jugadores, el segundo `Construir` destruye el tablero del primero. Dar un tablero a cada jugador significa indexar `celdas` por `UserId` y desplazar `ORIGEN` por jugador - vale la pena hacerlo, y queda como ejercicio para que el ciclo de jugada se lea claro acá.
¿Por qué no guardamos las minas en el cliente?
Un solver local - "revelá las vecinas que son seguras" - solo es posible si el cliente conoce las minas. Al mantener la matriz en Praxsuite y mandar solo la vista enmascarada, no hay nada en el dispositivo del jugador que un cheat pueda leer. Lo máximo que puede hacer un cliente modificado es mandar jugadas; no puede mandar jugadas informadas.
Paso 11 - Leer el Leaderboard
Desde el punto de vista del juego el leaderboard es de solo lectura: la Automation escribe el puntaje cuando una partida termina, y el juego solo pide las primeras filas.
remotes.Leaderboard.OnServerInvoke = function(player, limite)
local res = llamar(Endpoints.Leaderboard, {
limite = limite or 10,
motor = Endpoints.MOTOR,
})
if not res then
return {}
end
return res.top or {}
endPasar motor = "roblox" filtra a las filas de Roblox; omítelo para traer todos los front-ends de una vez.
Cada entrada se ve así:
{ "posicion": 1, "alias": "MirkOwwO", "puntos": 2128, "motor": "roblox",
"sdk": "lua", "dificultad": "facil", "segundos": 56, "jugadorId": "11033534760" }Acá el campo del puntaje es `puntos`. Dentro de una partida el puntaje es `puntaje`; en una entrada del leaderboard es `puntos`. Leer el equivocado te da `nil`, y `tostring(nil)` imprime `"nil"` en tu marcador sin levantar nada.
Paso 12 - El Cliente: Menú y HUD
El cliente solo pinta lo que recibe. Este LocalScript arma tres botones de dificultad y una línea de estado, para que la partida empiece con un clic en vez de una llamada desde la barra de comandos:
-- StarterPlayer/StarterPlayerScripts/BuscaminasCliente (LocalScript)
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local remotes = ReplicatedStorage:WaitForChild("BuscaminasRemotes")
local jugador = Players.LocalPlayer
local gui = Instance.new("ScreenGui")
gui.Name = "BuscaminasHUD"
gui.ResetOnSpawn = false
gui.Parent = jugador:WaitForChild("PlayerGui")
local estado = Instance.new("TextLabel")
estado.Size = UDim2.fromOffset(360, 34)
estado.Position = UDim2.fromOffset(20, 20)
estado.BackgroundColor3 = Color3.fromRGB(28, 31, 43)
estado.TextColor3 = Color3.fromRGB(235, 235, 245)
estado.Font = Enum.Font.GothamMedium
estado.TextSize = 15
estado.Text = "Elige una dificultad"
estado.Parent = gui
local function boton(texto, x, dificultad)
local b = Instance.new("TextButton")
b.Size = UDim2.fromOffset(110, 34)
b.Position = UDim2.fromOffset(x, 62)
b.BackgroundColor3 = Color3.fromRGB(45, 212, 191)
b.TextColor3 = Color3.fromRGB(12, 14, 20)
b.Font = Enum.Font.GothamBold
b.TextSize = 14
b.Text = texto
b.Parent = gui
b.MouseButton1Click:Connect(function()
estado.Text = "Empezando..."
local res = remotes.NuevaPartida:InvokeServer(dificultad)
if not res or res.error then
estado.Text = "No se pudo empezar"
return
end
estado.Text = string.format("%s - %dx%d, %d minas", dificultad, res.filas, res.columnas, res.minas)
end)
return b
end
boton("Fácil", 20, "facil")
boton("Medio", 140, "medio")
boton("Difícil", 260, "dificil")
local marcador = Instance.new("TextButton")
marcador.Size = UDim2.fromOffset(230, 30)
marcador.Position = UDim2.fromOffset(20, 106)
marcador.BackgroundColor3 = Color3.fromRGB(60, 64, 82)
marcador.TextColor3 = Color3.fromRGB(235, 235, 245)
marcador.Font = Enum.Font.GothamMedium
marcador.TextSize = 13
marcador.Text = "Ver leaderboard"
marcador.Parent = gui
marcador.MouseButton1Click:Connect(function()
local top = remotes.Leaderboard:InvokeServer(10)
print("=== LEADERBOARD ===")
for _, fila in ipairs(top) do
print(string.format("%d. %s - %d pts (%s)", fila.posicion, tostring(fila.alias), fila.puntos, tostring(fila.motor)))
end
end)Presiona Play, haz clic en Fácil y acércate al tablero. Clic izquierdo revela, clic derecho pone bandera. El tablero responde porque cada clic es un viaje de ida y vuelta a Praxsuite.
Tip: El ciclo de jugada no tiene nada de lógica de juego del lado del cliente. El LocalScript nunca guarda el `codigo` de la partida, nunca cuenta minas y nunca decide una victoria. Es un control remoto, no un cerebro.
Parte 3 - Cuentas y Códigos Canjeables
Un código como PRAX-LAVA02 desbloquea un cosmético. Un código, un uso, un dueño - para siempre.
Esa última palabra es la que obliga a tener cuenta. Un cosmético atado a un UserId de Roblox desaparecería en cuanto la misma persona abriera la versión de Unity o la del navegador, y dos cuentas de Praxsuite compartiendo una máquina compartirían desbloqueos. Por eso el dueño de un inventario es un End User de Praxsuite, y el UserId de Roblox se guarda al lado, solo como dato informativo.
Registro / Login → id de end user → Canjear código → Inventario → EquiparEso significa que la Parte 3 tiene un orden que no se puede alterar: primero las cuentas, después los códigos.
Actualización - la demo en vivo ahora usa `Auth.LoginPlayer` en vez de estas dos Automations. `Praxsuite.Auth.LoginPlayer(player)` no existía en el SDK cuando se escribió este patrón; `Registro`/`Login` eran la única forma de conseguir un id de end user estable. Construirlas tú mismo, como hace el Paso 14, sigue siendo la decisión correcta si la misma cuenta necesita iniciar sesión desde Unity o un navegador con el mismo email y contraseña - `Auth.LoginPlayer` solo afirma una identidad específica de Roblox, limitada al proveedor de esta plataforma. Pero si lo único que necesitas es un End User de Praxsuite estable para esta plataforma, `Auth.LoginPlayer` te da uno sin Automation, sin campo de contraseña y sin ningún remote `Registrar`/`Login` - revisa la guía Lua SDK Implementation en Roblox. El place de Roblox ya publicado cambió a esto justamente por esa razón en cuanto el módulo estuvo disponible; `Registrar` todavía existe en su árbol del Explorer pero ya no tiene un handler del lado del servidor.
Paso 13 - Tres Tablas Más
`Buscaminas Codigos` - una fila es un código.
Columna | Tipo | Qué guarda |
| ShortText | El código en sí, en mayúsculas: |
| ShortText | Qué entrada del catálogo desbloquea |
| Bool | Permite retirar un código sin borrarlo |
| Bool | Quemado o no |
| DateTime | Cuándo se quemó |
| ShortText | Qué end user lo quemó |
| ShortText | Su nombre visible, para poder leer la tabla |
| ShortText |
|
`Buscaminas Cosmeticos` - el catálogo. Una fila es una cosa desbloqueable.
Columna | Tipo | Qué guarda |
| ShortText | Id estable: |
| ShortText | Nombre visible |
| ShortText | Una línea para la interfaz |
| ShortText | Siempre |
| ShortText | La ranura que ocupa: |
| Status |
|
| Json | Qué hace realmente el cosmético. Lo defines tú. |
| Bool | Permite retirar un cosmético sin romper inventarios |
Config es deliberadamente abierta. El backend nunca la lee: se la pasa al juego, que decide qué significa. Para una paleta de tablero, algo así:
{ "oculta": [150, 150, 165], "revelada": [90, 90, 100], "bandera": [240, 120, 120] }`Buscaminas Inventario` - una fila es un cosmético que pertenece a un jugador.
Columna | Tipo | Qué guarda |
| ShortText | El id de end user de Praxsuite. El dueño. |
| ShortText | UserId de Roblox, informativo |
| ShortText | Nombre visible al momento del desbloqueo |
| Table | Vínculo a la fila del catálogo |
| ShortText | Clave desnormalizada, para que las búsquedas no necesiten join |
| ShortText | Copiado del catálogo: un equipado por tipo |
| Bool | Si está puesto ahora mismo |
| DateTime | Cuándo |
| ShortText | Qué código lo produjo |
| ShortText | Desde dónde se canjeó |
| ShortText | Qué SDK lo canjeó |
¿Por qué el inventario guarda `Cosmetico Clave` si ya tiene un vínculo a la fila del catálogo? Porque todas las queries de la Parte 3 filtran por clave, y un filtro sobre una columna común es una búsqueda mientras que un filtro a través de un vínculo es un join. El vínculo está para las personas que leen la tabla; la clave está para las Automations.
Paso 14 - Las Automations `Registro` y `Login`
Las dos hablan con el Auth Gateway del propio workspace por HTTP. Ninguna guarda una contraseña en ningún lado de Roblox.
Crea cinco endpoints más en Gateway → Endpoints, todos Sync: Buscaminas: Registro, Buscaminas: Login, Buscaminas: Canjear Codigo, Buscaminas: Mis Cosmeticos, Buscaminas: Equipar Cosmetico.
Después guarda tu key del gateway en el Vault con el alias gatewayKey. Las rutas de auth necesitan una key propia, y un nodo Vault es la forma de que una Automation la lea sin que aparezca en el grafo.
`Buscaminas: Registro` son seis nodos:
Trigger de endpoint → Vault → Try/Catch → HTTP: register → Script: armar respuesta → ResponseVault, alias
gatewayKey.Try/Catch con continuar ante fallo activado. El nodo HTTP lanza ante cualquier respuesta que no sea 2xx, y acá un
409(email ya registrado) es un desenlace esperable, no un bug.HTTP Request, dentro de la rama Try.
POSTahttps://gateway.praxsuite.com/TU-WORKSPACE-ID/auth/register, cabecerasContent-Type: application/jsonyx-api-key: {{vault.gatewayKey}}, cuerpo:
{ "email": "{{context.request.body.email}}", "password": "{{context.request.body.password}}", "username": "{{context.request.body.alias}}" }Script, llamado
armar. Entradas:respuestadesde{{context.steps.crear.body}},detalledesde{{context.steps.intento.error}},payloaddesde{{context.request.body}}.
// Misma forma que el login, para que el cliente trate ambos casos igual.
// El HttpRequest va dentro del Try/Catch porque lanza en cualquier no-2xx,
// y aca un 409 (email ya registrado) es un desenlace esperable, no un bug.
const r = respuesta || {};
const d = r.data || {};
const exitoso = (r.isSuccess === true) && Boolean(d.accessToken);
if (!exitoso) {
const texto = String(detalle || "");
let mensaje = "No se pudo crear la cuenta";
if (texto.indexOf("409") >= 0) mensaje = "Ese email ya tiene una cuenta";
else if (texto.indexOf("400") >= 0) mensaje = "Revisa el email y que la contrasena tenga al menos 8 caracteres";
else if (r.isSuccess === false && r.message) mensaje = r.message;
return { salida: JSON.stringify({ ok: false, mensaje: mensaje }) };
}
const u = d.user || {};
const pedido = payload || {};
return {
salida: JSON.stringify({
ok: true,
mensaje: "Cuenta creada",
usuario: {
id: String(u.id || ""),
email: String(u.email || ""),
alias: String(u.username || pedido.alias || u.firstName || u.email || "jugador")
}
})
};Response, cuerpo
{{context.steps.armar.salida}}.
`Buscaminas: Login` es el mismo grafo apuntado a /auth/login, con un cuerpo de solo email y password, y este script:
// El nodo HttpRequest LANZA en cualquier respuesta que no sea 2xx: expone
// statusCode en su salida, pero en el camino de error nunca llegas a leerlo.
// Por eso la llamada va dentro de un Try/Catch.
//
// Y ojo con `trySucceeded`: en el camino `finally` todavia no esta escrito.
// La senal confiable es la respuesta misma — si el paso lanzo, `respuesta`
// llega vacia.
const r = respuesta || {};
const d = r.data || {};
const exitoso = (r.isSuccess === true) && Boolean(d.accessToken);
if (!exitoso) {
const texto = String(detalle || "");
let mensaje = "No se pudo iniciar sesion";
if (texto.indexOf("401") >= 0) mensaje = "Email o contrasena incorrectos";
else if (texto.indexOf("404") >= 0) mensaje = "Esa cuenta no existe";
else if (texto.indexOf("429") >= 0) mensaje = "Demasiados intentos, espera un momento";
else if (r.isSuccess === false && r.message) mensaje = r.message;
return { salida: JSON.stringify({ ok: false, mensaje: mensaje }) };
}
// El accessToken y el refreshToken NO se devuelven a Roblox: el servidor del
// juego ya es la parte de confianza, mandarle el token solo agrandaria la
// superficie sin darle nada a cambio.
const u = d.user || {};
return {
salida: JSON.stringify({
ok: true,
mensaje: "Sesion iniciada",
usuario: {
id: String(u.id || ""),
email: String(u.email || ""),
alias: String(u.username || u.firstName || u.email || "jugador")
}
})
};Las dos devuelven { ok, mensaje, usuario: { id, email, alias } }. Ese usuario.id es el id de end user que necesita toda operación con códigos.
¿Por qué el token nunca llega a Roblox? Porque tu servidor de juego ya se autentica con una key secreta: es la parte de confianza. Mandarle un token de sesión le agrega una credencial que guardar, refrescar y filtrar, a cambio de un permiso que ya tiene.
Paso 15 - La Automation `Canjear Codigo`
Esta es la que decide si un código vale algo. Nueve nodos:
Trigger → Buscar código → Buscar cosmético → Buscar "¿ya lo tiene?" → Script: validar
→ ¿Válido? ─ no ──────────────────────────────────────────────→ Response
└ sí → Quemar el código → Agregar al inventario ────→ ResponseQuery Rows
buscarCodigosobreBuscaminas Codigos:Codigoeq{{context.request.body.codigo}}, límite 1.Query Rows
buscarCosmeticosobreBuscaminas Cosmeticos:Claveeq{{context.steps.buscarCodigo.row.Cosmetico_Clave}}, límite 1.Query Rows
buscarPosesionsobreBuscaminas Inventario:Jugador Ideq{{context.request.body.jugadorId}}yCosmetico Claveeq{{context.steps.buscarCodigo.row.Cosmetico_Clave}}, límite 1.
Fíjate en los guiones bajos de Cosmetico_Clave: dentro del contexto de pasos, los nombres de columna llegan con los espacios reemplazados por guiones bajos.
Script
validar. Entradas:payload,codigodesdebuscarCodigo.row,cosmeticodesdebuscarCosmetico.row,posesiondesdebuscarPosesion.row.
// -- Canje de un codigo cosmetico --------------------------------------------
// Un codigo = un uso. Toda la decision vive aca; los nodos de base que siguen
// solo ejecutan lo que este Script ya resolvio.
//
// QUIEN es el dueno: `jugadorId` es el id del END USER de Praxsuite, no el de
// la plataforma. Esa distincion importa mas de lo que parece: si el inventario
// colgara del UserId de Roblox, dos cuentas de Praxsuite en la misma maquina
// compartirian cosmeticos, y la misma persona entrando desde Unity o desde el
// navegador no veria nada de lo que desbloqueo. El id de plataforma se guarda
// al lado, como dato informativo.
const body = payload || {};
const cod = codigo || {};
const cos = cosmetico || {};
const ya = posesion || {};
// Las columnas Status llegan como objeto { Id, Name, ... } o como texto plano
// segun por donde se lean. Normalizar una sola vez y olvidarse.
function nombreDe(v) {
if (!v) return "";
if (typeof v === "object" && v.Name) return String(v.Name);
return String(v);
}
const pedido = String(body.codigo || "").trim().toUpperCase();
const jugadorId = String(body.jugadorId || "").trim();
const alias = String(body.alias || "anonimo");
const ahora = new Date().toISOString();
const existe = Boolean(cod.Codigo);
const clave = String(cod.Cosmetico_Clave || cod["Cosmetico Clave"] || "");
let valido = false;
let mensaje = "";
if (!pedido) {
mensaje = "Escribi un codigo";
} else if (!jugadorId) {
mensaje = "Tenes que iniciar sesion para canjear";
} else if (!existe) {
mensaje = "Ese codigo no existe";
} else if (cod.Activo === false) {
mensaje = "Ese codigo fue dado de baja";
} else if (cod.Usado === true) {
mensaje = "Ese codigo ya fue canjeado";
} else if (!cos.Clave) {
// El codigo apunta a un cosmetico que no existe o fue borrado: es un error
// de datos, no del jugador. No se quema el codigo.
mensaje = "El premio de ese codigo no esta disponible";
} else if (cos.Activo === false) {
mensaje = "Ese cosmetico esta desactivado";
} else if (ya.Cosmetico_Clave || ya["Cosmetico Clave"]) {
// Importante: no se quema el codigo. Si ya lo tenes, el codigo sigue vivo
// para que se lo pases a otro.
mensaje = "Ya tenes " + String(cos.Nombre || clave);
} else {
valido = true;
mensaje = "Desbloqueaste " + String(cos.Nombre || clave);
}
const tipo = nombreDe(cos.Tipo_Clave || cos["Tipo Clave"] || cos.Tipo);
const rareza = nombreDe(cos.Rareza);
let config = cos.Config;
if (typeof config === "string") {
try { config = JSON.parse(config); } catch (e) { config = {}; }
}
const respuesta = {
ok: valido,
mensaje: mensaje,
codigo: pedido,
cosmetico: valido ? {
clave: clave,
nombre: String(cos.Nombre || clave),
descripcion: String(cos.Descripcion || ""),
tipo: tipo,
rareza: rareza,
config: config || {}
} : null
};
return {
valido: valido ? "si" : "no",
mensaje: mensaje,
rowIdCodigo: String(cod.ID || ""),
clave: clave,
nombre: String(cos.Nombre || clave),
tipo: tipo,
rareza: rareza,
rowIdCosmetico: String(cos.ID || ""),
referencia: alias + " / " + clave,
ahora: ahora,
respuesta: JSON.stringify(respuesta)
};If/Else
puede:{{context.steps.validar.valido}}==si.Update Rows
quemarsobreBuscaminas Codigos, row id{{context.steps.validar.rowIdCodigo}}:
Columna | Valor |
|
|
|
|
|
|
|
|
|
|
Insert Rows
guardarsobreBuscaminas Inventario:
Columna | Valor |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Response desde las dos ramas, cuerpo
{{context.steps.validar.respuesta}}.
El endpoint toma { codigo, jugadorId, plataformaId, alias, motor, sdk } y devuelve { ok, mensaje, codigo, cosmetico }.
Dos fallos deliberadamente no queman el código. Una entrada rota del catálogo es tu bug, no del jugador. Y tener ya el cosmético no es un fallo en absoluto: el código sigue vivo para poder regalarlo. Quemar en cada intento sería más simple de escribir y peor de jugar.
Paso 16 - `Mis Cosmeticos` y `Equipar Cosmetico`
`Buscaminas: Mis Cosmeticos` - cinco nodos, de solo lectura:
Trigger de endpoint → Query inventario → Query catálogo → Script: cruzar → Responseinventario filtra Jugador Id eq {{context.request.body.jugadorId}}, límite 200. catalogo filtra Juego eq Buscaminas, límite 200. Después:
// El inventario guarda solo la clave del cosmetico. La config (colores,
// simbolos, material) vive en el catalogo, para poder retocar un cosmetico
// sin tener que reescribir la fila de cada jugador que lo tiene.
//
// Cruzar aca y no en el juego tiene una razon concreta: el cliente recibe la
// config ya resuelta y no necesita conocer el catalogo ni pedirlo aparte.
function nombreDe(v) {
if (!v) return "";
if (typeof v === "object" && v.Name) return String(v.Name);
return String(v);
}
function parseJ(v, porDefecto) {
if (v === null || v === undefined) return porDefecto;
if (typeof v === "string") {
try { return JSON.parse(v); } catch (e) { return porDefecto; }
}
return v;
}
const porClave = new Map();
for (const c of (Array.isArray(catalogo) ? catalogo : [])) {
porClave.set(String(c.Clave), c);
}
const items = [];
const equipado = {};
for (const fila of (Array.isArray(mios) ? mios : [])) {
const clave = String(fila.Cosmetico_Clave || fila["Cosmetico Clave"] || "");
const c = porClave.get(clave);
if (!c) continue; // cosmetico borrado del catalogo
if (c.Activo === false) continue; // desactivado: no se ofrece
const tipo = nombreDe(c.Tipo_Clave || c["Tipo Clave"] || c.Tipo);
const estaEquipado = fila.Equipado === true;
const item = {
clave: clave,
nombre: String(c.Nombre || clave),
descripcion: String(c.Descripcion || ""),
tipo: tipo,
rareza: nombreDe(c.Rareza),
config: parseJ(c.Config, {}),
equipado: estaEquipado,
obtenido: String(fila.Obtenido || ""),
codigo: String(fila.Codigo_Usado || fila["Codigo Usado"] || "")
};
items.push(item);
if (estaEquipado) equipado[tipo] = item;
}
items.sort(function (a, b) {
if (a.tipo !== b.tipo) return a.tipo < b.tipo ? -1 : 1;
return a.nombre < b.nombre ? -1 : 1;
});
return {
respuesta: JSON.stringify({
ok: true,
total: items.length,
items: items,
equipado: equipado // { paleta: {...}, mina: {...} } listo para aplicar
})
};Toma { jugadorId } y devuelve { ok, total, items[], equipado }.
`Buscaminas: Equipar Cosmetico` - siete nodos:
Trigger → Query "¿lo tiene?" → Script: validar → ¿Lo posee? ─ no ─→ Response
└ sí → Desequipar del mismo tipo → Equipar este → ResponsebuscarItem filtra Jugador Id y Cosmetico Clave, límite 1. Después:
// Equipar es una operacion de dos pasos que tiene que quedar consistente:
// primero se desequipa todo lo del mismo tipo, despues se marca este. Si el
// jugador no posee el cosmetico, no se toca nada.
//
// Y es un INTERRUPTOR, no un boton de una sola direccion: tocar un cosmetico
// que ya esta puesto lo saca. Sin esto un jugador que desbloquea una paleta
// no puede volver nunca al tablero por defecto, ni sacarse el gorro; solo
// puede cambiar una cosa por otra del mismo tipo, que no es lo mismo.
const body = payload || {};
const it = item || {};
const clave = String(body.clave || "").trim();
const jugadorId = String(body.jugadorId || "").trim();
const tipo = String(it.Tipo || "");
const yaPuesto = it.Equipado === true;
// El cliente puede forzar la direccion mandando `equipar: true|false`.
// Sin ese campo, alterna. Que el default sea alternar mantiene la UI simple
// (un solo click por item) sin quitarle precision a quien la necesite.
let queda;
if (typeof body.equipar === "boolean") {
queda = body.equipar;
} else {
queda = !yaPuesto;
}
let valido = false;
let mensaje = "";
if (!jugadorId) {
mensaje = "Tenes que iniciar sesion";
} else if (!clave) {
mensaje = "Falta el cosmetico";
} else if (!it.Cosmetico_Clave && !it["Cosmetico Clave"]) {
mensaje = "No tenes ese cosmetico";
} else if (!tipo) {
mensaje = "Ese cosmetico no tiene tipo asignado";
} else {
valido = true;
mensaje = queda ? "Equipado" : "Guardado";
}
return {
valido: valido ? "si" : "no",
mensaje: mensaje,
rowId: String(it.ID || ""),
clave: clave,
tipo: tipo,
// Los fields de UpdateRows solo aceptan strings, asi que el booleano viaja
// como "true" / "false".
nuevoEstado: queda ? "true" : "false",
accion: queda ? "equipar" : "desequipar",
respuesta: JSON.stringify({
ok: valido,
mensaje: mensaje,
clave: clave,
tipo: tipo,
equipado: valido ? queda : yaPuesto
})
};Update Rows
desequipar, filtrado porJugador IdyTipo, poniendoEquipadoenfalse.Update Rows
equipar, row id{{context.steps.validar.rowId}}, poniendoEquipadoen{{context.steps.validar.nuevoEstado}}.Response desde las dos ramas.
Paso 17 - Sembrar Algunos Códigos
Agrega un par de filas a Buscaminas Cosmeticos:
|
|
|
|
|
|
| Lava |
|
| true |
|
| Hielo |
|
| true |
|
Después un código para cada uno en Buscaminas Codigos, con Activo en true y Usado en false:
|
|
|
|
|
|
Los códigos se comparan en mayúsculas - el Script pasa a mayúsculas lo que haya escrito el jugador - así que guárdalos en mayúsculas también.
Paso 18 - El Cliente: Iniciar Sesión y Canjear
Agrega los endpoints restantes al módulo del Paso 6:
-- ServerScriptService/BuscaminasEndpoints (ModuleScript)
return {
MOTOR = "roblox",
SDK = "lua",
NuevaPartida = "pega-aqui-el-uuid-del-endpoint-nueva-partida",
Jugar = "pega-aqui-el-uuid-del-endpoint-jugar",
Leaderboard = "pega-aqui-el-uuid-del-endpoint-leaderboard",
Registro = "pega-aqui-el-uuid-del-endpoint-registro",
Login = "pega-aqui-el-uuid-del-endpoint-login",
Canjear = "pega-aqui-el-uuid-del-endpoint-canjear",
MisCosmeticos = "pega-aqui-el-uuid-del-endpoint-mis-cosmeticos",
Equipar = "pega-aqui-el-uuid-del-endpoint-equipar",
}Y cinco remotes más a la lista del Paso 7:
remote("Registrar")
remote("Login")
remote("Canjear")
remote("MisCosmeticos")
remote("Equipar")Ahora el lado del servidor. El id de end user es lo único de lo que cuelga toda la Parte 3, así que vive en la tabla de sesión y nunca se manda al cliente como algo que el cliente pueda elegir:
-- agregar a ServerScriptService/BuscaminasServidor (Script)
local cuentas = {} -- [UserId] = { id = "<uuid del end user>", alias = "..." }
remotes.Registrar.OnServerInvoke = function(player, email, password, alias)
local res = llamar(Endpoints.Registro, {
email = email,
password = password,
alias = alias or player.Name,
})
if not res then
return { ok = false, mensaje = "El servidor no respondió" }
end
if res.ok and res.usuario then
cuentas[player.UserId] = { id = res.usuario.id, alias = res.usuario.alias }
end
-- Solo vuelven ok y mensaje. El id de end user se queda en el servidor.
return { ok = res.ok, mensaje = res.mensaje }
end
remotes.Login.OnServerInvoke = function(player, email, password)
local res = llamar(Endpoints.Login, { email = email, password = password })
if not res then
return { ok = false, mensaje = "El servidor no respondió" }
end
if res.ok and res.usuario then
cuentas[player.UserId] = { id = res.usuario.id, alias = res.usuario.alias }
end
return { ok = res.ok, mensaje = res.mensaje, alias = res.usuario and res.usuario.alias }
end
remotes.Canjear.OnServerInvoke = function(player, codigo)
local cuenta = cuentas[player.UserId]
if not cuenta then
return { ok = false, mensaje = "Inicia sesión antes de canjear" }
end
local res = llamar(Endpoints.Canjear, {
codigo = codigo,
jugadorId = cuenta.id, -- el end user de Praxsuite
plataformaId = tostring(player.UserId), -- el id de Roblox, informativo
alias = cuenta.alias,
motor = Endpoints.MOTOR,
sdk = Endpoints.SDK,
})
if not res then
return { ok = false, mensaje = "El servidor no respondió" }
end
return res
end
remotes.MisCosmeticos.OnServerInvoke = function(player)
local cuenta = cuentas[player.UserId]
if not cuenta then
return { ok = false, items = {} }
end
return llamar(Endpoints.MisCosmeticos, { jugadorId = cuenta.id }) or { ok = false, items = {} }
end
remotes.Equipar.OnServerInvoke = function(player, clave)
local cuenta = cuentas[player.UserId]
if not cuenta then
return { ok = false, mensaje = "Inicia sesión primero" }
end
return llamar(Endpoints.Equipar, { jugadorId = cuenta.id, clave = clave })
or { ok = false, mensaje = "El servidor no respondió" }
endEl cliente nunca manda su propio `jugadorId`. Pide canjear un código, y el servidor decide en nombre de quién. Si el id lo aportara el cliente, cualquier jugador podría escribir el uuid de end user ajeno y canjear sobre su inventario - o leerlo con `MisCosmeticos`. El único lugar de donde sale ese id es la respuesta de login que recibió el propio servidor.
Ahora la caja de códigos, en el LocalScript del Paso 12:
-- agregar a StarterPlayer/StarterPlayerScripts/BuscaminasCliente (LocalScript)
local caja = Instance.new("TextBox")
caja.Size = UDim2.fromOffset(230, 30)
caja.Position = UDim2.fromOffset(20, 146)
caja.BackgroundColor3 = Color3.fromRGB(20, 22, 30)
caja.TextColor3 = Color3.fromRGB(235, 235, 245)
caja.PlaceholderText = "Escribe un código"
caja.Font = Enum.Font.Gotham
caja.TextSize = 14
caja.ClearTextOnFocus = false
caja.Text = ""
caja.Parent = gui
local canjear = Instance.new("TextButton")
canjear.Size = UDim2.fromOffset(110, 30)
canjear.Position = UDim2.fromOffset(260, 146)
canjear.BackgroundColor3 = Color3.fromRGB(45, 212, 191)
canjear.TextColor3 = Color3.fromRGB(12, 14, 20)
canjear.Font = Enum.Font.GothamBold
canjear.TextSize = 14
canjear.Text = "Canjear"
canjear.Parent = gui
local aviso = Instance.new("TextLabel")
aviso.Size = UDim2.fromOffset(350, 24)
aviso.Position = UDim2.fromOffset(20, 184)
aviso.BackgroundTransparency = 1
aviso.TextXAlignment = Enum.TextXAlignment.Left
aviso.TextColor3 = Color3.fromRGB(200, 205, 220)
aviso.Font = Enum.Font.Gotham
aviso.TextSize = 13
aviso.Text = ""
aviso.Parent = gui
canjear.MouseButton1Click:Connect(function()
local texto = caja.Text:gsub("%s+", "")
if texto == "" then
aviso.Text = "Escribe un código primero"
return
end
canjear.Text = "..."
local res = remotes.Canjear:InvokeServer(texto:upper())
canjear.Text = "Canjear"
if not res then
aviso.Text = "Sin respuesta"
return
end
aviso.Text = tostring(res.mensaje)
if res.ok then
caja.Text = ""
if res.cosmetico then
-- Equipar lo recién desbloqueado, para que el premio se vea al instante
remotes.Equipar:InvokeServer(res.cosmetico.clave)
end
end
end)Escribe PRAX-LAVA02, presiona Canjear, y la etiqueta responde con lo que haya decidido la Automation: Desbloqueaste Lava la primera vez, Ese codigo ya fue canjeado la segunda.
Paso 19 - Aplicar el Cosmético al Tablero
Mis Cosmeticos ya devuelve equipado, indexado por tipo, con la config del catálogo ya resuelta. El tablero solo tiene que leerla:
-- agregar a ServerScriptService/BuscaminasTablero (ModuleScript)
-- Paleta por defecto, se usa cuando el jugador no tiene nada equipado
local PALETA = {
oculta = Color3.fromRGB(150, 150, 165),
revelada = Color3.fromRGB(90, 90, 100),
bandera = Color3.fromRGB(240, 120, 120),
mina = Color3.fromRGB(200, 40, 40),
}
local function color(lista, porDefecto)
if typeof(lista) == "table" and #lista == 3 then
return Color3.fromRGB(lista[1], lista[2], lista[3])
end
return porDefecto
end
-- `config` es la columna Json del catálogo, pasada sin tocar.
function Tablero.AplicarPaleta(config)
if typeof(config) ~= "table" then
return
end
PALETA.oculta = color(config.oculta, PALETA.oculta)
PALETA.revelada = color(config.revelada, PALETA.revelada)
PALETA.bandera = color(config.bandera, PALETA.bandera)
PALETA.mina = color(config.mina, PALETA.mina)
endDespués usa PALETA.oculta, PALETA.revelada, PALETA.bandera y PALETA.mina dentro de Tablero.Pintar en vez de las llamadas fijas a Color3.fromRGB(...), y carga la paleta del jugador cuando empieza su partida:
-- en el handler de NuevaPartida, antes de Tablero.Construir(res)
local cuenta = cuentas[player.UserId]
if cuenta then
local cos = llamar(Endpoints.MisCosmeticos, { jugadorId = cuenta.id })
if cos and cos.equipado and cos.equipado.paleta then
Tablero.AplicarPaleta(cos.equipado.paleta.config)
end
endTodo el sistema de cosméticos llega al juego como un bloque Json que el backend nunca interpreta. Agregar una paleta nueva es una fila de catálogo y un código: sin cambiar la Automation, sin republicar, sin Lua nuevo.
¿Por qué el backend no valida `Config`? Porque no puede saber qué significa un cosmético. `paleta` hoy son tres colores; mañana un tipo `mina` podría ser un id de malla y un sonido. Un backend que validara la forma tendría que redeployarse por cada tipo nuevo de premio. Pasarla sin tocar es lo que mantiene el catálogo editable a mano.
Código Completo
Seis scripts, en el orden en que cargan. PraxsuiteConfig y BuscaminasEndpoints son los módulos del Paso 6, BuscaminasRemotes el del Paso 7, BuscaminasTablero el del Paso 9. Este es el puente del servidor con todo conectado:
-- ServerScriptService/BuscaminasServidor (Script)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Praxsuite = require(game.ServerScriptService.PraxsuiteSDK)
local Endpoints = require(game.ServerScriptService.BuscaminasEndpoints)
local Tablero = require(game.ServerScriptService.BuscaminasTablero)
local remotes = ReplicatedStorage:WaitForChild("BuscaminasRemotes")
local sesiones = {} -- [UserId] = { codigo, dificultad } la partida en curso
local cuentas = {} -- [UserId] = { id, alias } el end user de Praxsuite
local function llamar(slug, payload)
local ok, res = pcall(Praxsuite.Endpoints.Call, slug, payload)
if not ok then
warn("[Buscaminas] El endpoint '" .. slug .. "' falló: " .. tostring(res))
return nil
end
return res
end
local function jugar(player, accion, fila, columna)
local sesion = sesiones[player.UserId]
if not sesion then
return nil
end
local res = llamar(Endpoints.Jugar, {
codigo = sesion.codigo,
accion = accion,
fila = fila,
columna = columna,
})
if res and res.vista then
Tablero.Pintar(res.vista)
end
if res and res.terminada then
print("[Buscaminas] " .. tostring(res.estado) .. " - " .. tostring(res.puntaje) .. " pts en " .. tostring(res.segundos) .. "s")
sesiones[player.UserId] = nil
end
return res
end
remotes.NuevaPartida.OnServerInvoke = function(player, dificultad)
local res = llamar(Endpoints.NuevaPartida, {
alias = player.Name,
jugadorId = tostring(player.UserId),
dificultad = dificultad,
motor = Endpoints.MOTOR,
sdk = Endpoints.SDK,
})
if not res then
return { error = "No se pudo crear la partida" }
end
sesiones[player.UserId] = { codigo = res.codigo, dificultad = dificultad }
local cuenta = cuentas[player.UserId]
if cuenta then
local cos = llamar(Endpoints.MisCosmeticos, { jugadorId = cuenta.id })
if cos and cos.equipado and cos.equipado.paleta then
Tablero.AplicarPaleta(cos.equipado.paleta.config)
end
end
Tablero.Construir(res)
Tablero.Conectar(jugar)
return res
end
remotes.Leaderboard.OnServerInvoke = function(player, limite)
local res = llamar(Endpoints.Leaderboard, {
limite = limite or 10,
motor = Endpoints.MOTOR,
})
return res and res.top or {}
end
remotes.Registrar.OnServerInvoke = function(player, email, password, alias)
local res = llamar(Endpoints.Registro, {
email = email,
password = password,
alias = alias or player.Name,
})
if not res then
return { ok = false, mensaje = "El servidor no respondió" }
end
if res.ok and res.usuario then
cuentas[player.UserId] = { id = res.usuario.id, alias = res.usuario.alias }
end
return { ok = res.ok, mensaje = res.mensaje }
end
remotes.Login.OnServerInvoke = function(player, email, password)
local res = llamar(Endpoints.Login, { email = email, password = password })
if not res then
return { ok = false, mensaje = "El servidor no respondió" }
end
if res.ok and res.usuario then
cuentas[player.UserId] = { id = res.usuario.id, alias = res.usuario.alias }
end
return { ok = res.ok, mensaje = res.mensaje, alias = res.usuario and res.usuario.alias }
end
remotes.Canjear.OnServerInvoke = function(player, codigo)
local cuenta = cuentas[player.UserId]
if not cuenta then
return { ok = false, mensaje = "Inicia sesión antes de canjear" }
end
local res = llamar(Endpoints.Canjear, {
codigo = codigo,
jugadorId = cuenta.id,
plataformaId = tostring(player.UserId),
alias = cuenta.alias,
motor = Endpoints.MOTOR,
sdk = Endpoints.SDK,
})
return res or { ok = false, mensaje = "El servidor no respondió" }
end
remotes.MisCosmeticos.OnServerInvoke = function(player)
local cuenta = cuentas[player.UserId]
if not cuenta then
return { ok = false, items = {} }
end
return llamar(Endpoints.MisCosmeticos, { jugadorId = cuenta.id }) or { ok = false, items = {} }
end
remotes.Equipar.OnServerInvoke = function(player, clave)
local cuenta = cuentas[player.UserId]
if not cuenta then
return { ok = false, mensaje = "Inicia sesión primero" }
end
return llamar(Endpoints.Equipar, { jugadorId = cuenta.id, clave = clave })
or { ok = false, mensaje = "El servidor no respondió" }
end
game:GetService("Players").PlayerRemoving:Connect(function(player)
sesiones[player.UserId] = nil
cuentas[player.UserId] = nil
end)Presiona Play, inicia sesión, haz clic en Fácil y juega. Cuando termines, abre Demos Leaderboard en el portal: tu fila está ahí, al lado de los jugadores de Unity y del navegador, con el mismo alias deduplicado por front-end.
Tabla "Demos Leaderboard"

Tabla "Buscaminas Partidas"

Errores Comunes y Cómo Evitarlos
Error | Causa | Solución |
| A | Agrega |
| Un script usó el SDK antes de que apareciera el módulo de configuración. | Asegúrate de que |
| El ID del endpoint no existe, o la Automation no está publicada. | Vuelve a copiar el ID desde Gateway → Endpoints y confirma que la Automation tenga una versión publicada. |
| El SDK resuelve la API key en cada petición, y el Secrets Store no tiene | Agrega el secreto en Game Settings → Security → Secrets Store. |
El script nunca corre y no se registra nada | Está esperando |
|
| Se trató a | Indexa la fila primero: |
| Se mandaron coordenadas base 1 de Luau a un servidor base 0. | Resta uno antes de mandar: |
El leaderboard imprime | El campo de la entrada es | Lee |
|
| Verifica que |
El tablero no responde a los clics |
| Sube la distancia, o pon el tablero cerca del spawn. |
| La llamada de canje llegó con un | El jugador tiene que iniciar sesión primero: el id de end user sale de la respuesta de |
Un código funciona una vez y después dice | Funciona como debe. Un código, un uso. | Agrega otra fila a |
Tip: Envuelve también los cuerpos de `OnServerInvoke` en `pcall`. Una RemoteFunction que da error en el servidor le muestra al jugador un error engañoso en Studio, que tapa la causa real. Devuelve una tabla de error en vez de lanzar.
Consejos de Producción
Limita la frecuencia de las jugadas. Cada clic es un
Endpoints.Callsincrónico. Una ráfaga de clics es una ráfaga de peticiones HTTP; alcanza con un intervalo mínimo entre jugadas.Mantén exactas las etiquetas `MOTOR` y `SDK`. El leaderboard deduplica por
alias + motor; cambiar"roblox"rompe ese agrupamiento.Guarda la key en el Secrets Store, nunca en el código. El módulo de configuración solo tiene el nombre del secreto.
Nunca dejes que el cliente elija un `jugadorId`. Es el único campo que decide qué inventario se toca, y sale de la respuesta de login que recibió el servidor.
Envuelve cada llamada de red en `pcall`. Un tropiezo en un handler de
RemoteFunctiontiene que convertirse en un error devuelto, no en un ingreso roto.Mantén la matriz en Praxsuite. Si una función futura necesita que el cliente conozca el tablero, debería recibir solo la vista enmascarada, nunca las minas.
Nota de Producción: Por Qué Mover el Motor a Roblox (y Por Qué Praxsuite Igual Valida el Resultado)
El primer consejo de la sección anterior ya lo insinúa: cada clic en este tutorial es un Endpoints.Call sincrónico contra la automatización Buscaminas: Jugar del Paso 4 — una vuelta de red completa, Roblox → gateway → automatización → base de datos → automatización → gateway → Roblox, antes de que el jugador vea el efecto de su clic. En una implementación real de este mismo tutorial, con clics seguidos (abrir una zona vacía a las apuradas, por ejemplo) esa latencia se termina sintiendo, y limitar la frecuencia de las jugadas solo la disimula, no la elimina.
La solución que se probó en producción fue mover el álgebra del juego —siembra, flood-fill, cálculo de puntaje— del Paso 4 a un módulo Luau local, BuscaminasMotor.lua, un puerto 1:1 del mismo script resolver que armaste ahí, corriendo directamente en ServerScriptService. Con eso, un clic se resuelve en el mismo proceso de confianza del servidor de Roblox, sin ninguna llamada a Praxsuite: los mismos milisegundos que tarda cualquier otro cálculo local.
Eso solo, sin embargo, abre un agujero: sin ningún registro del lado de Praxsuite, nada impide que un resultado corrupto —un bug en el motor local, o un reporte manipulado a mano— escriba cualquier puntaje al leaderboard compartido con Unity y la web. La corrección no revive la automatización por clic (el problema de latencia estaba ahí, no en la partida completa); agrega de vuelta dos llamadas por partida, no por clic:
El patrón híbrido: dos llamadas por partida
Al abrir la partida, se sigue llamando a la automatización
buscaminas-nueva-partidadel Paso 3, sin cambios: devuelve código, filas, columnas y minas, que Praxsuite guarda como fila enBuscaminas Partidas— con la matriz todavía vacía, sin sembrar. Igual que en el Paso 4, las minas recién se siembran en la primera jugada válida, tanto del lado del motor local como del lado de la fila, para que esa primera jugada nunca sea una moneda al aire. Esta fila es el punto de comparación contra el que se valida el resultado final.Al cerrar la partida, en vez de que Roblox escriba directo al leaderboard, se llama a una automatización aparte,
buscaminas-validar-resultado— la que construiste en La Automation `Validar Resultado` — un endpoint Sync cuyo nodo Script recalcula todo desde cero contraFilas/Columnas/Minasde esa fila, nunca contra lo que manda el cliente.
Qué verifica buscaminas-validar-resultado
Antes de tocar la fila o el leaderboard, el Script del nuevo endpoint revisa, todo del lado servidor:
Que la partida exista y siga
"En curso"— una fila ya cerrada rechaza cualquier reintento (protección contra reenviar la misma partida dos veces).Que la matriz reportada tenga exactamente la cantidad de minas que generó
Nueva Partida, y que filas/columnas coincidan.Que cada número no-mina de la matriz sea consistente con las posiciones de minas reportadas, recalculado celda por celda — no alcanza con mandar una matriz cualquiera con la cantidad correcta de minas.
Si el estado es
"Ganada": que ninguna celda revelada sea una mina, y que estén reveladas todas las celdas seguras (filas × columnas − minas).Si el estado es
"Perdida": que al menos una celda revelada sea una mina.Que el puntaje recalculado con la misma fórmula del Paso 4 (
reveladas × 10, másminas × 50 + max(0, 600 − segundos) × 2si ganó) coincida exacto con lo reportado.
Si cualquiera de estos chequeos falla, la automatización no toca la fila ni el leaderboard — el jugador ya vio su resultado local de todas formas; lo único que se pierde es esa entrada del leaderboard cruzado.
Esta misma automatización también alimenta el Event Bus compartido. Una vez que un resultado validado cierra la fila, `buscaminas-validar-resultado` revisa si el puntaje entró en el top 3 del leaderboard y, si es así, publica un evento `gamecompleted
— el mismo mecanismo de solo-publicación que comparte el leaderboard entre motores (Unity, la demo web y el plugin de Minecraft disparan esta misma automatización, no una copia propia de Roblox). El evento sale en toda partida validada, y quién entró al podio viaja en el campoesTop3del payload. El nodo de publicación corre concontinueOnError`, así que cerrar la fila y escribir el leaderboard nunca se ven afectados por nada que pase del lado del Event Bus — el aviso al bus es estrictamente un extra sobre eso.*
Antes (Pasos 3-5, automatización por clic) | Después (híbrido, motor local + validación) |
Clic → | Clic → |
El leaderboard se escribía dentro de | Al terminar, |
La automatización Buscaminas: Jugar construida en el Paso 4 sigue existiendo tal cual en el workspace y sigue siendo válida — es agnóstica de motor, así que Unity o un cliente web todavía pueden seguir llamándola por clic. Roblox simplemente dejó de necesitarla en la ruta caliente.
Resultado de las pruebas (probado en vivo el 2026-09-09)
Un resultado válido cierra la fila y escribe el leaderboard.
El mismo código reenviado se rechaza (
partida_ya_cerrada).Un resultado alterado a mano — dice que ganó pero deja una celda segura sin revelar, con un puntaje inventado — se rechaza (
gano_pero_no_revelo_todas_las_celdas_seguras) sin tocar la fila.Una sesión real de Play en Roblox Studio (8×8, 10 minas, 33 clics, derrota) corrió el flujo completo de punta a punta: el puntaje que calculó Roblox (330) llegó intacto al leaderboard tras pasar la validación.
Próximos Pasos
Dale a cada jugador su propio tablero: indexa
celdasporUserIdy desplazaORIGEN, para que dos personas puedan jugar en el mismo servidor.Retoma una partida sin terminar guardando el
codigoy volviendo a pedir la vista enmascarada cuando el jugador reingresa.Agrega un panel de cosméticos que liste
MisCosmeticosy permita alternar cada uno: el endpointEquiparya es un interruptor.Agrega tipos de cosmético más allá de
paleta: una malla demina, unmaterialde tablero, un sonido de victoria. El backend no cambia; solo crece la lectura que el juego hace deconfig.Porta el cliente a Unity o al navegador contra los mismos endpoints: el backend no necesita ningún cambio, solo difieren
motorysdk.