Praxsuite

Lua SDK Use Case in Roblox

Mirko Franichevic · August 27, 2026

What Are We Going to Build?

A complete Minesweeper in Roblox whose brain lives in Praxsuite. Your game renders a board of 3D cells, the player left-clicks to reveal and right-clicks to flag, and every decision - where the mines are, what a click opens, when you win - is made by an Automation on the server. The client only ever receives a masked view of the board.

By the end of this guide you'll have:

  • Two Tables and three Automations you built yourself, in your own workspace

  • A board built from cells the server reveals one at a time

  • Three difficulties whose sizes are fixed by Praxsuite, not by your client

  • A leaderboard that mixes Roblox, Unity and browser players

  • A game where cheating is structurally impossible, not just discouraged

You build both halves. Part 1 is the backend, inside the Praxsuite portal. Part 2 is the Roblox client.

Required level: You have completed the Lua SDK Implementation in Roblox guide. You are comfortable writing Luau, including ServerScriptService and RemoteFunctions. No prior experience with Automations is needed. Repository: https://github.com/TesseractSoftwares/Praxsuite-SDK-Lua


How It Works

The whole project rests on one idea: the client never learns where the mines are. It asks for a board, sends coordinates, and paints whatever the server returns. There is no local solver, because a solver would need to know the mines.

Runs on the client

Runs on Praxsuite

A difficulty menu and a status label

The mine matrix and the flood fill

Drawing the masked view that arrives

Deciding what each click opens

Sending (row, column, action) for each move

Detecting win and loss

Asking for the leaderboard

Scoring the game and saving it

The matrix stays in a Table called Buscaminas Partidas and never leaves the workspace. What travels to your game is a masked view: an array with one string per row, one character per cell.

Character

Meaning

?

hidden

F

flagged

0-8

revealed, with the count of neighboring mines

*

a mine - only appears once the game is already lost

An 8x8 board arrives looking like this, which is exactly what your code has to paint:

["01??????", "01??????", "02?212??", "01?101??", "011102??", "000002??", "011102??", "01?101??"]

On Roblox the SDK runs in ServerScriptService, so your server is the trusted party. A player cannot edit it the way they can edit a LocalScript.

What is a masked view? The full picture only the referee keeps, drawn over with sticky notes. Your client receives the sticky notes, never the picture underneath.


Prerequisites

Requirement

Description

The Implementation guide

You have done it and have PraxsuiteSDK in ServerScriptService.

Your own workspace

You build the backend in it. Part 1 creates everything from scratch, so a brand new workspace works.

A secret key

An sk_live_... key from Gateway → Credentials, scoped to the two tables you are about to create.

Roblox Studio

With HTTP requests enabled for the place.

Why your own workspace and not a shared demo one? Because calling a workspace requires a key for that workspace, and a key that lets you write is a key you cannot publish in a guide. Building the backend yourself is not a detour around that - it is most of what there is to learn here.


Part 1 - The Backend

Three endpoints expose the game, and each one runs an Automation. A Sync endpoint runs its Automation and returns the result in the same request, which is what Endpoints.Call waits for.

Endpoint

Input

Output

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 is revelar or bandera.


Step 1 - Create the Two Tables

In the portal, go to DataEngine and create a Table named `Buscaminas Partidas`. One row is one game.

Column

Type

What it holds

Codigo

ShortText

The 8-character game code the client sends back on every move

Alias

ShortText

Display name, used by the leaderboard

Jugador Externo Id

ShortText

The player's id on whatever platform hosts them - a Roblox UserId here

Estado

Status

En curso, Ganada, Perdida

Filas

Integer

Board height

Columnas

Integer

Board width

Minas

Integer

How many mines the board will have

Dificultad

ShortText

facil, medio or dificil

Matriz

Json

The mine field. This is the secret.

Revelado

Json

Grid of 0/1: which cells are open

Banderas

Json

Grid of 0/1: which cells are flagged

Celdas Reveladas

Integer

Running count, so the win check is a comparison

Puntaje

Integer

Final score, written when the game ends

Inicio

DateTime

When the first move happened

Fin

DateTime

When the game ended

Segundos

Integer

Duration

Motor

ShortText

roblox, unity, web

SDK

ShortText

lua, csharp, typescript

When you create Estado, add exactly three statuses: `En curso`, `Ganada`, `Perdida`. The Automation writes those names as text, so a typo here is a rejected write later.

Why are `Matriz`, `Revelado` and `Banderas` Json and not three tables? Because they are always read and written whole, together, for one game. Splitting a 16x16 grid into 256 rows would buy you nothing and cost you a query per move. A Json column is the right shape when the value has no life of its own outside its row.

Now create a second Table named `Demos Leaderboard`. One row is one finished game.

Column

Type

What it holds

Record

ShortText

A human-readable label for the row

Alias

ShortText

Display name

Jugador Externo Id

ShortText

Same id as above

Points

Integer

The score

Dificultad

ShortText

Which preset was played

Segundos

Integer

How long it took

Juego

ShortText

Always Buscaminas - the table is shared with other demos

Motor

ShortText

roblox, unity, web

SDK

ShortText

lua, csharp, typescript

Write down both table UUIDs from Gateway → Playground. The Automations need them.

Captura de pantalla 2026-08-28 102729.png

The Player Role, and What It Must NOT Touch

The tables exist now, and a player's role currently reaches neither of them. Decide what it may see before going any further, because the easy answer — grant access to the table and move on — is exactly the one that drains the rest of this guide of meaning.

Under Settings → API Gateway → Roles, create a role called `Buscaminas Jugador` and give it a scope over Buscaminas Partidas:

  • Row filter: __SELF__ on the Jugador column, so each player reaches only their own games.

  • Default value for the Jugador column: {{claim:sub}}, applied to whatever the player writes themselves.

  • Column access, which is where the whole thing lives:

Column

Read

Write

Matriz

No

No

Puntaje, Estado, Celdas Reveladas, Fin, Segundos

Yes

No

Everything else (Codigo, Filas, Columnas, Minas, Dificultad, Alias, Motor, SDK, Inicio)

Yes

No

`Matriz` is the entire security model. If the player's role can read it, a modified client asks for its own row through the Gateway and knows where every mine is before the first click. If it can also write it, it can rewrite the minefield so that an invented result validates. Automations do not go through these scopes — they run with the workspace's own authority — so closing `Matriz` to the player costs them nothing.

Same reasoning for Demos Leaderboard: the player's role reads it (the game shows the top) and never writes. The only write comes from the Automation that validates the result.

