Praxsuite

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 (fila, columna, acción) por cada jugada

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

F

con bandera

0-8

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 PraxsuiteSDK en ServerScriptService.

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 sk_live_... de Gateway → Credentials, con alcance sobre las dos tablas que vas a crear.

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

Buscaminas: Nueva Partida

{ alias, jugadorId, dificultad, motor, sdk }

{ codigo, filas, columnas, minas, vista, ... }

Buscaminas: Jugar

{ codigo, accion, fila, columna }

{ estado, vista, celdasReveladas, puntaje, segundos, terminada, mensaje }

Buscaminas: Leaderboard

{ limite, motor? }

{ total, porMotor, top[] }

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

Codigo

ShortText

El código de 8 caracteres que el cliente devuelve en cada jugada

Alias

ShortText

Nombre visible, lo usa el leaderboard

Jugador Externo Id

ShortText

El id del jugador en la plataforma que lo hospeda - acá un UserId de Roblox

Estado

Status

En curso, Ganada, Perdida

Filas

Integer

Alto del tablero

Columnas

Integer

Ancho del tablero

Minas

Integer

Cuántas minas va a tener

Dificultad

ShortText

facil, medio o dificil

Matriz

Json

El campo minado. Este es el secreto.

Revelado

Json

Grilla de 0/1: qué celdas están abiertas

Banderas

Json

Grilla de 0/1: qué celdas tienen bandera

Celdas Reveladas

Integer

Cuenta corriente, para que detectar la victoria sea una comparación

Puntaje

Integer

Puntaje final, se escribe al terminar

Inicio

DateTime

Cuándo ocurrió la primera jugada

Fin

DateTime

Cuándo terminó la partida

Segundos

Integer

Duración

Motor

ShortText

roblox, unity, web

SDK

ShortText

lua, csharp, typescript

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

Record

ShortText

Una etiqueta legible para la fila

Alias

ShortText

Nombre visible

Jugador Externo Id

ShortText

El mismo id de arriba

Points

Integer

El puntaje

Dificultad

ShortText

Qué preset se jugó

Segundos

Integer

Cuánto tardó

Juego

ShortText

Siempre Buscaminas - la tabla se comparte con otras demos

Motor

ShortText

roblox, unity, web

SDK

ShortText

lua, csharp, typescript

Anota los UUID de ambas tablas desde Gateway → Playground. Las Automations los necesitan.

Captura de pantalla 2026-08-28 102729.png

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 columna Jugador, 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

Matriz

No

No

Puntaje, Estado, Celdas Reveladas, Fin, Segundos

Sí

No

El resto (Codigo, Filas, Columnas, Minas, Dificultad, Alias, Motor, SDK, Inicio)

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 Partida

  • Buscaminas: Jugar

  • Buscaminas: 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.

imagen_2026-08-28_101425525.png

¿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  →  Response

Nodo 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

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}}

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.

imagen_2026-08-28_101150405.png

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 → Response

Nodo 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

Estado

{{context.steps.resolver.estado}}

Inicio

{{context.steps.resolver.inicio}}

Matriz

{{context.steps.resolver.matriz}}

Revelado

{{context.steps.resolver.revelado}}

Banderas

{{context.steps.resolver.banderas}}

Celdas Reveladas

{{context.steps.resolver.celdasReveladas}}

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

Fin

{{context.steps.resolver.fin}}

Puntaje

{{context.steps.resolver.puntaje}}

Segundos

{{context.steps.resolver.segundos}}

Nodo 7 - Insert Rows, llamado marcador, sobre Demos Leaderboard:

Columna

Valor

Record

Buscaminas {{context.steps.resolver.codigo}} ({{context.steps.resolver.estado}})

Alias

{{context.steps.resolver.alias}}

Jugador Externo Id

{{context.steps.resolver.jugadorId}}

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}}

Nodo 8 - Response, alcanzado desde las dos ramas. Plantilla del cuerpo {{context.steps.resolver.respuesta}}.

Publica y enlaza al endpoint Buscaminas: Jugar.

image.png

¿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  →  Response

Nodo 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 → Responder

Nodo 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

Estado

{{context.steps.validar.estado}}

Matriz

{{context.steps.validar.matriz}}

Revelado

{{context.steps.validar.revelado}}

Banderas

{{context.steps.validar.banderas}}

Celdas Reveladas

{{context.steps.validar.celdasReveladas}}

Puntaje

{{context.steps.validar.puntaje}}

Segundos

{{context.steps.validar.segundos}}

Fin

{{context.steps.validar.fin}}

Nodo 7 — Insert Rows, llamado marcador, sobre Demos Leaderboard:

Columna

Valor

Record

Buscaminas {{context.steps.validar.codigo}} ({{context.steps.validar.estado}})

Alias

{{context.steps.validar.alias}}

Jugador Externo Id

{{context.steps.validar.jugadorId}}

Points

{{context.steps.validar.puntaje}}

Dificultad

{{context.steps.validar.dificultad}}