A freshly created end user account starts with whatever default roles the workspace is configured with, and if there are none it has access to nothing: every query comes back empty or 403. Assign Buscaminas Jugador when you register the player, or from an Automation that validates their token.

Quick check: open Gateway → Playground, pick the `Buscaminas Jugador` role and ask for a game's `Matriz` column. It has to fail. If it hands you the grid, the scope is open and any player can read the mines.


Step 2 - Create the Three Endpoints

Under Gateway → Endpoints, create three endpoints, all in Sync mode:

  • Buscaminas: Nueva Partida

  • Buscaminas: Jugar

  • Buscaminas: Leaderboard

Leave them unlinked for now - you link each one to its Automation as you build it. Copy the three UUIDs; Part 2 puts them in a Luau module.

imagen_2026-08-28_101425525.png

Sync or Async? Sync blocks until the Automation answers and hands you its Response node output. Async accepts the request and returns immediately. A move needs an answer before the next line runs, so all three are Sync.


Step 3 - The `Nueva Partida` Automation

Create an Automation named Buscaminas: Nueva Partida. It is four nodes in a straight line:

Endpoint trigger  →  Script: generate board  →  Insert row  →  Response

Node 1 - Endpoint trigger. Point it at the Buscaminas: Nueva Partida endpoint. Give it this test payload so you can run the Automation without a game:

{ "alias": "mirko", "jugadorId": "1", "dificultad": "facil", "motor": "roblox", "sdk": "lua" }

Node 2 - Script, named generar. One input: payload, sourced from {{context.request.body}}. Language JavaScript.

// Opening a game. This endpoint is engine-agnostic: the Lua (Roblox), C# (Unity)
// and TypeScript SDKs all call it, which is why the body carries `motor` and
// `sdk`. They are stored with the game and travel to the leaderboard.
//
// The mines are NOT seeded here. This only reserves the board: dimensions, how
// many mines it will have, and the revealed/flag grids at zero. The mines are
// seeded on the first move, once we know which cell the player touched and can
// keep it clear.
//
// Seeding here would make the first move a coin flip: on hard there are 45 mines
// in 256 cells, so one game in six would end on the first click before the player
// decided anything. That is not difficulty, it is a coin toss before the start.

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` gets the opening time so the column is never empty, but it is not the
// game clock yet: the Jugar automation overwrites it with the first move's time.
// What gets timed is the game, not the menu.
const inicio = new Date().toISOString();

const alias = String(body.alias || "anonimo");
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)
};

Declare the outputs the node returns, so later nodes can reference them: codigo, filas, columnas, minas, dificultad, matriz, revelado, banderas, inicio, alias, jugadorId, motor, sdk and respuesta. All strings except filas, columnas and minas, which are numbers.

Node 3 - Insert Rows, named guardar, pointed at your Buscaminas Partidas table. Map the fields:

Column

Value

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

Node 4 - Response. Status 200, content type application/json, body template {{context.steps.generar.respuesta}}.

Publish the Automation and link it to the Buscaminas: Nueva Partida endpoint.

Why does the Script return both a row and a `respuesta`? Because they are two different audiences. The row keeps everything, including `matriz`. The `respuesta` is what leaves the workspace, and it has no `matriz` in it. Building them separately is what makes the omission deliberate rather than accidental.

imagen_2026-08-28_101150405.png

Step 4 - The `Jugar` Automation

This is the referee. Create an Automation named Buscaminas: Jugar with eight nodes:

Endpoint trigger → Query row → Script: resolve → Update row → If ended?
                                                                 ├─ no  → Response
                                                                 └─ yes → Close game → Leaderboard row → Response

Node 1 - Endpoint trigger, pointed at Buscaminas: Jugar. Test payload:

{ "codigo": "2K2Z5B64", "accion": "revelar", "fila": 0, "columna": 0 }

Node 2 - Query Rows, named buscar, on Buscaminas Partidas. One filter: column Codigo, operator eq, value {{context.request.body.codigo}}. Limit 1.

Node 3 - Script, named resolver. Two inputs: payload from {{context.request.body}}, and partida from {{context.steps.buscar.row}}.

// Resolve one move. All the authority lives here: the client only sends
// coordinates and receives a masked view. The mines never travel to the game,
// except on a loss, when it no longer matters.
//
// NOTE: in the step context, column names arrive with underscores
// ('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, []);

// Dimensions come from the row, NOT from the matrix: until the first move the
// matrix is empty on purpose, and `matriz.length` would be 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;

// A Status column can arrive as { Id, Name, ... } or as plain text.
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; }

// Seed the mines, keeping the cell the player just touched clear along with its
// eight neighbours where they fit.
//
// Excluding only the touched cell would be enough to avoid losing on the first
// click, but it would leave the player staring at a lone number, still guessing.
// With the 3x3 clear, the touched cell has zero adjacent mines, so the flood fill
// opens a hole and the game starts with information.
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++;
      }
    }
  }

  // Small, heavily mined board: if the mines do not fit with the 3x3 clear, the
  // safe zone shrinks to the touched cell. Without this the loop below never ends.
  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") {
  // Flagging before the first click is legal and seeds nothing.
  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 {
    // First valid move: only now do we know which cell to keep clear.
    if (!sembrado) {
      matriz = sembrar(fila, col);
      sembrado = true;
      // The clock starts with the first move, not when the game was opened.
      inicio = new Date().toISOString();
    }

    if (matriz[fila][col] === -1) {
      revelado[fila][col] = 1;
      estado = "Perdida";
      mensaje = "Pisaste una mina";
    } else {
      // Iterative flood fill: opening a 0 opens the whole empty block.
      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;
  }
}

// Masked view: '?' hidden | 'F' flag | '0'-'8' revealed | '*' mine (only on a loss)
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)
};

Node 4 - Update Rows, named actualizar, on Buscaminas Partidas, with row id {{context.steps.resolver.rowId}}:

Column

Value

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

Node 5 - If/Else, named termino. One rule: {{context.steps.resolver.terminada}} == si.

Node 6 - Update Rows on the true branch, named cerrar, same table and row id:

Column

Value

Fin

{{context.steps.resolver.fin}}

Puntaje

{{context.steps.resolver.puntaje}}

Segundos

{{context.steps.resolver.segundos}}

Node 7 - Insert Rows, named marcador, on Demos Leaderboard:

Column

Value

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

Node 8 - Response, reached from both branches. Body template {{context.steps.resolver.respuesta}}.

Publish and link it to the Buscaminas: Jugar endpoint.

image.png

Why does the score only exist when the game ends? Because a running score is a number the client could show and the player could chase mid-game. Computing it once, at the end, on the server, keeps it out of every intermediate response.


Step 5 - The `Leaderboard` Automation

Four nodes again:

Endpoint trigger  →  Query rows  →  Script: format  →  Response

Node 1 - Endpoint trigger, pointed at Buscaminas: Leaderboard. Test payload { "limite": 10 }.

Node 2 - Query Rows, named consultar, on Demos Leaderboard. Filter: Juego eq Buscaminas. Order by Points descending. Limit 50.

Node 3 - Script, named formatear. Two inputs: filas from {{context.steps.consultar.rows}} and payload from {{context.request.body}}. One output: respuesta.

// Takes the raw leaderboard rows and returns a top list ready to paint.
//
// The dedupe key is alias + motor, not alias alone: the point of this board is
// seeing the same player coming in from Roblox, from Unity and from the browser,
// and comparing. Deduping by alias alone would let the best game hide the other two.

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));

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

Node 4 - Response, body template {{context.steps.formatear.respuesta}}.

Publish, link it, and the backend is done.

Note the field name. Each entry in `top` carries `puntos`, not `puntaje`. `puntaje` is the game's score inside `Jugar`; `puntos` is the leaderboard entry's. Reading the wrong one gives you `nil` in Lua, silently.


The `Validar Resultado` Automation

Up to here the game calls Jugar on every move, and Praxsuite is the authority at all times. The published demos of this game — Roblox, Unity, the web app and the Minecraft plugin — went one step further for latency: they resolve the move on the client and call Praxsuite only twice per game, to open it and to close it. This Automation is the only thing that keeps that shortcut safe.

It does not replay the game: it compares what the client reports against the dimensions and mine count Praxsuite generated when the game was opened, and checks that the reported result is internally consistent with that. If anything fails to add up, nothing is written — neither the game's closing row nor the leaderboard.

Do I need it if I stay with the per-click design? No, and the two coexist happily in the same workspace. But the moment you move the engine to the client it becomes mandatory: without it, "local" means "the client decides and Praxsuite takes notes".

Ten nodes, with one branch:

Trigger → Find game → Current top 3 → Script: validate → Valid?
   ├── true  → Close game → Record on leaderboard → Vault → Publish to bus → Respond
   └── false → Respond

Node 1 - Endpoint Trigger, pointed at Buscaminas: Validar Resultado. Test payload, with a 2×2 board you can check by hand:

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

Node 2 - Query Rows, named buscar, over Buscaminas Partidas. Filter Codigo eq {{context.request.body.codigo}}, limit 1. This row is the only source of truth about how many mines the game had.

Node 3 - Query Rows, named top3, over Demos Leaderboard. Filter Juego eq Buscaminas, order by Points descending, limit 3. It is read before inserting this game, so there is a threshold to compare against.

Node 4 - Script, named validar, JavaScript. Three inputs: payload from {{context.request.body}}, partida from {{context.steps.buscar.row}} and top3rows from {{context.steps.top3.rows}}.

// -- Minesweeper: validate the final result -----------------------------------
// Inputs:
//   payload   <- {{context.request.body}}        final result from the local engine
//   partida   <- {{context.steps.buscar.row}}     row from 'Buscaminas Partidas'
//   top3rows  <- {{context.steps.top3.rows}}      current top 3, BEFORE inserting this game
//
// The local engine (Roblox/Unity/web/Minecraft) already played the whole game
// with no network per click. This does not replay it: it uses the rows/columns/
// mines Praxsuite generated in 'Buscaminas: Nueva Partida' -never what the client
// sends- as the single point of comparison, and checks that the reported result
// is internally consistent with that. If anything fails to add up, nothing is
// written: neither the game's closing row nor the 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");

// Top 3 threshold BEFORE inserting this game: while there are fewer than 3 rows,
// any valid score gets in.
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)
};

Declare the eighteen outputs: valido, rowId, codigo, alias, jugadorId, motor, sdk, dificultad, estado, matriz, revelado, banderas, celdasReveladas, puntaje, segundos, fin, esTop3 and respuesta.

Node 5 - If/Else, named esvalido. One rule: {{context.steps.validar.valido}} == si.

Node 6 - Update Rows (the true branch), named cerrar, over Buscaminas Partidas, with rowId = {{context.steps.validar.rowId}}:

Column

Value

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

Node 7 - Insert Rows, named marcador, over Demos Leaderboard:

Column

Value

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

Node 8 - Vault, named vaultbus. Alias busKey, pointed at the secret holding an API key allowed to publish to the Event Bus.

Node 9 - HTTP Request, named publicartop3. POST to your workspace's bus publish route:

https://gateway.praxsuite.com/api/v1/gateway/<your-workspace-uuid>/bus/leaderboard/buscaminas/publish

Headers x-api-key: {{vault.busKey}} and Content-Type: application/json. Body:

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

Turn on `continueOnError` for this node. That is what keeps closing the game and writing the leaderboard from ever depending on the bus being up: the announcement is strictly a bonus on top of a write that already happened.

The event is called `game_completed`, and it fires on every validated game — not only the podium ones. Who made the top travels as the `esTop3` field of the payload, not as the event name. A subscriber that only wants to announce records filters on that field; one that wants to show all activity filters on nothing. If you are coming from an earlier version of these guides that mentioned a `topscore` event, that is the name to fix on the subscriber side.*

Node 10 - Response. Status 200, application/json, body {{context.steps.validar.respuesta}}. Both branches end here: the invalid one answers with ok: false and the list of motivos, having written nothing.

Publish the Automation and bind it to the Buscaminas: Validar Resultado endpoint. You will need that endpoint alongside the other three — create it in Sync mode like the rest.

What does this actually prevent? Not a client that lies about everything — a determined cheater could still invent an internally consistent board. What it does prevent is exactly the "trust the client" failure mode: a bug in the local engine, or a deliberately modified binary, reporting an impossible board (more mines than the game opened with, numbers that do not match the minefield, a score the arithmetic does not support) and having that land silently on a leaderboard where everyone else's real games appear.


Checkpoint - Test the Backend Before Writing Any Lua

Run each Automation from the portal with its test payload. Nueva Partida should return a codigo and a vista of eight strings of eight ? each. Copy that codigo into the Jugar test payload and run it: the first reveal opens a patch, because the mines were seeded around your click.

{ "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??"] }

Coordinates start at zero. `fila` and `columna` are `0` to `R-1`. Send `fila = 8` on an 8x8 board and you get `"Coordenada fuera del tablero"`. Luau tables start at 1, so Part 2 has to subtract - this is the single most common thing to get wrong when wiring the client.


Part 2 - The Roblox Client

Step 6 - Configure the SDK and Name the Endpoints

Repository: https://github.com/TesseractSoftwares/Praxsuite-SDK-Lua

Create the config module. It holds no secret value - the name points at the Roblox Secrets Store:

-- ServerScriptService/PraxsuiteConfig  (ModuleScript)
return {
    workspaceId = "your-workspace-uuid",
    apiKeySecret = "PraxsuiteKey",
    baseUrl = "https://gateway.praxsuite.com",
}

Add a secret named PraxsuiteKey under Game Settings → Security → Secrets Store. Its value is the sk_live_... key scoped to your tables.

Now a module that names the endpoints once, so the rest of the code never repeats a UUID:

-- ServerScriptService/BuscaminasEndpoints  (ModuleScript)
return {
    MOTOR = "roblox",
    SDK = "lua",
    NuevaPartida = "paste-your-nueva-partida-endpoint-uuid",
    Jugar = "paste-your-jugar-endpoint-uuid",
    Leaderboard = "paste-your-leaderboard-endpoint-uuid",
}

MOTOR and SDK travel with every request so the shared leaderboard can dedupe the same person per front-end.

Why a module for the endpoints and not string literals? Because an ID is not meaning. `Jugar` says what it does; `c145d99d-...` does not. One module also gives you a single place to change when an endpoint is republished with a new ID.


Step 7 - Create the Remotes First

Everything else depends on these existing, so build them before any script that waits on them:

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

Order matters. Every script below starts with `ReplicatedStorage:WaitForChild("BuscaminasRemotes")`. If the folder is never created, that call yields forever and the script simply never runs - with no error to tell you why.


Step 8 - Start a Game

A game begins when the player picks a difficulty. The server calls Nueva Partida and receives a game code and a masked view:

-- 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] Endpoint '" .. slug .. "' failed: " .. 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 = "Could not create the game" }
    end

    sesiones[player.UserId] = {
        codigo = res.codigo,
        dificultad = dificultad,
    }

    return res
end

res contains codigo, filas, columnas, minas and a vista array full of ? characters. The mines do not exist yet; they get seeded on the first reveal.

Important: `Endpoints.Call` is synchronous: it blocks the server thread until the Automation responds. That is exactly what you want for a move - the next line needs the result - and exactly what you do not want on a `PlayerAdded` handler that must not stall.

Why does the client send the difficulty, if the server fixes it?

The client sends a choice, not a definition. facil is a key the server looks up; the server decides the board size and mine count. If the client sends dificil it gets 16x16; if it sends nonsense it gets the facil preset. The client never gets to define the board.


Step 9 - Build the Board from the Masked View

The board is 3D geometry in the Workspace, not a GUI. That choice is what removes the client's ability to lie: each cell is a Part, and its ClickDetector events fire on the server - the LocalScript never even gets a move to fake.

Two things about the masked view decide how this code is written, and both are easy to get wrong:

  • `vista` is an array of strings, one per row - not one long string. vista[1] is the top row.

  • The server counts rows and columns from zero. Luau tables count from one. Every coordinate you send has to be shifted down by one.

-- ServerScriptService/BuscaminasTablero  (ModuleScript)
local Tablero = {}

local CELDA = 8
local ORIGEN = Vector3.new(80, 0, -60)

local celdas = {}  -- [fila][columna] = Part, both 1-based like every Luau table

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` arrives as an array of strings: one entry per row, one character per
-- column. vista[1] is the top row, and vista[1]:sub(3, 3) is its third cell.
function Tablero.Pintar(vista)
    if typeof(vista) ~= "table" then
        warn("[Buscaminas] Expected an array of rows, got " .. 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

-- The board is 1-based; the server is 0-based. The subtraction happens here,
-- once, so no caller has to remember it.
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

Hidden cells are tall and light; revealed cells flatten to a low dark slab, and mines turn red once the game is lost. The relief is what lets a player read the board from a distance.

The off-by-one is the bug you will actually hit. Send 1-based coordinates and the server answers `"Coordenada fuera del tablero"` on the last row and column, while the first row becomes unreachable. Nothing crashes - the board just stops responding at the edges, which is much harder to notice than an error. Why 3D geometry and not a ScreenGui? Because a ScreenGui lives on the client, where a modified LocalScript can intercept or invent clicks. A ClickDetector fires on the server with the real `Player`. The client can draw whatever it wants over the board, but it cannot make the server reveal a cell.


Step 10 - Reveal and Flag

Now the move loop: a click on a cell becomes a call to Jugar, and the returned view repaints the board. Add this to 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,       -- already 0-based: Tablero.Conectar subtracted
        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 in " .. tostring(res.segundos) .. "s")
        sesiones[player.UserId] = nil
    end

    return res
end

And replace the NuevaPartida handler from Step 8 with this one, which also builds the board and wires the clicks:

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 = "Could not create the game" }
    end

    sesiones[player.UserId] = { codigo = res.codigo, dificultad = dificultad }

    Tablero.Construir(res)
    Tablero.Conectar(jugar)

    return res