Segundos

{{context.steps.validar.segundos}}

Juego

Buscaminas

Motor

{{context.steps.validar.motor}}

SDK

{{context.steps.validar.sdk}}

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/publish

Cabeceras 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
end

res 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 Tablero

Las 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
end

Y 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
end

Có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.

imagen_2026-08-28_101945674.png

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 {}
end

Pasar 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  →  Equipar

Eso 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

Codigo

ShortText

El código en sí, en mayúsculas: PRAX-LAVA02

Cosmetico Clave

ShortText

Qué entrada del catálogo desbloquea

Activo

Bool

Permite retirar un código sin borrarlo

Usado

Bool

Quemado o no

Usado En

DateTime

Cuándo se quemó

Usado Por Id

ShortText

Qué end user lo quemó

Usado Por Alias

ShortText

Su nombre visible, para poder leer la tabla

Usado Desde Motor

ShortText

roblox, unity, web

`Buscaminas Cosmeticos` - el catálogo. Una fila es una cosa desbloqueable.

Columna

Tipo

Qué guarda

Clave

ShortText

Id estable: paleta-lava

Nombre

ShortText

Nombre visible

Descripcion

ShortText

Una línea para la interfaz

Juego

ShortText

Siempre Buscaminas - el catálogo se comparte

Tipo Clave

ShortText

La ranura que ocupa: paleta, mina, gorro

Rareza

Status

Comun, Rara, Legendaria - lo que quieras

Config

Json

Qué hace realmente el cosmético. Lo defines tú.

Activo

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

Jugador Id

ShortText

El id de end user de Praxsuite. El dueño.

Plataforma Id

ShortText

UserId de Roblox, informativo

Alias

ShortText

Nombre visible al momento del desbloqueo

Cosmetico

Table

Vínculo a la fila del catálogo

Cosmetico Clave

ShortText

Clave desnormalizada, para que las búsquedas no necesiten join

Tipo

ShortText

Copiado del catálogo: un equipado por tipo

Equipado

Bool

Si está puesto ahora mismo

Obtenido

DateTime

Cuándo

Codigo Usado

ShortText

Qué código lo produjo

Motor

ShortText

Desde dónde se canjeó

SDK

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 → Response
  • Vault, 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. POST a https://gateway.praxsuite.com/TU-WORKSPACE-ID/auth/register, cabeceras Content-Type: application/json y x-api-key: {{vault.gatewayKey}}, cuerpo:

{ "email": "{{context.request.body.email}}", "password": "{{context.request.body.password}}", "username": "{{context.request.body.alias}}" }
  • Script, llamado armar. Entradas: respuesta desde {{context.steps.crear.body}}, detalle desde {{context.steps.intento.error}}, payload desde {{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 ────→ Response
  • Query Rows buscarCodigo sobre Buscaminas Codigos: Codigo eq {{context.request.body.codigo}}, límite 1.

  • Query Rows buscarCosmetico sobre Buscaminas Cosmeticos: Clave eq {{context.steps.buscarCodigo.row.Cosmetico_Clave}}, límite 1.

  • Query Rows buscarPosesion sobre Buscaminas Inventario: Jugador Id eq {{context.request.body.jugadorId}} y Cosmetico Clave eq {{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, codigo desde buscarCodigo.row, cosmetico desde buscarCosmetico.row, posesion desde buscarPosesion.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 quemar sobre Buscaminas Codigos, row id {{context.steps.validar.rowIdCodigo}}:

Columna

Valor

Usado

true

Usado En

{{context.steps.validar.ahora}}

Usado Por Id

{{context.request.body.jugadorId}}

Usado Por Alias

{{context.request.body.alias}}

Usado Desde Motor

{{context.request.body.motor}}

  • Insert Rows guardar sobre Buscaminas Inventario:

Columna

Valor

Jugador Id

{{context.request.body.jugadorId}}

Plataforma Id

{{context.request.body.plataformaId}}

Alias

{{context.request.body.alias}}

Cosmetico

{{context.steps.validar.rowIdCosmetico}}

Cosmetico Clave

{{context.steps.validar.clave}}

Tipo

{{context.steps.validar.tipo}}

Equipado

false

Obtenido

{{context.steps.validar.ahora}}

Codigo Usado

{{context.request.body.codigo}}

Referencia

{{context.steps.validar.referencia}}

Motor

{{context.request.body.motor}}

SDK

{{context.request.body.sdk}}

  • 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 → Response

inventario 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 → Response

buscarItem 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 por Jugador Id y Tipo, poniendo Equipado en false.

  • Update Rows equipar, row id {{context.steps.validar.rowId}}, poniendo Equipado en {{context.steps.validar.nuevoEstado}}.

  • Response desde las dos ramas.


Paso 17 - Sembrar Algunos Códigos

Agrega un par de filas a Buscaminas Cosmeticos:

Clave

Nombre

Tipo Clave

Juego

Activo

Config

paleta-lava

Lava

paleta

Buscaminas

true

{"oculta":[60,30,30],"revelada":[120,50,30],"bandera":[255,180,60]}

paleta-hielo

Hielo

paleta

Buscaminas

true

{"oculta":[150,190,220],"revelada":[80,110,140],"bandera":[255,255,255]}

Después un código para cada uno en Buscaminas Codigos, con Activo en true y Usado en false:

Codigo

Cosmetico Clave

PRAX-LAVA02

paleta-lava

PRAX-ICE01

paleta-hielo

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ó" }
end

El 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)
end

Despué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
end

Todo 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"

Captura de pantalla 2026-08-28 102443.png

Tabla "Buscaminas Partidas"

Captura de pantalla 2026-08-28 102729.png

Errores Comunes y Cómo Evitarlos

Error

Causa

Solución

[PraxsuiteSDK] baseUrl is required. Copy it from your workspace's API Gateway settings page

A PraxsuiteConfig le falta baseUrl.

Agrega baseUrl = "https://gateway.praxsuite.com" al módulo de configuración.

[PraxsuiteSDK] Not initialized. Either: 1. Call Praxsuite.Init(...)

Un script usó el SDK antes de que apareciera el módulo de configuración.

Asegúrate de que PraxsuiteConfig sea un ModuleScript directamente en ServerScriptService.

[PraxsuiteSDK] HTTP_404: Not Found

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.

[PraxsuiteSDK] HTTP_401: Unauthorized

El SDK resuelve la API key en cada petición, y el Secrets Store no tiene PraxsuiteKey.

Agrega el secreto en Game Settings → Security → Secrets Store.

El script nunca corre y no se registra nada

Está esperando BuscaminasRemotes, que ningún script creó.

BuscaminasRemotes.server.lua tiene que correr primero. Un WaitForChild sobre algo que nunca aparece espera para siempre, en silencio.

attempt to index nil with 'sub', o el tablero no pinta nada

Se trató a vista como una cadena larga. Es un arreglo, una cadena por fila.

Indexa la fila primero: vista[fila], y después string.sub(fila, columna, columna).

"Coordenada fuera del tablero", o la primera fila nunca responde

Se mandaron coordenadas base 1 de Luau a un servidor base 0.

Resta uno antes de mandar: onJugada(player, accion, fila - 1, columna - 1).

El leaderboard imprime nil en cada puntaje

El campo de la entrada es puntos, no puntaje.

Lee fila.puntos. puntaje solo existe dentro de una respuesta de Jugar.

No se pudo crear la partida

llamar no obtuvo una respuesta usable: forma de payload equivocada, o el endpoint dio error.

Verifica que dificultad sea facil, medio o dificil, e imprime el res completo dentro de llamar.

El tablero no responde a los clics

ClickDetector.MaxActivationDistance es muy chico, o el jugador aparece lejos del tablero.

Sube la distancia, o pon el tablero cerca del spawn.

"Tenes que iniciar sesion para canjear"

La llamada de canje llegó con un jugadorId vacío.

El jugador tiene que iniciar sesión primero: el id de end user sale de la respuesta de Login o Registro, no del UserId de Roblox.

Un código funciona una vez y después dice "Ese codigo ya fue canjeado"

Funciona como debe. Un código, un uso.

Agrega otra fila a Buscaminas Codigos, o vuelve a poner Usado en false para probar de nuevo.

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.Call sincró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 RemoteFunction tiene 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

  1. Al abrir la partida, se sigue llamando a la automatización buscaminas-nueva-partida del Paso 3, sin cambios: devuelve código, filas, columnas y minas, que Praxsuite guarda como fila en Buscaminas 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.

  2. 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 contra Filas/Columnas/Minas de 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ás minas × 50 + max(0, 600 − segundos) × 2 si 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 campo esTop3 del payload. El nodo de publicación corre con continueOnError`, 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 → Endpoints.Call("Jugar") → automatización Buscaminas: Jugar (siembra, flood-fill, puntaje, Insert Rows al leaderboard) → respuesta

Clic → BuscaminasMotor.Jugar(...), local, síncrono, sin red

El leaderboard se escribía dentro de Buscaminas: Jugar

Al terminar, buscaminas-validar-resultado recalcula todo y sólo si valida cierra la fila e inserta el leaderboard

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 celdas por UserId y desplaza ORIGEN, para que dos personas puedan jugar en el mismo servidor.

  • Retoma una partida sin terminar guardando el codigo y volviendo a pedir la vista enmascarada cuando el jugador reingresa.

  • Agrega un panel de cosméticos que liste MisCosmeticos y permita alternar cada uno: el endpoint Equipar ya es un interruptor.

  • Agrega tipos de cosmético más allá de paleta: una malla de mina, un material de tablero, un sonido de victoria. El backend no cambia; solo crece la lectura que el juego hace de config.

  • Porta el cliente a Unity o al navegador contra los mismos endpoints: el backend no necesita ningún cambio, solo difieren motor y sdk.