end

Run it and click a cell. The first click always opens a patch, because the Automation seeds the mines on that first reveal and keeps a 3x3 box around your click clear.

When jugar returns terminada = true, the game is over. If you lost, vista now contains * for the mines - the server shows them to you precisely because the game is already lost.

imagen_2026-08-28_101945674.png

One board per server. `celdas` is a single module-level board, so this build assumes one game at a time. With two players, the second `Construir` destroys the first player's board. Giving each player their own board means keying `celdas` by `UserId` and offsetting `ORIGEN` per player - worth doing, and left as an exercise so the move loop stays readable here.

Why do we not keep the mines in the client?

A local solver - "reveal the neighbors that are safe" - is only possible if the client knows the mines. By keeping the matrix in Praxsuite and sending only the masked view, there is nothing on the player's device that a cheat could read. The most a modified client can do is send moves; it cannot send informed moves.


Step 11 - Read the Leaderboard

The leaderboard is read-only from the game's point of view: the Automation writes the score when a game ends, and the game only asks for the top rows.

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

Passing motor = "roblox" filters to Roblox rows; omit it to fetch every front-end at once.

Each entry looks like this:

{ "posicion": 1, "alias": "MirkOwwO", "puntos": 2128, "motor": "roblox",
  "sdk": "lua", "dificultad": "facil", "segundos": 56, "jugadorId": "11033534760" }

The score field is `puntos` here. Inside a game the score is `puntaje`; in a leaderboard entry it is `puntos`. Reading the wrong one gives you `nil`, and `tostring(nil)` prints `"nil"` into your board without raising anything.


Step 12 - The Client: Menu and HUD

The client only paints what it receives. This LocalScript builds three difficulty buttons and a status line, so the game starts with a click instead of a command-bar call:

-- 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 = "Pick a difficulty"
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 = "Starting..."
        local res = remotes.NuevaPartida:InvokeServer(dificultad)
        if not res or res.error then
            estado.Text = "Could not start"
            return
        end
        estado.Text = string.format("%s - %dx%d, %d mines", dificultad, res.filas, res.columnas, res.minas)
    end)

    return b
end

boton("Easy", 20, "facil")
boton("Medium", 140, "medio")
boton("Hard", 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 = "Show 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)

Press Play, click Easy, and walk up to the board. Left-click reveals, right-click flags. The board responds because every click is a round trip to Praxsuite and back.

Tip: The move loop has no client-side game logic at all. The LocalScript never holds the `codigo` of the game, never counts mines, and never decides a win. It is a remote control, not a brain.


Part 3 - Accounts and Redeemable Codes

A code like PRAX-LAVA02 unlocks a cosmetic. One code, one use, one owner - forever.

That last word is what forces an account. A cosmetic tied to a Roblox UserId would vanish the moment the same person opened the Unity build or the browser one, and two Praxsuite accounts sharing a machine would share unlocks. So the owner of an inventory is a Praxsuite End User, and the Roblox UserId is stored beside it as informational data only.

Register / Login  →  end user id  →  Redeem code  →  Inventory  →  Equip

That means Part 3 has an order you cannot shuffle: accounts first, then codes.

Update - the live demo now uses `Auth.LoginPlayer` instead of these two Automations. `Praxsuite.Auth.LoginPlayer(player)` did not exist in the SDK when this pattern was written; `Registro`/`Login` were the only way to get a stable end user id. Building them yourself, as Step 14 does, is still the right call if the same account has to log in from Unity or a browser with the same email and password - `Auth.LoginPlayer` only asserts a Roblox-specific identity, scoped to this platform's provider. But if a stable Praxsuite End User for this platform is all you need, `Auth.LoginPlayer` gets you one with no Automation, no password field, and no `Registrar`/`Login` remote at all - see the Lua SDK Implementation in Roblox guide. The shipped Roblox place switched to it for exactly that reason once the module became available; `Registrar` still exists in its Explorer tree but no longer has a server-side handler.


Step 13 - Three More Tables

`Buscaminas Codigos` - one row is one code.

Column

Type

What it holds

Codigo

ShortText

The code itself, uppercase: PRAX-LAVA02

Cosmetico Clave

ShortText

Which catalog entry it unlocks

Activo

Bool

Lets you retire a code without deleting it

Usado

Bool

Burned or not

Usado En

DateTime

When it was burned

Usado Por Id

ShortText

Which end user burned it

Usado Por Alias

ShortText

Their display name, for reading the table

Usado Desde Motor

ShortText

roblox, unity, web

`Buscaminas Cosmeticos` - the catalog. One row is one unlockable thing.

Column

Type

What it holds

Clave

ShortText

Stable id: paleta-lava

Nombre

ShortText

Display name

Descripcion

ShortText

One line for the UI

Juego

ShortText

Always Buscaminas - the catalog is shared

Tipo Clave

ShortText

The slot it occupies: paleta, mina, gorro

Rareza

Status

Comun, Rara, Legendaria - whatever you want

Config

Json

What the cosmetic actually does. You define this.

Activo

Bool

Lets you pull a cosmetic without breaking inventories

Config is deliberately open. The backend never reads it - it passes it through to the game, which decides what it means. For a board palette, something like:

{ "oculta": [150, 150, 165], "revelada": [90, 90, 100], "bandera": [240, 120, 120] }

`Buscaminas Inventario` - one row is one cosmetic owned by one player.

Column

Type

What it holds

Jugador Id

ShortText

The Praxsuite end user id. The owner.

Plataforma Id

ShortText

Roblox UserId, informational

Alias

ShortText

Display name at the time of unlock

Cosmetico

Table

Link to the catalog row

Cosmetico Clave

ShortText

Denormalized key, so lookups need no join

Tipo

ShortText

Copied from the catalog: one equipped item per type

Equipado

Bool

Whether it is currently on

Obtenido

DateTime

When

Codigo Usado

ShortText

Which code produced it

Motor

ShortText

Where it was redeemed from

SDK

ShortText

Which SDK redeemed it

Why does the inventory store `Cosmetico Clave` when it already links to the catalog row? Because every query in Part 3 filters by key, and a filter on a plain column is one lookup while a filter through a link is a join. The link is there for humans reading the table; the key is there for the Automations.


Step 14 - The `Registro` and `Login` Automations

Both talk to the workspace's own Auth Gateway over HTTP. Neither one stores a password anywhere in Roblox.

Create five more endpoints under Gateway → Endpoints, all Sync: Buscaminas: Registro, Buscaminas: Login, Buscaminas: Canjear Codigo, Buscaminas: Mis Cosmeticos, Buscaminas: Equipar Cosmetico.

Then store your gateway key in the Vault under the alias gatewayKey. The auth routes need a key of their own, and a Vault node is how an Automation reads one without it appearing in the graph.

`Buscaminas: Registro` is six nodes:

Endpoint trigger → Vault → Try/Catch → HTTP: register → Script: build reply → Response
  • Vault, alias gatewayKey.

  • Try/Catch with continue on failure enabled. The HTTP node throws on any non-2xx, and a 409 (email already registered) is an expected outcome here, not a bug.

  • HTTP Request, inside the Try branch. POST to https://gateway.praxsuite.com/YOUR-WORKSPACE-ID/auth/register, headers Content-Type: application/json and x-api-key: {{vault.gatewayKey}}, body:

{ "email": "{{context.request.body.email}}", "password": "{{context.request.body.password}}", "username": "{{context.request.body.alias}}" }
  • Script, named armar. Inputs: respuesta from {{context.steps.crear.body}}, detalle from {{context.steps.intento.error}}, payload from {{context.request.body}}.

// Same shape as login, so the client can treat both the same way.
// The HttpRequest sits inside the Try/Catch because it throws on any non-2xx,
// and here a 409 (email already registered) is an expected outcome, not a 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, body {{context.steps.armar.salida}}.

`Buscaminas: Login` is the same graph pointed at /auth/login, with a body of just email and password, and this script:

// The HttpRequest node THROWS on any non-2xx: it exposes statusCode on its
// output, but on the error path you never get to read it. Hence the Try/Catch.
//
// And beware of `trySucceeded`: on the finally path it is not written yet. The
// reliable signal is the response itself - if the step threw, it arrives empty.

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

// The accessToken and refreshToken are NOT returned to Roblox: the game server
// is already the trusted party, so handing it the token would only widen the
// surface without buying anything.
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")
    }
  })
};

Both return { ok, mensaje, usuario: { id, email, alias } }. That usuario.id is the end user id every code operation needs.

Why does the token never reach Roblox? Because your game server already authenticates with a secret key - it is the trusted party. Sending it a session token adds a credential to store, refresh and leak, in exchange for a permission it already has.


Step 15 - The `Canjear Codigo` Automation

This is the one that decides whether a code is worth anything. Nine nodes:

Endpoint trigger → Query code → Query cosmetic → Query "already owned?" → Script: validate
                 → Valid? ─ no ──────────────────────────────────────────→ Response
                          └ yes → Burn the code → Add to inventory ───────→ Response
  • Query Rows buscarCodigo on Buscaminas Codigos: Codigo eq {{context.request.body.codigo}}, limit 1.

  • Query Rows buscarCosmetico on Buscaminas Cosmeticos: Clave eq {{context.steps.buscarCodigo.row.Cosmetico_Clave}}, limit 1.

  • Query Rows buscarPosesion on Buscaminas Inventario: Jugador Id eq {{context.request.body.jugadorId}} and Cosmetico Clave eq {{context.steps.buscarCodigo.row.Cosmetico_Clave}}, limit 1.

Note the underscores in Cosmetico_Clave: inside the step context, column names arrive with spaces replaced by underscores.

  • Script validar. Inputs: payload, codigo from buscarCodigo.row, cosmetico from buscarCosmetico.row, posesion from buscarPosesion.row.

// One code, one use. All the decision lives here; the database nodes that
// follow only execute what this Script already resolved.
//
// WHO the owner is: `jugadorId` is the Praxsuite END USER id, not the platform
// one. If the inventory hung off the Roblox UserId, two Praxsuite accounts on
// the same machine would share cosmetics, and the same person coming in from
// Unity or the browser would see none of what they unlocked.

const body = payload || {};
const cod = codigo || {};
const cos = cosmetico || {};
const ya = posesion || {};

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) {
  // The code points at a cosmetic that does not exist or was deleted: a data
  // error, not the player's fault. The code is NOT burned.
  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"]) {
  // Also does not burn the code: if you already own it, the code stays alive
  // so you can pass it to someone else.
  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 on Buscaminas Codigos, row id {{context.steps.validar.rowIdCodigo}}:

Column

Value

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 on Buscaminas Inventario:

Column

Value

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 from both branches, body {{context.steps.validar.respuesta}}.

The endpoint takes { codigo, jugadorId, plataformaId, alias, motor, sdk } and returns { ok, mensaje, codigo, cosmetico }.

Two failures deliberately do not burn the code. A broken catalog entry is your bug, not the player's. Already owning the cosmetic is not a failure at all - the code stays alive so it can be given away. Burning on every attempt would be simpler to write and worse to play.


Step 16 - `Mis Cosmeticos` and `Equipar Cosmetico`

`Buscaminas: Mis Cosmeticos` - five nodes, read-only:

Endpoint trigger → Query inventory → Query catalog → Script: join → Response

inventario filters Jugador Id eq {{context.request.body.jugadorId}}, limit 200. catalogo filters Juego eq Buscaminas, limit 200. Then:

// The inventory stores only the cosmetic's key. The config (colors, symbols,
// material) lives in the catalog, so a cosmetic can be retouched without
// rewriting the row of every player who owns it.
//
// Joining here rather than in the game has a concrete reason: the client gets
// the config already resolved and never needs to know the catalog exists.

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;                       // deleted from the catalog
  if (c.Activo === false) continue;       // deactivated: not offered

  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: {...} } ready to apply
  })
};

It takes { jugadorId } and returns { ok, total, items[], equipado }.

`Buscaminas: Equipar Cosmetico` - seven nodes:

Endpoint trigger → Query "owned?" → Script: validate → Owned? ─ no ─→ Response
                                                              └ yes → Unequip same type → Equip this → Response

buscarItem filters Jugador Id and Cosmetico Clave, limit 1. Then:

// Equipping is a two-step operation that has to stay consistent: first unequip
// everything of the same type, then mark this one. If the player does not own
// the cosmetic, nothing is touched.
//
// And it is a SWITCH, not a one-way button: tapping a cosmetic that is already
// on takes it off. Without this, a player who unlocks a palette can never go
// back to the default board.

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;

// The client can force the direction by sending `equipar: true|false`.
// Without that field it toggles.
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,
  // UpdateRows fields only accept strings, so the boolean travels as "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, filtered by Jugador Id and Tipo, setting Equipado to false.

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

  • Response from both branches.


Step 17 - Seed a Few Codes

Add a couple of rows to 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

Ice

paleta

Buscaminas

true

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

Then a code for each in Buscaminas Codigos, with Activo true and Usado false:

Codigo

Cosmetico Clave

PRAX-LAVA02

paleta-lava

PRAX-ICE01

paleta-hielo

Codes are compared uppercase - the Script upper-cases whatever the player typed - so store them uppercase too.


Step 18 - The Client: Sign In and Redeem

Add the remaining endpoints to the module from Step 6:

-- ServerScriptService/BuscaminasEndpoints  (ModuleScript)
return {
    MOTOR = "roblox",
    SDK = "lua",
    NuevaPartida = "paste-your-nueva-partida-endpoint-uuid",
    Jugar = "paste-your-jugar-endpoint-uuid",
    Leaderboard = "paste-your-leaderboard-endpoint-uuid",
    Registro = "paste-your-registro-endpoint-uuid",
    Login = "paste-your-login-endpoint-uuid",
    Canjear = "paste-your-canjear-endpoint-uuid",
    MisCosmeticos = "paste-your-mis-cosmeticos-endpoint-uuid",
    Equipar = "paste-your-equipar-endpoint-uuid",
}

And five remotes to Step 7's list:

remote("Registrar")
remote("Login")
remote("Canjear")
remote("MisCosmeticos")
remote("Equipar")

Now the server side. The end user id is the one thing the whole of Part 3 hangs on, so it lives in the session table and is never sent to the client as something the client gets to choose:

-- add to ServerScriptService/BuscaminasServidor  (Script)

local cuentas = {}  -- [UserId] = { id = "<end user uuid>", 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 = "The server did not answer" }
    end
    if res.ok and res.usuario then
        cuentas[player.UserId] = { id = res.usuario.id, alias = res.usuario.alias }
    end
    -- Only ok and mensaje travel back. The end user id stays on the server.
    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 = "The server did not answer" }
    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 = "Sign in before redeeming" }
    end

    local res = llamar(Endpoints.Canjear, {
        codigo = codigo,
        jugadorId = cuenta.id,                      -- the Praxsuite end user
        plataformaId = tostring(player.UserId),     -- the Roblox id, informational
        alias = cuenta.alias,
        motor = Endpoints.MOTOR,
        sdk = Endpoints.SDK,
    })

    if not res then
        return { ok = false, mensaje = "The server did not answer" }
    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 = "Sign in first" }
    end
    return llamar(Endpoints.Equipar, { jugadorId = cuenta.id, clave = clave })
        or { ok = false, mensaje = "The server did not answer" }
end

The client never sends its own `jugadorId`. It asks to redeem a code, and the server decides on whose behalf. If the client supplied the id, any player could type someone else's end user uuid and redeem into their inventory - or read it back with `MisCosmeticos`. The one place that id comes from is the login response the server itself received.

Now the code box, in the LocalScript from Step 12:

-- add to 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 = "Enter a code"
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 = "Redeem"
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 = "Type a code first"
        return
    end

    canjear.Text = "..."
    local res = remotes.Canjear:InvokeServer(texto:upper())
    canjear.Text = "Redeem"

    if not res then
        aviso.Text = "No answer"
        return
    end

    aviso.Text = tostring(res.mensaje)
    if res.ok then
        caja.Text = ""
        if res.cosmetico then
            -- Equip what was just unlocked, so the reward is visible immediately
            remotes.Equipar:InvokeServer(res.cosmetico.clave)
        end
    end
end)

Type PRAX-LAVA02, press Redeem, and the label answers with whatever the Automation decided: Desbloqueaste Lava the first time, Ese codigo ya fue canjeado the second.


Step 19 - Apply the Cosmetic to the Board

Mis Cosmeticos already returns equipado, keyed by type, with the catalog config resolved. The board only has to read it:

-- add to ServerScriptService/BuscaminasTablero  (ModuleScript)

-- Default palette, used when the player has nothing equipped
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` is the Json column from the catalog, passed through untouched.
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

Then use PALETA.oculta, PALETA.revelada, PALETA.bandera and PALETA.mina in Tablero.Pintar instead of the hardcoded Color3.fromRGB(...) calls, and load the player's palette when their game starts:

-- in the NuevaPartida handler, before 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

The whole cosmetic system reaches the game as one Json blob the backend never interprets. Adding a new palette means one catalog row and one code - no Automation change, no republish, no new Lua.

Why does the backend not validate `Config`? Because it cannot know what a cosmetic means. `paleta` happens to be three colours today; tomorrow a `mina` type might be a mesh id and a sound. A backend that validated the shape would have to be redeployed for every new kind of reward. Passing it through untouched is what keeps the catalog editable by hand.


Complete Code

Six scripts, in the order they load. PraxsuiteConfig and BuscaminasEndpoints are the modules from Step 6, BuscaminasRemotes from Step 7, BuscaminasTablero from Step 9. Here is the server bridge with everything wired together:

-- 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 }   the current game
local cuentas = {}   -- [UserId] = { id, alias }            the Praxsuite end user

local function llamar(slug, payload)
    local ok, res = pcall(Praxsuite.Endpoints.Call, slug, payload)
    if not ok then
        warn("[Buscaminas] Endpoint '" .. slug .. "' failed: " .. 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 in " .. 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 = "Could not create the game" }
    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 = "The server did not answer" }
    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 = "The server did not answer" }
    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 = "Sign in before redeeming" }
    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 = "The server did not answer" }
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 = "Sign in first" }
    end
    return llamar(Endpoints.Equipar, { jugadorId = cuenta.id, clave = clave })
        or { ok = false, mensaje = "The server did not answer" }
end

game:GetService("Players").PlayerRemoving:Connect(function(player)
    sesiones[player.UserId] = nil
    cuentas[player.UserId] = nil
end)

Press Play, sign in, click Easy, and play. When you finish, open Demos Leaderboard in the portal: your row is there, next to the Unity and browser players, with the same alias deduped per front-end.

Tabla "Demos Leaderboard"

Captura de pantalla 2026-08-28 102443.png

Tabla "Buscaminas Partidas"

Captura de pantalla 2026-08-28 102729.png

Common Errors and How to Avoid Them

Error

Cause

Solution

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

PraxsuiteConfig is missing baseUrl.

Add baseUrl = "https://gateway.praxsuite.com" to the config module.

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

A script required the SDK before the config module was found.

Make sure PraxsuiteConfig is a ModuleScript directly in ServerScriptService.

[PraxsuiteSDK] HTTP_404: Not Found

The endpoint ID does not exist, or the Automation is not published.

Re-copy the ID from Gateway → Endpoints and confirm the Automation has a published version.

[PraxsuiteSDK] HTTP_401: Unauthorized

The SDK resolves the API key on every request, and the Secrets Store has no PraxsuiteKey.

Add the secret under Game Settings → Security → Secrets Store.

The script never runs and nothing is logged

It is waiting on BuscaminasRemotes, which no script created.

BuscaminasRemotes.server.lua has to run first. WaitForChild on something that never appears yields forever, silently.

attempt to index nil with 'sub', or the board paints nothing

vista was treated as one long string. It is an array, one string per row.

Index the row first: vista[fila], then string.sub(row, columna, columna).

"Coordenada fuera del tablero", or the first row never responds

1-based Luau coordinates were sent to a 0-based server.

Subtract one before sending: onJugada(player, accion, fila - 1, columna - 1).

The leaderboard prints nil for every score

The entry field is puntos, not puntaje.

Read fila.puntos. puntaje only exists inside a Jugar response.

Could not create the game

llamar got no usable response - wrong payload shape, or the endpoint errored.

Check that dificultad is one of facil, medio, dificil, and print the full res inside llamar.

The board does not respond to clicks

ClickDetector.MaxActivationDistance is too small, or the player spawns far from the board.

Raise the distance, or place the board near the spawn.

"Tenes que iniciar sesion para canjear"

The redeem call arrived with an empty jugadorId.

The player has to sign in first: the end user id comes from the Login or Registro response, not from the Roblox UserId.

A code works once and then reports "Ese codigo ya fue canjeado"

Working as intended. One code, one use.

Add another row to Buscaminas Codigos, or set Usado back to false to test again.

Tip: Wrap the `OnServerInvoke` bodies in `pcall` too. A RemoteFunction that errors on the server shows the player a misleading error in Studio, which hides the real cause. Return an error table instead of throwing.


Production Tips

  • Rate-limit the moves. Every click is a synchronous Endpoints.Call. A burst of clicks is a burst of HTTP requests; a small minimum interval between moves is enough.

  • Keep `MOTOR` and `SDK` labels exact. The leaderboard dedupes by alias + motor; changing "roblox" breaks that grouping.

  • Store the key in the Secrets Store, never in code. The config module holds only the secret's name.

  • Never let the client choose a `jugadorId`. It is the one field that decides whose inventory is being touched, and it comes from the login response the server received.

  • Wrap every network call in `pcall`. A hiccup in a RemoteFunction handler must become a returned error, not a broken join.

  • Keep the matrix in Praxsuite. If a future feature needs the client to know the board, it should receive only the masked view, never the mines.


Production Note: Why Move the Engine to Roblox (and Why Praxsuite Still Validates the Result)

The first tip in the section above already hints at it: every click in this tutorial is a synchronous Endpoints.Call against the Buscaminas: Jugar automation from Step 4 - a full network round trip, Roblox → gateway → automation → database → automation → gateway → Roblox, before the player sees the effect of their click. In a real deployment of this same tutorial, with clicks in quick succession (opening an empty area in a hurry, say) that latency ends up being noticeable, and rate-limiting the moves only hides it, it does not remove it.

The fix that was validated in production was moving the game's algebra - seeding, flood-fill, score calculation - from Step 4 into a local Luau module, BuscaminasMotor.lua, a 1:1 port of the same resolver script you built there, running directly in ServerScriptService. With that, a click resolves inside the same trusted process on the Roblox server, with no call to Praxsuite at all: the same milliseconds any other local computation takes.

That alone, however, opens a gap: with no record on Praxsuite's side, nothing stops a corrupt result - a bug in the local engine, or a hand-manipulated report - from writing any score to the leaderboard shared with Unity and the web. The fix does not bring back the automation per click (the latency problem lived there, not in the whole match); it adds back two calls per game, not per click:

The hybrid pattern: two calls per game

  1. When opening the game, the buscaminas-nueva-partida automation from Step 3 is still called, unchanged: it returns the code, rows, columns and mines, which Praxsuite stores as a row in Buscaminas Partidas - with the matrix still empty, unseeded. Just like in Step 4, the mines are only seeded on the first valid move, both on the local engine's side and on the row's side, so that first move is never a coin flip. This row is the comparison point the final result gets validated against.

  2. When closing the game, instead of Roblox writing straight to the leaderboard, a separate automation is called, buscaminas-validar-resultado — the one you built under The `Validar Resultado` Automation - a Sync endpoint whose Script node recomputes everything from scratch against that row's Filas/Columnas/Minas, never against what the client sends.

What buscaminas-validar-resultado checks

Before touching the row or the leaderboard, the new endpoint's Script checks, all server-side:

  • That the game exists and is still "En curso" - an already-closed row rejects any retry (protection against resending the same game twice).

  • That the reported matrix has exactly the number of mines Nueva Partida generated, and that rows/columns match.

  • That every non-mine number in the matrix is consistent with the reported mine positions, recomputed cell by cell - sending any matrix with the right mine count is not enough.

  • If the state is "Ganada" (won): that no revealed cell is a mine, and that every safe cell is revealed (rows × columns − mines).

  • If the state is "Perdida" (lost): that at least one revealed cell is a mine.

  • That the score, recomputed with the same formula from Step 4 (revealed × 10, plus mines × 50 + max(0, 600 − seconds) × 2 if won), matches the reported one exactly.

If any of these checks fails, the automation touches neither the row nor the leaderboard - the player already saw their local result either way; the only thing lost is that entry in the cross-engine leaderboard.

This same automation also feeds the shared Event Bus. Once a validated result closes the row, `buscaminas-validar-resultado` checks whether the score made the leaderboard's top 3 and, if so, publishes a `gamecompleted event — the same publish-only mechanism the leaderboard shares across every engine (Unity, the web demo and the Minecraft plugin trigger the identical automation too, not a Roblox-specific copy of it). The event fires on every validated game, and whether the score made the podium travels in the payload's esTop3 field. The publish call runs with continueOnError`, so closing the row and writing the leaderboard are never affected by anything happening on the Event Bus side — the bus announcement is strictly additive on top of that.*

Before (Steps 3-5, automation per click)

After (hybrid, local engine + validation)

Click → Endpoints.Call("Jugar") → Buscaminas: Jugar automation (seeding, flood-fill, score, Insert Rows to the leaderboard) → response

Click → BuscaminasMotor.Jugar(...), local, synchronous, no network

The leaderboard was written inside Buscaminas: Jugar

On finish, buscaminas-validar-resultado recomputes everything and only if it validates does it close the row and insert into the leaderboard

The Buscaminas: Jugar automation built in Step 4 still exists as-is in the workspace and is still valid - it is engine-agnostic, so Unity or a web client can still call it per click. Roblox simply no longer needs it on the hot path.

Test results (live-tested on 2026-09-09)

  • A valid result closes the row and writes the leaderboard.

  • The same code resent is rejected (partida_ya_cerrada).

  • A hand-altered result - claims a win but leaves a safe cell unrevealed, with a made-up score - is rejected (gano_pero_no_revelo_todas_las_celdas_seguras) without touching the row.

  • A real Play session in Roblox Studio (8×8, 10 mines, 33 clicks, loss) ran the full flow end to end: the score Roblox computed (330) reached the leaderboard intact after passing validation.


Next Steps

  • Give each player their own board: key celdas by UserId and offset ORIGEN, so two people can play in the same server.

  • Resume an unfinished game by storing the game codigo and re-fetching the masked view when the player rejoins.

  • Add a cosmetics panel that lists MisCosmeticos and lets the player toggle each one - the Equipar endpoint is already a switch.

  • Add new cosmetic types beyond paleta: a mina mesh, a board material, a victory sound. The backend needs no change; only the game's reading of config grows.

  • Port the client to Unity or the browser against the same endpoints - the backend needs no change, only motor and sdk differ.