Praxsuite

Caso de Uso del SDK de Unity

Mirko Franichevic · 27 de agosto de 2026

¿Qué vamos a construir?

En esta guía vas a construir un Buscaminas 2D completo en Unity con Praxsuite como backend. Unity dibuja la pantalla, maneja los clics y mantiene la interacción fluida. Praxsuite inicia sesión, registra jugadores, guarda la matriz oculta de minas, resuelve cada jugada, cierra la partida y devuelve un leaderboard compartido que puede mezclar entradas de Unity, Roblox y navegador.

Construyes las dos mitades. La Parte 1 es el backend, dentro del portal de Praxsuite. La Parte 2 es el cliente de Unity. La Parte 3 agrega códigos canjeables que desbloquean cosméticos.

Al terminar esta guía sabrás:

  • Cómo modelar el juego en dos tablas, y por qué el campo minado tiene que ser una de sus columnas

  • Cómo construir tres Automations nodo por nodo, del trigger a la respuesta

  • Cómo instalar y configurar el SDK (Software Development Kit) de Praxsuite en Unity

  • Cómo usar Prax.Auth para login y registro

  • Cómo llamar Automations del servidor con Prax.Endpoints.CallAsync

  • Cómo dibujar un tablero de Buscaminas sin descargar las minas

  • Cómo crear los GameObjects visibles una sola vez y generar sólo las celdas en runtime

  • Cómo mostrar un leaderboard con entradas de todos los tipos de cliente

  • Cómo emitir códigos de un solo uso que desbloquean un cosmético y sobreviven entre dispositivos

Nivel requerido: Debes conocer C# básico y UI 2D de Unity. No necesitas experiencia previa con Praxsuite.


Cómo funciona

Piensa en Praxsuite como el árbitro que está fuera del dispositivo del jugador. Unity puede pedir una partida nueva o enviar una jugada, pero el árbitro conserva la hoja de respuestas. Eso importa porque un build de Unity corre en una máquina que el jugador controla. Si el build sabe dónde están las minas, un build modificado también puede saberlo.

Runs on the client

Runs on Praxsuite

Muestra los campos de login y registro

Crea y refresca la sesión autenticada del jugador

Envía alias, jugadorId, dificultad, motor y sdk al empezar

Crea la fila de partida y prepara el estado oculto del tablero

Crea los botones visibles de las celdas

Guarda Matriz, Revelado y Banderas

Envía codigo, accion, fila y columna en cada jugada

Revela celdas, alterna banderas, siembra minas en la primera revelación, detecta victoria o derrota

Dibuja ?, iconos de bandera, números y minas desde la vista devuelta

Calcula Puntaje, Segundos y escribe la fila del leaderboard

Pide el leaderboard con { limite: 10 }

Devuelve las mejores filas de Unity, Roblox y navegador

Golden rule: Unity dibuja el tablero, pero Praxsuite es dueño de la verdad. El cliente nunca recibe la columna `Matriz` y nunca envía `Points`.

La vista del tablero es una lista de cadenas, una cadena por fila. Cada carácter es un estado público:

Carácter

Significado

?

Celda oculta

F

Celda con bandera

0 a 8

Celda revelada con el conteo de minas vecinas

*

Mina, sólo se devuelve después de perder

Qué hace distinto hoy la demo que se publica

Esta guía enseña el patrón de arriba porque es la forma más clara de aprender la regla que importa: nunca confiar en el cliente para nada que decida el resultado. Los Pasos 3 a 5 construyen exactamente eso: una Automation, Buscaminas: Jugar, que corre en cada jugada y decide victoria, derrota y puntaje por sí sola.

El proyecto Unity de referencia de Praxsuite (PraxsuiteSDKDemo) ya superó esa base, por la misma razón que lo hicieron las demos de TypeScript y Roblox: un viaje completo de ida y vuelta por cada click (Unity -> Gateway -> Automation -> Tabla -> Automation -> Unity) sumaba latencia que se notaba con clicks rápidos. Su lógica de juego ahora resuelve cada jugada localmente en C#, replicando lo que hace Buscaminas: Jugar, y Praxsuite se llama sólo dos veces por partida: una para abrirla (Nueva Partida) y otra al final, mediante un endpoint Validar Resultado que recalcula el resultado completo desde cero contra la fila guardada. Buscaminas: Jugar sigue existiendo en el workspace; el cliente que se publica simplemente ya no la llama en cada jugada.

Ambas formas son válidas, y esta guía sigue construyendo la que enseña la regla de fondo de la manera más directa. La forma de menor latencia que el proyecto de referencia realmente usa se construye más adelante en esta guía, en La Automation `Validar Resultado`.


Requisitos previos

Requisito

Descripción

Unity 2021.3 o superior

El paquete instalado declara Unity 2021.3 como versión mínima

uGUI

Esta guía usa Canvas, InputField, Text, Button, GridLayoutGroup y GraphicRaycaster

Paquete Input System

El proyecto demo usa el nuevo Input System, así que el EventSystem debe usar InputSystemUIInputModule

Paquete SDK de Praxsuite

Instalado como com.tesseractsoftwares.praxsuite desde el repositorio Git del SDK Unity

Tu propio workspace

Ahí construyes el backend. La Parte 1 crea todo desde cero, así que un workspace nuevo sirve

Cuenta de end-user de prueba

Necesaria para Prax.Auth.LoginAsync y Prax.Auth.RegisterAsync, y para canjear códigos en la Parte 3

¿Qué es un workspace? Un workspace es el área de Praxsuite que contiene tus Tables, Automations, endpoints de Gateway, usuarios y Docs. Para un juego, es el proyecto backend con el que habla tu cliente Unity. !imagen2026-08-27111906737.png


Parte 1 - Construir el Backend

Tres endpoints exponen el juego, y cada uno corre una Automation. Un endpoint Sync ejecuta su Automation y devuelve el resultado en la misma petición, que es lo que espera Endpoints.Call.

Endpoint

Entrada

Salida

Buscaminas: Nueva Partida

{ alias, jugadorId, dificultad, motor, sdk }

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

Buscaminas: Jugar

{ codigo, accion, fila, columna }

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

Buscaminas: Leaderboard

{ limite, motor? }

{ total, porMotor, top[] }

accion es revelar o bandera.


Paso 1 - Crear las Dos Tablas

En el portal, entra a DataEngine y crea una tabla llamada `Buscaminas Partidas`. Una fila es una partida.

Columna

Tipo

Qué guarda

Codigo

ShortText

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

Alias

ShortText

Nombre visible, lo usa el leaderboard

Jugador Externo Id

ShortText

El id del jugador en la plataforma que lo hospeda - acá un id de end user de Praxsuite

Estado

Status

En curso, Ganada, Perdida

Filas

Integer

Alto del tablero

Columnas

Integer

Ancho del tablero

Minas

Integer

Cuántas minas va a tener

Dificultad

ShortText

facil, medio o dificil

Matriz

Json

El campo minado. Este es el secreto.

Revelado

Json

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

Banderas

Json

Grilla de 0/1: qué celdas tienen bandera

Celdas Reveladas

Integer

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

Puntaje

Integer

Puntaje final, se escribe al terminar

Inicio

DateTime

Cuándo ocurrió la primera jugada

Fin

DateTime

Cuándo terminó la partida

Segundos

Integer

Duración

Motor

ShortText

roblox, unity, web

SDK

ShortText

lua, csharp, typescript

Al crear Estado, agrega exactamente tres estados: `En curso`, `Ganada`, `Perdida`. La Automation escribe esos nombres como texto, así que un error de tipeo acá es una escritura rechazada más adelante.

¿Por qué `Matriz`, `Revelado` y `Banderas` son Json y no tres tablas? Porque siempre se leen y escriben enteras, juntas, para una sola partida. Partir una grilla de 16x16 en 256 filas no te daría nada y te costaría una query por jugada. Una columna Json es la forma correcta cuando el valor no tiene vida propia fuera de su fila.

Ahora crea una segunda tabla llamada `Demos Leaderboard`. Una fila es una partida terminada.

Columna

Tipo

Qué guarda

Record

ShortText

Una etiqueta legible para la fila

Alias

ShortText

Nombre visible

Jugador Externo Id

ShortText

El mismo id de arriba

Points

Integer

El puntaje

Dificultad

ShortText

Qué preset se jugó

Segundos

Integer

Cuánto tardó

Juego

ShortText

Siempre Buscaminas - la tabla se comparte con otras demos

Motor

ShortText

roblox, unity, web

SDK

ShortText

lua, csharp, typescript

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

Captura de pantalla 2026-08-28 102729.png

El rol del jugador, y qué NO puede tocar

Las tablas ya existen, y ahora mismo el rol de un jugador no alcanza ninguna de las dos. Antes de seguir conviene decidir qué puede ver, porque la respuesta fácil — darle acceso a la tabla y seguir — es exactamente la que vacía de sentido al resto de esta guía.

En Settings → API Gateway → Roles, crea un rol llamado `Buscaminas Jugador` y dale un scope sobre Buscaminas Partidas:

  • Row filter: __SELF__ sobre la columna Jugador, para que cada jugador alcance únicamente sus propias partidas.

  • Valor por defecto de la columna Jugador: {{claim:sub}}, para lo que escriba el propio jugador.

  • Acceso de columna, que es donde está todo el asunto:

Columna

Lectura

Escritura

Matriz

No

No

Puntaje, Estado, Celdas Reveladas, Fin, Segundos

Sí

No

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

Sí

No

`Matriz` es el modelo de seguridad entero. Si el rol del jugador puede leerla, un cliente modificado pide su propia fila por el Gateway y sabe dónde están todas las minas antes del primer clic. Si además puede escribirla, puede reescribir el campo minado para que un resultado inventado valide. Las Automations no pasan por estos scopes — corren con la autoridad del workspace — así que cerrarle `Matriz` al jugador no les saca nada a ellas.

Para Demos Leaderboard, el mismo criterio: el rol del jugador lee (el juego muestra el top) y no escribe nunca. La única escritura la hace la Automation que valida el resultado.

Una cuenta de end user recién creada arranca con los roles por defecto que tenga configurados el workspace, y si no hay ninguno se queda sin acceso a nada: toda query vuelve vacía o con 403. Asigna Buscaminas Jugador al registrar al jugador, o desde una Automation que valide su token.

Comprobación rápida: entra a Gateway → Playground, elige el rol `Buscaminas Jugador` y pide la columna `Matriz` de una partida. Tiene que fallar. Si te devuelve la grilla, el scope quedó abierto y cualquier jugador puede leer las minas.


Paso 2 - Crear los Tres Endpoints

En Gateway → Endpoints, crea tres endpoints, todos en modo Sync:

  • Buscaminas: Nueva Partida

  • Buscaminas: Jugar

  • Buscaminas: Leaderboard

Déjalos sin enlazar por ahora: enlazas cada uno a su Automation a medida que la construyes. Copia los tres UUID; la Parte 2 los pone en un componente de C#.

imagen_2026-08-28_101425525.png

¿Sync o Async? Sync bloquea hasta que la Automation responde y te entrega la salida de su nodo Response. Async acepta la petición y vuelve de inmediato. Una jugada necesita respuesta antes de que corra la línea siguiente, así que los tres son Sync.


Paso 3 - La Automation `Nueva Partida`

Crea una Automation llamada Buscaminas: Nueva Partida. Son cuatro nodos en línea recta:

Trigger de endpoint  →  Script: generar tablero  →  Insertar fila  →  Response

Nodo 1 - Trigger de endpoint. Apúntalo al endpoint Buscaminas: Nueva Partida. Dale este payload de prueba para poder correr la Automation sin un juego:

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

Nodo 2 - Script, llamado generar. Una entrada: payload, con origen {{context.request.body}}. Lenguaje JavaScript.

// -- Buscaminas: apertura de la partida ---------------------------------------
// Entrada: payload (object) <- {{context.request.body}}
//
// Este endpoint es agnostico del motor: lo llaman por igual el SDK de Lua
// (Roblox), el de C# (Unity) y el de TypeScript. Por eso el cuerpo trae
// `motor` y `sdk`, que se guardan con la partida y viajan al leaderboard.
//
// Aca NO se siembran las minas. Solo se reserva el tablero: medidas, cuantas
// minas va a tener, y las grillas de revelado y banderas en cero. Las minas se
// siembran en la primera jugada, cuando ya sabemos que celda toco el jugador y
// podemos dejarla libre.
//
// Sembrarlas aca obliga a que la primera jugada sea una apuesta: en dificil hay
// 45 minas en 256 celdas, asi que una de cada seis partidas se termina en el
// primer click sin que el jugador haya podido decidir nada. Eso no es
// dificultad, es una moneda al aire antes de empezar.

const body = payload || {};

const PRESETS = {
  facil:   { filas: 8,  columnas: 8,  minas: 10 },
  medio:   { filas: 12, columnas: 12, minas: 24 },
  dificil: { filas: 16, columnas: 16, minas: 45 }
};

const dificultad = String(body.dificultad || "facil").toLowerCase();
const preset = PRESETS[dificultad] || PRESETS.facil;

const R = Math.max(4, Math.min(20, Number(body.filas)    || preset.filas));
const C = Math.max(4, Math.min(20, Number(body.columnas) || preset.columnas));
const M = Math.max(1, Math.min(R * C - 1, Number(body.minas) || preset.minas));

const revelado = Array.from({ length: R }, () => new Array(C).fill(0));
const banderas = Array.from({ length: R }, () => new Array(C).fill(0));
const vista    = Array.from({ length: R }, () => "?".repeat(C));

const ALFA = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let codigo = "";
for (let i = 0; i < 8; i++) codigo += ALFA[Math.floor(Math.random() * ALFA.length)];

// `Inicio` queda con la hora de apertura para que la columna nunca este vacia,
// pero todavia no es el reloj de la partida: la automation de jugar lo pisa con
// la hora de la primera jugada. Lo que se cronometra es el juego, no el menu.
const inicio = new Date().toISOString();

const alias = String(body.alias || "anonimo");
// Id del jugador en la plataforma que lo hospeda: UserId de Roblox, id de
// Steam, uuid del navegador. Neutro a proposito.
const jugadorId = String(body.jugadorId || body.robloxUserId || "0");
const motor = String(body.motor || "desconocido").toLowerCase();
const sdk = String(body.sdk || "desconocido").toLowerCase();

const respuesta = {
  ok: true,
  codigo: codigo,
  filas: R,
  columnas: C,
  minas: M,
  dificultad: dificultad,
  motor: motor,
  sdk: sdk,
  estado: "En curso",
  celdasReveladas: 0,
  puntaje: 0,
  sembrado: false,
  vista: vista,
  inicio: inicio
};

return {
  codigo: codigo,
  filas: R,
  columnas: C,
  minas: M,
  dificultad: dificultad,
  matriz: "[]",
  revelado: JSON.stringify(revelado),
  banderas: JSON.stringify(banderas),
  inicio: inicio,
  alias: alias,
  jugadorId: jugadorId,
  motor: motor,
  sdk: sdk,
  respuesta: JSON.stringify(respuesta)
};

Declara las salidas que devuelve el nodo, para que los nodos siguientes puedan referenciarlas: codigo, filas, columnas, minas, dificultad, matriz, revelado, banderas, inicio, alias, jugadorId, motor, sdk y respuesta. Todas cadenas salvo filas, columnas y minas, que son números.

Nodo 3 - Insert Rows, llamado guardar, apuntado a tu tabla Buscaminas Partidas. Mapea los campos:

Columna

Valor

Codigo

{{context.steps.generar.codigo}}

Alias

{{context.steps.generar.alias}}

Jugador Externo Id

{{context.steps.generar.jugadorId}}

Estado

En curso

Filas

{{context.steps.generar.filas}}

Columnas

{{context.steps.generar.columnas}}

Minas

{{context.steps.generar.minas}}

Dificultad

{{context.steps.generar.dificultad}}

Matriz

{{context.steps.generar.matriz}}

Revelado

{{context.steps.generar.revelado}}

Banderas

{{context.steps.generar.banderas}}

Celdas Reveladas

0

Puntaje

0

Inicio

{{context.steps.generar.inicio}}

Motor

{{context.steps.generar.motor}}

SDK

{{context.steps.generar.sdk}}

Nodo 4 - Response. Estado 200, content type application/json, plantilla del cuerpo {{context.steps.generar.respuesta}}.

Publica la Automation y enlázala al endpoint Buscaminas: Nueva Partida.

¿Por qué el Script devuelve una fila y además una `respuesta`? Porque son dos audiencias distintas. La fila guarda todo, incluida `matriz`. La `respuesta` es lo que sale del workspace, y no tiene `matriz` adentro. Construirlas por separado es lo que hace que la omisión sea deliberada y no accidental.

Captura de pantalla 2026-08-31 003323.png

Paso 4 - La Automation `Jugar`

Este es el árbitro. Crea una Automation llamada Buscaminas: Jugar con ocho nodos:

Trigger → Buscar fila → Script: resolver → Actualizar fila → ¿Termino?
                                                                ├─ no  → Response
                                                                └─ si  → Cerrar partida → Fila de leaderboard → Response

Nodo 1 - Trigger de endpoint, apuntado a Buscaminas: Jugar. Payload de prueba:

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

Nodo 2 - Query Rows, llamado buscar, sobre Buscaminas Partidas. Un filtro: columna Codigo, operador eq, valor {{context.request.body.codigo}}. Límite 1.

Nodo 3 - Script, llamado resolver. Dos entradas: payload desde {{context.request.body}}, y partida desde {{context.steps.buscar.row}}.

// -- Buscaminas: resolver una jugada ------------------------------------------
// Entradas (source = valor con plantilla, NO una ruta de contexto):
//   payload  <- {{context.request.body}}      { codigo, accion, fila, columna }
//   partida  <- {{context.steps.buscar.row}}  fila de 'Buscaminas Partidas'
//
// Toda la autoridad vive aqui: el cliente solo manda coordenadas y recibe una
// vista enmascarada. Las minas nunca viajan al juego, salvo al perder, cuando
// ya no importa. Vale igual para Roblox, Unity o un navegador.
//
// OJO: en el contexto de pasos los nombres de columna llegan con guion bajo
// ('Jugador Externo Id' -> Jugador_Externo_Id).

const body = payload || {};
const p = partida || {};

function parseJ(v, fallback) {
  if (v === null || v === undefined) return fallback;
  if (typeof v === "string") {
    try { return JSON.parse(v); } catch (e) { return fallback; }
  }
  return v;
}

function isoDe(v, porDefecto) {
  if (!v) return porDefecto;
  const t = Date.parse(String(v));
  return Number.isFinite(t) ? new Date(t).toISOString() : porDefecto;
}

let matriz     = parseJ(p.Matriz, []);
const revelado = parseJ(p.Revelado, []);
const banderas = parseJ(p.Banderas, []);

// Las medidas salen de la fila, NO de la matriz: hasta la primera jugada la
// matriz esta vacia a proposito y `matriz.length` seria 0.
const R = Number(p.Filas) || 0;
const C = Number(p.Columnas) || 0;
const M = Number(p.Minas) || 0;

const existe = Boolean(p.Codigo) && R > 0 && C > 0;

let sembrado = Array.isArray(matriz) && matriz.length > 0;

// La columna Status puede llegar como objeto { Id, Name, ... } o como texto
let estado = (p.Estado && p.Estado.Name) ? p.Estado.Name : String(p.Estado || "En curso");

const accion = String(body.accion || "revelar").toLowerCase();
const fila = Number(body.fila);
const col  = Number(body.columna);

let mensaje = "";

function dentro(r, c) { return r >= 0 && r < R && c >= 0 && c < C; }

// Siembra las minas dejando libre la celda que el jugador acaba de tocar y, si
// entran, sus ocho vecinas.
//
// Excluir solo la celda ya alcanzaria para que no pierda en el primer click,
// pero lo dejaria mirando un numero suelto y adivinando igual. Con el 3x3 libre
// la celda tocada tiene cero minas alrededor, asi que el flood-fill abre un
// hueco y la partida empieza con informacion.
function sembrar(rSeguro, cSeguro) {
  const m = Array.from({ length: R }, () => new Array(C).fill(0));

  const prohibidas = {};
  let cuantasProhibidas = 0;
  for (let dr = -1; dr <= 1; dr++) {
    for (let dc = -1; dc <= 1; dc++) {
      const rr = rSeguro + dr, cc = cSeguro + dc;
      if (rr >= 0 && rr < R && cc >= 0 && cc < C && !prohibidas[rr * C + cc]) {
        prohibidas[rr * C + cc] = true;
        cuantasProhibidas++;
      }
    }
  }

  // Tablero chico y muy minado: si dejando el 3x3 libre no entran todas las
  // minas, la zona segura se achica a la celda tocada. Sin esto el while de
  // abajo no terminaria.
  if (R * C - cuantasProhibidas < M) {
    for (const k in prohibidas) delete prohibidas[k];
    prohibidas[rSeguro * C + cSeguro] = true;
  }

  let puestas = 0;
  while (puestas < M) {
    const r = Math.floor(Math.random() * R);
    const c = Math.floor(Math.random() * C);
    if (prohibidas[r * C + c]) continue;
    if (m[r][c] === -1) continue;
    m[r][c] = -1;
    puestas++;
  }

  for (let r = 0; r < R; r++) {
    for (let c = 0; c < C; c++) {
      if (m[r][c] === -1) continue;
      let n = 0;
      for (let dr = -1; dr <= 1; dr++) {
        for (let dc = -1; dc <= 1; dc++) {
          if (dr === 0 && dc === 0) continue;
          const rr = r + dr, cc = c + dc;
          if (rr >= 0 && rr < R && cc >= 0 && cc < C && m[rr][cc] === -1) n++;
        }
      }
      m[r][c] = n;
    }
  }
  return m;
}

let inicio = isoDe(p.Inicio, new Date().toISOString());

if (!existe) {
  mensaje = "Partida no encontrada";
} else if (estado !== "En curso") {
  mensaje = "La partida ya termino";
} else if (!dentro(fila, col)) {
  mensaje = "Coordenada fuera del tablero";
} else if (accion === "bandera") {
  // Poner banderas antes del primer click es legal y no siembra nada.
  if (revelado[fila][col] === 0) {
    banderas[fila][col] = banderas[fila][col] ? 0 : 1;
  }
} else {
  if (banderas[fila][col] === 1) {
    mensaje = "Esa celda tiene bandera";
  } else if (revelado[fila][col] === 1) {
    mensaje = "Ya estaba revelada";
  } else {
    // Primera jugada valida: recien aca sabemos que celda hay que dejar libre.
    if (!sembrado) {
      matriz = sembrar(fila, col);
      sembrado = true;
      // El reloj arranca con la primera jugada, no al abrir la partida.
      inicio = new Date().toISOString();
    }

    if (matriz[fila][col] === -1) {
      revelado[fila][col] = 1;
      estado = "Perdida";
      mensaje = "Pisaste una mina";
    } else {
      // Flood-fill iterativo: al abrir un 0 se abre todo el bloque vacio
      const pila = [[fila, col]];
      while (pila.length > 0) {
        const par = pila.pop();
        const r = par[0], c = par[1];
        if (!dentro(r, c)) continue;
        if (revelado[r][c] === 1 || banderas[r][c] === 1) continue;
        revelado[r][c] = 1;
        if (matriz[r][c] === 0) {
          for (let dr = -1; dr <= 1; dr++) {
            for (let dc = -1; dc <= 1; dc++) {
              if (dr === 0 && dc === 0) continue;
              pila.push([r + dr, c + dc]);
            }
          }
        }
      }
    }
  }
}

let reveladas = 0;
for (let r = 0; r < R; r++) {
  for (let c = 0; c < C; c++) if (revelado[r] && revelado[r][c] === 1) reveladas++;
}

const seguras = R * C - M;
if (existe && estado === "En curso" && sembrado && reveladas >= seguras) {
  estado = "Ganada";
  mensaje = "Campo despejado";
}

const ahora = Date.now();
const segundos = sembrado
  ? Math.max(0, Math.round((ahora - Date.parse(inicio)) / 1000))
  : 0;

const terminada = existe && estado !== "En curso";
let puntaje = 0;
if (terminada) {
  puntaje = reveladas * 10;
  if (estado === "Ganada") {
    puntaje += M * 50 + Math.max(0, 600 - segundos) * 2;
  }
}

// Vista enmascarada: '?' oculta | 'F' bandera | '0'-'8' revelada | '*' mina (solo al perder)
const vista = [];
for (let r = 0; r < R; r++) {
  let s = "";
  for (let c = 0; c < C; c++) {
    if (sembrado && estado === "Perdida" && matriz[r][c] === -1) s += "*";
    else if (sembrado && revelado[r][c] === 1) s += String(matriz[r][c]);
    else if (banderas[r][c] === 1) s += "F";
    else s += "?";
  }
  vista.push(s);
}

const motor = String(p.Motor || "desconocido");
const sdk = String(p.SDK || "desconocido");

const respuesta = {
  ok: existe,
  codigo: String(p.Codigo || ""),
  filas: R,
  columnas: C,
  minas: M,
  estado: estado,
  celdasReveladas: reveladas,
  puntaje: puntaje,
  segundos: segundos,
  terminada: terminada,
  sembrado: sembrado,
  mensaje: mensaje,
  motor: motor,
  sdk: sdk,
  vista: vista
};

return {
  rowId: String(p.ID || ""),
  codigo: String(p.Codigo || ""),
  alias: String(p.Alias || "anonimo"),
  jugadorId: String(p.Jugador_Externo_Id || p["Jugador Externo Id"] || "0"),
  motor: motor,
  sdk: sdk,
  dificultad: String(p.Dificultad || "facil"),
  estado: estado,
  matriz: JSON.stringify(matriz),
  revelado: JSON.stringify(revelado),
  banderas: JSON.stringify(banderas),
  inicio: inicio,
  celdasReveladas: String(reveladas),
  puntaje: String(puntaje),
  segundos: String(segundos),
  fin: new Date(ahora).toISOString(),
  terminada: terminada ? "si" : "no",
  respuesta: JSON.stringify(respuesta)
};

Nodo 4 - Update Rows, llamado actualizar, sobre Buscaminas Partidas, con row id {{context.steps.resolver.rowId}}:

Columna

Valor

Estado

{{context.steps.resolver.estado}}

Inicio

{{context.steps.resolver.inicio}}

Matriz

{{context.steps.resolver.matriz}}

Revelado

{{context.steps.resolver.revelado}}

Banderas

{{context.steps.resolver.banderas}}

Celdas Reveladas

{{context.steps.resolver.celdasReveladas}}

Nodo 5 - If/Else, llamado termino. Una regla: {{context.steps.resolver.terminada}} == si.

Nodo 6 - Update Rows en la rama verdadera, llamado cerrar, misma tabla y row id:

Columna

Valor

Fin

{{context.steps.resolver.fin}}

Puntaje

{{context.steps.resolver.puntaje}}

Segundos

{{context.steps.resolver.segundos}}

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

Columna

Valor

Record

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

Alias

{{context.steps.resolver.alias}}

Jugador Externo Id

{{context.steps.resolver.jugadorId}}

Points

{{context.steps.resolver.puntaje}}

Dificultad

{{context.steps.resolver.dificultad}}

Segundos

{{context.steps.resolver.segundos}}

Juego

Buscaminas

Motor

{{context.steps.resolver.motor}}

SDK

{{context.steps.resolver.sdk}}

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

Publica y enlaza al endpoint Buscaminas: Jugar.

image.png

¿Por qué el puntaje solo existe cuando la partida termina? Porque un puntaje en curso es un número que el cliente podría mostrar y el jugador podría perseguir a mitad de partida. Calcularlo una sola vez, al final, en el servidor, lo mantiene afuera de todas las respuestas intermedias.


Paso 5 - La Automation `Leaderboard`

Cuatro nodos otra vez:

Trigger de endpoint  →  Query rows  →  Script: formatear  →  Response

Nodo 1 - Trigger de endpoint, apuntado a Buscaminas: Leaderboard. Payload de prueba { "limite": 10 }.

Nodo 2 - Query Rows, llamado consultar, sobre Demos Leaderboard. Filtro: Juego eq Buscaminas. Ordenar por Points descendente. Límite 50.

Nodo 3 - Script, llamado formatear. Dos entradas: filas desde {{context.steps.consultar.rows}} y payload desde {{context.request.body}}. Una salida: respuesta.

// Toma las filas crudas del leaderboard y devuelve un top listo para pintar.
//
// La clave de deduplicacion es alias + motor, no solo alias: la gracia de este
// marcador es ver al mismo jugador entrando desde Roblox, desde Unity y desde
// el navegador, y comparar. Si dedujeramos solo por alias, la mejor partida
// taparia a las otras dos.
//
// El cuerpo acepta { limite, motor }: pasando `motor` se filtra a un solo
// front-end, util para las demos individuales de cada SDK.

const body = payload || {};
const limite = Math.max(1, Math.min(25, Number(body.limite) || 10));
const filtroMotor = body.motor ? String(body.motor).toLowerCase() : null;

const rows = Array.isArray(filas) ? filas : [];

const mejorPorClave = new Map();
let contados = 0;

for (const r of rows) {
  const motor = String(r.Motor || "desconocido").toLowerCase();
  if (filtroMotor && motor !== filtroMotor) continue;
  contados++;

  const alias = String(r.Alias || r.Jugador_Externo_Id || "anonimo");
  const clave = alias + "|" + motor;
  const puntos = Number(r.Points) || 0;

  const previo = mejorPorClave.get(clave);
  if (!previo || puntos > previo.puntos) {
    mejorPorClave.set(clave, {
      alias: alias,
      puntos: puntos,
      motor: motor,
      sdk: String(r.SDK || "desconocido"),
      dificultad: String(r.Dificultad || ""),
      segundos: Number(r.Segundos) || 0,
      jugadorId: String(r.Jugador_Externo_Id || r["Jugador Externo Id"] || "0")
    });
  }
}

const top = Array.from(mejorPorClave.values())
  .sort((a, b) => b.puntos - a.puntos)
  .slice(0, limite)
  .map((e, i) => Object.assign({ posicion: i + 1 }, e));

// Cuantas partidas aporto cada motor, para el pie del marcador.
const porMotor = {};
for (const r of rows) {
  const m = String(r.Motor || "desconocido").toLowerCase();
  if (filtroMotor && m !== filtroMotor) continue;
  porMotor[m] = (porMotor[m] || 0) + 1;
}

return {
  respuesta: JSON.stringify({
    ok: true,
    total: contados,
    porMotor: porMotor,
    top: top
  })
};

Nodo 4 - Response, plantilla del cuerpo {{context.steps.formatear.respuesta}}.

Publica, enlaza, y el backend está listo.

Fíjate en el nombre del campo. Cada entrada de `top` trae `puntos`, no `puntaje`. `puntaje` es el puntaje de la partida dentro de `Jugar`; `puntos` es el de la entrada del leaderboard. Leer el equivocado te da `nil` en Lua, en silencio.


La Automation `Validar Resultado`

Hasta acá el juego llama a Jugar en cada jugada, y Praxsuite es la autoridad en todo momento. Las demos publicadas de este juego — Roblox, Unity, la web y el plugin de Minecraft — dieron un paso más por latencia: resuelven la jugada en el cliente y llaman a Praxsuite solo dos veces por partida, al abrirla y al cerrarla. Esta automation es lo único que hace que ese atajo siga siendo seguro.

No vuelve a jugar la partida: compara lo que el cliente reporta contra las dimensiones y la cantidad de minas que Praxsuite generó al abrirla, y verifica que el resultado sea internamente consistente con eso. Si algo no cierra, no escribe nada — ni el cierre de la partida ni el leaderboard.

¿La necesito si me quedo con el diseño por clic? No, y las dos conviven sin problema en el mismo workspace. Pero en cuanto muevas el motor al cliente pasa a ser obligatoria: sin ella, "local" significa "el cliente decide y Praxsuite anota".

Son diez nodos, con una bifurcación:

Trigger → Buscar partida → Top 3 actual → Script: validar → ¿Es valido?
   ├── true  → Cerrar partida → Registrar en leaderboard → Vault → Publicar al bus → Responder
   └── false → Responder

Nodo 1 — Endpoint Trigger, apuntado a Buscaminas: Validar Resultado. Payload de prueba, con un tablero de 2×2 que se puede verificar a mano:

{
  "codigo": "2K2Z5B64",
  "estado": "Ganada",
  "matriz": [[0,0],[0,-1]],
  "revelado": [[1,1],[1,0]],
  "banderas": [[0,0],[0,1]],
  "puntaje": 0,
  "segundos": 12,
  "alias": "mirko",
  "jugadorId": "1",
  "motor": "unity",
  "sdk": "csharp"
}

Nodo 2 — Query Rows, llamado buscar, sobre Buscaminas Partidas. Filtro Codigo eq {{context.request.body.codigo}}, límite 1. Esta fila es la única fuente de verdad sobre cuántas minas tenía la partida.

Nodo 3 — Query Rows, llamado top3, sobre Demos Leaderboard. Filtro Juego eq Buscaminas, orden Points descendente, límite 3. Se consulta antes de insertar esta partida, para saber contra qué umbral compararla.

Nodo 4 — Script, llamado validar, JavaScript. Tres entradas: payload desde {{context.request.body}}, partida desde {{context.steps.buscar.row}} y top3rows desde {{context.steps.top3.rows}}.

// -- Buscaminas: validar resultado final ---------------------------------------
// Entradas:
//   payload   <- {{context.request.body}}        resultado final del motor local
//   partida   <- {{context.steps.buscar.row}}     fila de 'Buscaminas Partidas'
//   top3rows  <- {{context.steps.top3.rows}}      top 3 actual, ANTES de insertar esta partida
//
// El motor local (Roblox/Unity/web/Minecraft) ya jugo la partida entera sin red
// por click. Esto no la vuelve a jugar: usa filas/columnas/minas que Praxsuite
// genero en 'Buscaminas: Nueva Partida' -nunca lo que manda el cliente- como el
// unico punto de comparacion, y verifica que el resultado reportado sea
// internamente consistente con eso. Si algo no cierra no se escribe nada: ni el
// cierre de la partida ni el leaderboard.

const body = payload || {};
const p = partida || {};

function parseJ(v, fallback) {
  if (v === null || v === undefined) return fallback;
  if (typeof v === "string") {
    try { return JSON.parse(v); } catch (e) { return fallback; }
  }
  return v;
}

const R = Number(p.Filas) || 0;
const C = Number(p.Columnas) || 0;
const M = Number(p.Minas) || 0;
const rowId = String(p.ID || "");
const codigo = String(p.Codigo || "");
const estadoActual = (p.Estado && p.Estado.Name) ? p.Estado.Name : String(p.Estado || "En curso");

const motivos = [];
function fallar(m) { motivos.push(m); }

const existe = Boolean(codigo) && R > 0 && C > 0;
if (!existe) fallar("partida_no_encontrada");
if (existe && estadoActual !== "En curso") fallar("partida_ya_cerrada");

const estadoReportado = String(body.estado || "").trim();
if (estadoReportado !== "Ganada" && estadoReportado !== "Perdida") fallar("estado_invalido");

const matriz = parseJ(body.matriz, null);
const revelado = parseJ(body.revelado, null);
const banderas = parseJ(body.banderas, null);

function formaValida(g) {
  return Array.isArray(g) && g.length === R && g.every(function (fila) {
    return Array.isArray(fila) && fila.length === C;
  });
}

if (existe && motivos.length === 0) {
  if (!formaValida(matriz) || !formaValida(revelado) || !formaValida(banderas)) {
    fallar("forma_de_tablero_invalida");
  }
}

let minasReportadas = 0;
let reveladas = 0;
let minaRevelada = false;
let numerosConsistentes = true;

if (motivos.length === 0) {
  for (let r = 0; r < R; r++) {
    for (let c = 0; c < C; c++) {
      if (matriz[r][c] === -1) minasReportadas++;
    }
  }
  for (let r = 0; r < R; r++) {
    for (let c = 0; c < C; c++) {
      if (matriz[r][c] !== -1) {
        let n = 0;
        for (let dr = -1; dr <= 1; dr++) {
          for (let dc = -1; dc <= 1; dc++) {
            if (dr === 0 && dc === 0) continue;
            const rr = r + dr, cc = c + dc;
            if (rr >= 0 && rr < R && cc >= 0 && cc < C && matriz[rr][cc] === -1) n++;
          }
        }
        if (matriz[r][c] !== n) numerosConsistentes = false;
      }
      if (revelado[r][c] === 1) {
        reveladas++;
        if (matriz[r][c] === -1) minaRevelada = true;
      }
    }
  }

  if (minasReportadas !== M) fallar("cantidad_de_minas_no_coincide");
  if (!numerosConsistentes) fallar("numeros_de_la_matriz_inconsistentes");

  if (estadoReportado === "Ganada") {
    if (minaRevelada) fallar("gano_pero_hay_una_mina_revelada");
    if (reveladas !== R * C - M) fallar("gano_pero_no_revelo_todas_las_celdas_seguras");
  } else if (estadoReportado === "Perdida") {
    if (!minaRevelada) fallar("perdio_pero_ninguna_mina_esta_revelada");
  }
}

const segundos = Math.max(0, Math.min(36000, Number(body.segundos) || 0));

let puntajeEsperado = 0;
if (motivos.length === 0) {
  puntajeEsperado = reveladas * 10;
  if (estadoReportado === "Ganada") {
    puntajeEsperado += M * 50 + Math.max(0, 600 - segundos) * 2;
  }
  if (Number(body.puntaje) !== puntajeEsperado) fallar("puntaje_no_coincide");
}

const valido = motivos.length === 0;

const alias = String(body.alias || p.Alias || "anonimo");
const jugadorId = String(body.jugadorId || p.Jugador_Externo_Id || p["Jugador Externo Id"] || "0");
const motor = String(body.motor || p.Motor || "desconocido");
const sdk = String(body.sdk || p.SDK || "desconocido");
const dificultad = String(p.Dificultad || "facil");

// Umbral de top 3 ANTES de insertar esta partida: si hay menos de 3 filas
// todavia, cualquier puntaje valido entra.
const top3 = Array.isArray(top3rows) ? top3rows : [];
const umbral = top3.length >= 3 ? (Number(top3[top3.length - 1].Points) || 0) : -1;
const esTop3 = valido && Number(body.puntaje) > umbral;

const fin = new Date().toISOString();

const respuesta = {
  ok: valido,
  codigo: codigo,
  estado: valido ? estadoReportado : estadoActual,
  puntaje: valido ? Number(body.puntaje) : 0,
  motivos: motivos,
  mensaje: valido ? "Resultado validado" : ("Resultado rechazado: " + motivos.join(", "))
};

return {
  valido: valido ? "si" : "no",
  rowId: rowId,
  codigo: codigo,
  alias: alias,
  jugadorId: jugadorId,
  motor: motor,
  sdk: sdk,
  dificultad: dificultad,
  estado: estadoReportado,
  matriz: JSON.stringify(matriz),
  revelado: JSON.stringify(revelado),
  banderas: JSON.stringify(banderas),
  celdasReveladas: String(reveladas),
  puntaje: String(valido ? Number(body.puntaje) : 0),
  segundos: String(segundos),
  fin: fin,
  esTop3: esTop3 ? "si" : "no",
  respuesta: JSON.stringify(respuesta)
};

Declara las dieciocho salidas: valido, rowId, codigo, alias, jugadorId, motor, sdk, dificultad, estado, matriz, revelado, banderas, celdasReveladas, puntaje, segundos, fin, esTop3 y respuesta.

Nodo 5 — If/Else, llamado esvalido. Una regla: {{context.steps.validar.valido}} == si.

Nodo 6 — Update Rows (rama true), llamado cerrar, sobre Buscaminas Partidas, con rowId = {{context.steps.validar.rowId}}:

Columna

Valor

Estado

{{context.steps.validar.estado}}

Matriz

{{context.steps.validar.matriz}}

Revelado

{{context.steps.validar.revelado}}

Banderas

{{context.steps.validar.banderas}}

Celdas Reveladas

{{context.steps.validar.celdasReveladas}}

Puntaje

{{context.steps.validar.puntaje}}

Segundos

{{context.steps.validar.segundos}}

Fin

{{context.steps.validar.fin}}

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

Columna

Valor

Record

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

Alias

{{context.steps.validar.alias}}

Jugador Externo Id

{{context.steps.validar.jugadorId}}

Points

{{context.steps.validar.puntaje}}

Dificultad

{{context.steps.validar.dificultad}}

Segundos

{{context.steps.validar.segundos}}

Juego

Buscaminas

Motor

{{context.steps.validar.motor}}

SDK

{{context.steps.validar.sdk}}

Nodo 8 — Vault, llamado vaultbus. Alias busKey, apuntado al secreto donde guardaste una API key con permiso de publicar al Event Bus.

Nodo 9 — HTTP Request, llamado publicartop3. POST a la ruta de publicación del bus de tu workspace:

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

Cabeceras x-api-key: {{vault.busKey}} y Content-Type: application/json. Cuerpo:

{"event":"game_completed","payload":{"alias":"{{context.steps.validar.alias}}","puntaje":{{context.steps.validar.puntaje}},"estado":"{{context.steps.validar.estado}}","dificultad":"{{context.steps.validar.dificultad}}","motor":"{{context.steps.validar.motor}}","codigo":"{{context.steps.validar.codigo}}","esTop3":"{{context.steps.validar.esTop3}}"}}

Activa `continueOnError` en este nodo. Es lo que hace que cerrar la partida y escribir el leaderboard nunca dependan de que el bus esté disponible: el aviso es estrictamente un extra sobre una escritura que ya ocurrió.

El evento se llama `game_completed`, y se publica en toda partida validada — no solo en las del podio. Quién entró al top viaja como el campo `esTop3` del payload, no como el nombre del evento. Un suscriptor que quiera anunciar solo los récords filtra por ese campo; uno que quiera mostrar toda la actividad no filtra nada. Si vienes de una versión anterior de estas guías que hablaba de un evento `topscore`, ese es el nombre que hay que corregir del lado del suscriptor.*

Nodo 10 — Response. Estado 200, application/json, cuerpo {{context.steps.validar.respuesta}}. Las dos ramas terminan acá: la inválida responde con ok: false y la lista de motivos, sin haber escrito nada.

Publica la automation y enlázala al endpoint Buscaminas: Validar Resultado.

¿Qué previene esto en realidad? No a un cliente que mienta en todo — un tramposo decidido igual podría inventar un tablero internamente consistente. Lo que sí previene es exactamente el modo de falla de "confiar en el cliente": un error del motor local, o un binario modificado a propósito, reportando un tablero imposible (más minas de las que la partida abrió, números que no coinciden con el trazado, un puntaje que la aritmética no sostiene) y que eso entre en silencio a un leaderboard donde aparecen las partidas reales de todos los demás.


Punto de Control - Prueba el Backend Antes de Abrir Unity

Corre cada Automation desde el portal con su payload de prueba. Nueva Partida debería devolver un codigo y una vista de ocho cadenas de ocho ? cada una. Copia ese codigo al payload de prueba de Jugar y córrelo: el primer revelado abre un hueco, porque las minas se sembraron alrededor de tu clic.

{ "ok": true, "codigo": "3KQQ9KRN", "filas": 8, "columnas": 8, "minas": 10,
  "estado": "En curso", "celdasReveladas": 37, "sembrado": true, "mensaje": "",
  "vista": ["01??????", "01??????", "02?212??", "01?101??",
            "011102??", "000002??", "011102??", "01?101??"] }

Las coordenadas empiezan en cero. `fila` y `columna` van de `0` a `R-1`. Manda `fila = 8` en un tablero de 8x8 y obtienes `"Coordenada fuera del tablero"`. Las colecciones de C# también empiezan en 0, así que Unity pasa sus índices de bucle tal cual - uno de los pocos lugares donde este cliente tiene menos para equivocar que uno en Luau.


Parte 2 - El Cliente de Unity

Paso 6 - Instalar el SDK

Repositorio: https://github.com/TesseractSoftwares/Praxsuite-SDK-Unity

Abre Package Manager de Unity y agrega el paquete desde Git URL:

https://github.com/TesseractSoftwares/Praxsuite-SDK-Unity.git

Unity lo escribe en Packages/manifest.json con esta forma:

{
  "dependencies": {
    "com.tesseractsoftwares.praxsuite": "https://github.com/TesseractSoftwares/Praxsuite-SDK-Unity.git"
  }
}

Cuando Unity termine de resolver paquetes, debes poder escribir using Praxsuite; desde un script dentro de Assets/.

¿Por qué un paquete y no scripts copiados?

El SDK incluye verificaciones de build, almacenamiento de sesión, reintentos de request, parsing JSON y módulos tipados. Copiar un archivo suelto dentro de Assets/ pierde esas protecciones. Un paquete mantiene el SDK como una sola pieza y permite actualizarlo limpiamente desde Unity.


Paso 7 - Configurar Praxsuite

El SDK puede leer un settings asset, pero este caso de uso lo configura en código porque la muestra necesita un token store personalizado y desactiva la carga automática de esquema. Crea Assets/Scripts/Buscaminas/PraxsuiteMinesweeperApi.cs y empieza con esta estructura:

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Praxsuite;
using UnityEngine;

public class PraxsuiteMinesweeperApi : MonoBehaviour
{
    private const string BaseUrl = "https://gateway.praxsuite.com";
    private const string WorkspaceId = "tu-workspace-uuid";

    // Los tres UUID de endpoint que copiaste en la Parte 1, Paso 2.
    private const string NewGameEndpoint = "tu-uuid-del-endpoint-nueva-partida";
    private const string PlayEndpoint = "tu-uuid-del-endpoint-jugar";
    private const string LeaderboardEndpoint = "tu-uuid-del-endpoint-leaderboard";

    private bool configured;

    private void EnsureConfigured()
    {
        if (configured && Prax.IsConfigured)
            return;

        Prax.Configure(new PraxsuiteOptions
        {
            BaseUrl = BaseUrl,
            WorkspaceId = WorkspaceId,
            AutoFetchSchema = false,
            PersistSession = true
        });

        configured = true;
    }
}

Esto compila sin publishable key porque el SDK puede obtenerla desde /auth/config.

Regla de oro: Nunca pegues una secret key - la que lleva el prefijo `sk` seguido de `live` - en un cliente Unity. El SDK y el build guard están diseñados para impedir que eso se publique, pero igual debes tratar cualquier secreto commiteado como comprometido.


Paso 8 - Agregar login y registro

Agrega el estado del jugador y los métodos de autenticación al mismo componente API:

public string CurrentAlias { get; private set; }
public string CurrentPlayerId { get; private set; }

private void Awake()
{
    EnsureConfigured();
    CurrentAlias = PlayerPrefs.GetString("buscaminas.alias", "unity-player");
    CurrentPlayerId = PlayerPrefs.GetString("buscaminas.playerId", SystemInfo.deviceUniqueIdentifier);

    var user = Prax.Auth.CurrentUser;
    if (user != null)
        ApplyUser(user);
}

public void Login(string email, string password, Action<PraxAuthResult> onSuccess, Action<string> onError)
{
    EnsureConfigured();
    StartCoroutine(RunTask(() => Prax.Auth.LoginAsync(email, password), result =>
    {
        if (result.User != null)
            ApplyUser(result.User);

        onSuccess?.Invoke(result);
    }, onError));
}

public void Register(string email, string password, string alias, Action<PraxAuthResult> onSuccess, Action<string> onError)
{
    EnsureConfigured();
    StartCoroutine(RunTask(() => Prax.Auth.RegisterAsync(email, password, alias, alias, "Unity"), result =>
    {
        if (result.User != null)
            ApplyUser(result.User);

        onSuccess?.Invoke(result);
    }, onError));
}

private void ApplyUser(PraxUser user)
{
    CurrentAlias = FirstNonEmpty(user.DisplayName, user.Username, user.FirstName, user.Email, CurrentAlias);
    CurrentPlayerId = FirstNonEmpty(user.Id, CurrentPlayerId);
    PlayerPrefs.SetString("buscaminas.alias", CurrentAlias);
    PlayerPrefs.SetString("buscaminas.playerId", CurrentPlayerId);
    PlayerPrefs.Save();
}

Prax.Auth.LoginAsync y Prax.Auth.RegisterAsync devuelven Task<PraxAuthResult>, así que el wrapper usa un puente a coroutine. Agrega ese puente debajo:

private static System.Collections.IEnumerator RunTask<T>(
    Func<Task<T>> taskFactory,
    Action<T> onSuccess,
    Action<string> onError)
{
    Task<T> task;
    try
    {
        task = taskFactory();
    }
    catch (Exception ex)
    {
        onError?.Invoke(ToMessage(ex));
        yield break;
    }

    while (!task.IsCompleted)
        yield return null;

    if (task.IsFaulted)
    {
        onError?.Invoke(ToMessage(task.Exception));
        yield break;
    }

    if (task.IsCanceled)
    {
        onError?.Invoke("Operation cancelled.");
        yield break;
    }

    onSuccess?.Invoke(task.Result);
}

Después de este paso, un botón de Unity puede llamar Login() o Register() sin convertir el método en async void.

¿Por qué login por el SDK y no por un endpoint propio?

El workspace puede contener Automations envoltorio de login para clientes que no pueden guardar con seguridad una publishable key. Unity puede usar el SDK directamente. El SDK guarda la sesión, la refresca y expone Prax.Auth.CurrentUserId, que es el valor que el servidor puede confiar como claim subject del JSON Web Token (JWT).


Paso 9 - Llamar los endpoints de Buscaminas

El SDK actual expone Prax.Endpoints.CallAsync, que es el camino correcto para reglas de juego con autoridad del servidor. Agrega estos métodos a PraxsuiteMinesweeperApi:

public void StartGame(string difficulty, Action<MinesweeperGameState> onSuccess, Action<string> onError)
{
    EnsureConfigured();

    var body = new Dictionary<string, object>
    {
        { "alias", CurrentAlias },
        { "jugadorId", CurrentPlayerId },
        { "dificultad", difficulty },
        { "motor", "unity" },
        { "sdk", "csharp" }
    };

    StartCoroutine(RunTask(
        () => Prax.Endpoints.CallAsync(NewGameEndpoint, body),
        raw => onSuccess?.Invoke(MapGameState(raw)),
        onError));
}

public void Play(string code, string action, int row, int column, Action<MinesweeperGameState> onSuccess, Action<string> onError)
{
    EnsureConfigured();

    var body = new Dictionary<string, object>
    {
        { "codigo", code },
        { "accion", action },
        { "fila", row },
        { "columna", column }
    };

    StartCoroutine(RunTask(
        () => Prax.Endpoints.CallAsync(PlayEndpoint, body),
        raw => onSuccess?.Invoke(MapGameState(raw)),
        onError));
}

public void LoadLeaderboard(Action<MinesweeperLeaderboardResponse> onSuccess, Action<string> onError)
{
    EnsureConfigured();

    StartCoroutine(RunTask(
        () => Prax.Endpoints.CallAsync(LeaderboardEndpoint, new Dictionary<string, object> { { "limite", 10 } }),
        raw => onSuccess?.Invoke(MapLeaderboard(raw)),
        onError));
}

Observa que LoadLeaderboard no envía motor. Es intencional: el tablero debe mostrar todos los tipos de entrada, no sólo Unity.


Paso 10 - Mapear las respuestas

El endpoint devuelve diccionarios. Crea clases serializables simples para las formas que necesita Unity:

[Serializable]
public class MinesweeperGameState
{
    public bool ok;
    public string codigo;
    public int filas;
    public int columnas;
    public int minas;
    public string dificultad;
    public string estado;
    public int celdasReveladas;
    public int puntaje;
    public int segundos;
    public bool terminada;
    public bool sembrado;
    public string mensaje;
    public string motor;
    public string sdk;
    public List<string> vista = new List<string>();
    public string inicio;
}

[Serializable]
public class MinesweeperLeaderboardResponse
{
    public bool ok;
    public int total;
    public Dictionary<string, int> porMotor = new Dictionary<string, int>();
    public List<MinesweeperLeaderboardEntry> top = new List<MinesweeperLeaderboardEntry>();
}

[Serializable]
public class MinesweeperLeaderboardEntry
{
    public int posicion;
    public string alias;
    public int puntos;
    public string motor;
    public string sdk;
    public string dificultad;
    public int segundos;
    public string jugadorId;
}

Luego convierte valores de forma defensiva. Los números JSON del Gateway pueden llegar como long o double, según el camino de parsing, así que no los castees directamente:

private static int ToInt(object value)
{
    if (value == null) return 0;
    if (value is long l) return (int)l;
    if (value is double d) return (int)Math.Round(d);
    return int.TryParse(Convert.ToString(value), out var parsed) ? parsed : 0;
}

Con estas clases, el resto del código Unity trabaja con campos normales en vez de diccionarios crudos.


Paso 11 - Crear los objetos visibles de la escena

Crea una escena con cámara, luz direccional, Canvas y tres paneles: autenticación/dificultad a la izquierda, tablero al centro, leaderboard a la derecha. La decisión importante es que todos los controles visibles existen como GameObjects en la escena. Lo único creado en runtime es la grilla de celdas individuales, porque su tamaño cambia con la dificultad.

La implementación real usa un menú de editor llamado Praxsuite/Buscaminas/Create Demo Scene. Crea:

Buscaminas Game
  PraxsuiteMinesweeperApi
  MinesweeperGameController

Buscaminas Canvas
  Buscaminas Root
    Buscaminas Shell
      Buscaminas Left Panel
        Buscaminas Alias Field
        Buscaminas Email Field
        Buscaminas Password Field
        Buscaminas Login Button
        Buscaminas Register Button
        Buscaminas Easy Button
        Buscaminas Medium Button
        Buscaminas Hard Button
      Buscaminas Board Area
        Buscaminas Board Grid
      Buscaminas Leaderboard Panel
        Buscaminas Refresh Leaderboard Button
        Buscaminas Leaderboard Text

!imagen2026-08-27112436657.png

¿Por qué crear los controles en la escena y no en el script de juego?

Los GameObjects de escena son más fáciles de inspeccionar, enlazar, rediseñar y depurar. Si toda la interfaz se crea en Start(), una referencia faltante es invisible hasta Play mode. Aquí el controller tiene campos serializados, el setup de editor los enlaza, y el runtime sólo administra la parte que cambia de verdad: las celdas.


Paso 12 - Conectar el controller

Crea MinesweeperGameController.cs. Sus campos reflejan los objetos de la escena:

public class MinesweeperGameController : MonoBehaviour
{
    [Header("Praxsuite")]
    [SerializeField] private PraxsuiteMinesweeperApi api;

    [Header("Auth")]
    [SerializeField] private InputField emailInput;
    [SerializeField] private InputField passwordInput;
    [SerializeField] private InputField aliasInput;
    [SerializeField] private Text sessionText;

    [Header("Game")]
    [SerializeField] private Text statusText;
    [SerializeField] private Text scoreText;
    [SerializeField] private GridLayoutGroup boardGrid;
    [SerializeField] private RectTransform boardRect;
    [SerializeField] private Button easyButton;
    [SerializeField] private Button mediumButton;
    [SerializeField] private Button hardButton;

    [Header("Leaderboard")]
    [SerializeField] private Button refreshLeaderboardButton;
    [SerializeField] private Text leaderboardText;
}

En Start(), el controller prepara el EventSystem, valida referencias, oculta la contraseña, dibuja un tablero vacío 8 x 8 y carga el leaderboard:

private void Start()
{
    EnsureEventSystem();

    if (!ResolveSceneReferences())
        return;

    passwordInput.contentType = InputField.ContentType.Password;
    passwordInput.ForceLabelUpdate();

    api.UseGuest(aliasInput.text);
    RefreshSessionText();
    RenderBoard(new MinesweeperGameState
    {
        filas = 8,
        columnas = 8,
        minas = 10,
        estado = "En curso",
        vista = HiddenBoard(8, 8)
    });
    LoadLeaderboard();
}

Ahora debes poder presionar Play y ver un tablero vacío antes de que termine cualquier llamada al servidor.


Paso 13 - Soportar el nuevo Input System

Si tu proyecto usa el paquete Input System, un StandaloneInputModule legacy produce este error de Unity:

InvalidOperationException: You are trying to read Input using the UnityEngine.Input class, but you have switched active Input handling to Input System package in Player Settings.

Corrige el EventSystem en código:

private static void EnsureEventSystem()
{
    var eventSystem = UnityEngine.Object.FindAnyObjectByType<EventSystem>();
    if (eventSystem == null)
        eventSystem = new GameObject("EventSystem", typeof(EventSystem)).GetComponent<EventSystem>();

#if ENABLE_INPUT_SYSTEM
    var legacyInput = eventSystem.GetComponent<StandaloneInputModule>();
    if (legacyInput != null)
        UnityEngine.Object.Destroy(legacyInput);

    if (eventSystem.GetComponent<InputSystemUIInputModule>() == null)
        eventSystem.gameObject.AddComponent<InputSystemUIInputModule>();
#else
    if (eventSystem.GetComponent<StandaloneInputModule>() == null)
        eventSystem.gameObject.AddComponent<StandaloneInputModule>();
#endif
}

Esto mantiene la misma escena funcionando en proyectos Unity con input antiguo o nuevo.


Paso 14 - Dibujar el tablero

El servidor devuelve la vista pública. Unity crea un botón por carácter:

private void RenderBoard(MinesweeperGameState state)
{
    foreach (Transform child in boardGrid.transform)
        Destroy(child.gameObject);

    boardGrid.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
    boardGrid.constraintCount = Math.Max(1, state.columnas);
    boardGrid.cellSize = CellSizeFor(state.columnas);

    for (var r = 0; r < state.filas; r++)
    {
        var row = state.vista != null && r < state.vista.Count ? state.vista[r] : string.Empty;
        for (var c = 0; c < state.columnas; c++)
        {
            var ch = c < row.Length ? row[c] : '?';
            var cell = CreateCell(boardGrid.transform);
            cell.Bind(this, r, c, ch);
        }
    }
}

La vista de celda transforma F en una bandera pequeña hecha con UI Images, no en una letra:

private void EnsureFlagIcon()
{
    if (flagIcon != null)
        return;

    var root = new GameObject("Flag Icon", typeof(RectTransform));
    root.transform.SetParent(transform, false);
    flagIcon = root.GetComponent<RectTransform>();
    flagIcon.sizeDelta = new Vector2(26f, 28f);

    flagPole = CreateFlagPart("Pole", flagIcon, Color.white, new Vector2(3f, 24f), new Vector2(-5f, 0f));
    flagCloth = CreateFlagPart("Cloth", flagIcon, new Color(0.90f, 0.10f, 0.15f), new Vector2(17f, 12f), new Vector2(2f, 6f));
    flagBase = CreateFlagPart("Base", flagIcon, Color.white, new Vector2(17f, 3f), new Vector2(-3f, -11f));
    flagIcon.gameObject.SetActive(false);
}

!Game.png


Paso 15 - Enviar jugadas y elegir dificultad

Los botones de dificultad llaman presets del servidor:

public void StartEasyGame() => StartNewGame("facil");
public void StartMediumGame() => StartNewGame("medio");
public void StartHardGame() => StartNewGame("dificil");

private void StartNewGame(string difficulty)
{
    api.UseGuest(aliasInput.text);
    SetBusy(true, "Creating game...");
    api.StartGame(difficulty, state =>
    {
        SetBusy(false);
        ApplyGame(state);
    }, ShowError);
}

Cada celda envía revelar o bandera. Clic derecho marca bandera; clic izquierdo revela:

public void OnPointerClick(PointerEventData eventData)
{
    var flag = eventData.button == PointerEventData.InputButton.Right;
    controller.CellClicked(row, column, flag);
}

public void CellClicked(int row, int column, bool flag)
{
    if (busy || game == null || game.terminada)
        return;

    SetBusy(true, flag ? "Marking flag..." : "Revealing cell...");
    api.Play(game.codigo, flag ? "bandera" : "revelar", row, column, state =>
    {
        SetBusy(false);
        ApplyGame(state);
        LoadLeaderboard();
    }, ShowError);
}

Después de cada jugada, Unity descarta la vista anterior y dibuja la nueva respuesta del servidor.


Paso 16 - Mostrar el leaderboard compartido

El endpoint de leaderboard puede filtrar por motor, pero el caso de uso Unity no lo hace. El jugador debe ver todos los clientes que usan el mismo backend:

public void LoadLeaderboard()
{
    refreshLeaderboardButton.interactable = false;

    api.LoadLeaderboard(response =>
    {
        refreshLeaderboardButton.interactable = true;
        RenderLeaderboard(response);
    }, error =>
    {
        refreshLeaderboardButton.interactable = true;
        leaderboardText.text = "Leaderboard unavailable.\n" + error;
    });
}

Dibuja las filas devueltas con su motor y sdk para que Unity, Roblox y navegador se distingan:

private void RenderLeaderboard(MinesweeperLeaderboardResponse response)
{
    var lines = new List<string>();
    foreach (var entry in response.top)
        lines.Add($"{entry.posicion}. {entry.alias}  {entry.puntos} pts  {entry.motor}/{entry.sdk}  {entry.segundos}s");

    leaderboardText.text = string.Join("\n", lines);
}


Paso 17 - Persistir sesiones con cuidado

El SDK incluye PraxEncryptedFileTokenStore, pero la muestra encontró una regla de threading de Unity: SystemInfo.deviceUniqueIdentifier sólo puede leerse desde el main thread. La corrección es asegurar que cualquier token store personalizado tome valores de Unity desde Awake() u otro método del main thread, no desde un callback en background ni desde un inicializador de campo.

Usa este principio si entregas tu propio IPraxTokenStore:

private void Awake()
{
    var deviceId = SystemInfo.deviceUniqueIdentifier;

    Prax.Configure(new PraxsuiteOptions
    {
        WorkspaceId = WorkspaceId,
        PersistSession = true,
        TokenStore = new MinesweeperPraxTokenStore(WorkspaceId, deviceId)
    });
}

El mecanismo exacto de almacenamiento importa menos que el límite de autoridad: una sesión guardada del jugador nunca debe tener permisos suficientes para cambiar puntajes o revelar minas directamente.


Parte 3 - Códigos Canjeables

Un código como PRAX-LAVA02 desbloquea un cosmético. Un código, un uso, un dueño - para siempre.

Esa última palabra es la que ata los códigos a las cuentas. El dueño de un inventario es un end user de Praxsuite, que en Unity es Prax.Auth.CurrentUserId - el mismo valor que guarda CurrentPlayerId después de un login exitoso. El id de plataforma se guarda al lado, solo como dato informativo.

Login  →  id de end user  →  Canjear código  →  Inventario  →  Equipar

Jugando como invitado no se puede poseer nada. `UseGuest` cae en `SystemInfo.deviceUniqueIdentifier`, así que un código canjeado como invitado queda atado a un dispositivo, no a una persona: reinstala el juego o ábrelo en otra máquina y el cosmético desapareció. Deja la interfaz de canje detrás de un usuario con sesión iniciada.


Paso 18 - Tres Tablas Más

`Buscaminas Codigos` - una fila es un código.

Columna

Tipo

Qué guarda

Codigo

ShortText

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

Cosmetico Clave

ShortText

Qué entrada del catálogo desbloquea

Activo

Bool

Permite retirar un código sin borrarlo

Usado

Bool

Quemado o no

Usado En

DateTime

Cuándo se quemó

Usado Por Id

ShortText

Qué end user lo quemó

Usado Por Alias

ShortText

Su nombre visible, para poder leer la tabla

Usado Desde Motor

ShortText

roblox, unity, web

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

Columna

Tipo

Qué guarda

Clave

ShortText

Id estable: paleta-lava

Nombre

ShortText

Nombre visible

Descripcion

ShortText

Una línea para la interfaz

Juego

ShortText

Siempre Buscaminas - el catálogo se comparte

Tipo Clave

ShortText

La ranura que ocupa: paleta, mina

Rareza

Status

Comun, Rara, Legendaria - lo que quieras

Config

Json

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

Activo

Bool

Permite retirar un cosmético sin romper inventarios

Config es deliberadamente abierta. El backend nunca la lee: se la pasa al juego, que decide qué significa. Para una paleta de tablero en Unity, los tripletes RGB mapean directo sobre un Color:

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

`Buscaminas Inventario` - una fila es un cosmético que pertenece a un jugador.

Columna

Tipo

Qué guarda

Jugador Id

ShortText

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

Plataforma Id

ShortText

Id de dispositivo o plataforma, informativo

Alias

ShortText

Nombre visible al momento del desbloqueo

Cosmetico

Table

Vínculo a la fila del catálogo

Cosmetico Clave

ShortText

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

Tipo

ShortText

Copiado del catálogo: un equipado por tipo

Equipado

Bool

Si está puesto ahora mismo

Obtenido

DateTime

Cuándo

Codigo Usado

ShortText

Qué código lo produjo

Motor

ShortText

Desde dónde se canjeó

SDK

ShortText

Qué SDK lo canjeó


Paso 19 - Las Tres Automations de Cosméticos

Crea tres endpoints Sync más - Buscaminas: Canjear Codigo, Buscaminas: Mis Cosmeticos, Buscaminas: Equipar Cosmetico - y una Automation detrás de cada uno.

`Canjear Codigo` - nueve nodos

Trigger → Buscar código → Buscar cosmético → Buscar "¿ya lo tiene?" → Script: validar
        → ¿Válido? ─ no ──────────────────────────────────────────────→ Response
                   └ sí → Quemar el código → Agregar al inventario ────→ Response

Las tres queries: buscarCodigo sobre Buscaminas Codigos filtrando Codigo eq {{context.request.body.codigo}}; buscarCosmetico sobre Buscaminas Cosmeticos filtrando Clave eq {{context.steps.buscarCodigo.row.Cosmetico_Clave}}; y buscarPosesion sobre Buscaminas Inventario filtrando Jugador Id y Cosmetico Clave. Todas con límite 1.

Fíjate en los guiones bajos de Cosmetico_Clave: dentro del contexto de pasos, los nombres de columna llegan con los espacios reemplazados por guiones bajos.

El nodo Script validar toma payload, codigo, cosmetico y posesion:

// -- Canje de un codigo cosmetico --------------------------------------------
// Un codigo = un uso. Toda la decision vive aca; los nodos de base que siguen
// solo ejecutan lo que este Script ya resolvio.
//
// QUIEN es el dueno: `jugadorId` es el id del END USER de Praxsuite, no el del
// dispositivo. Si el inventario colgara de un id de dispositivo, la misma
// persona abriendo el juego en otra maquina no veria nada de lo que desbloqueo.

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) {
  // Apunta a un cosmetico que no existe: es un error de datos, no del jugador.
  // No se quema el codigo.
  mensaje = "El premio de ese codigo no esta disponible";
} else if (cos.Activo === false) {
  mensaje = "Ese cosmetico esta desactivado";
} else if (ya.Cosmetico_Clave || ya["Cosmetico Clave"]) {
  // Tampoco se quema: si ya lo tenes, el codigo sigue vivo.
  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)
};

Un If/Else sobre {{context.steps.validar.valido}} == si habilita los dos nodos de escritura. quemar actualiza la fila del código (Usado en true, más Usado En, Usado Por Id, Usado Por Alias, Usado Desde Motor), y guardar inserta la fila de inventario con Jugador Id, Plataforma Id, Alias, Cosmetico, Cosmetico Clave, Tipo, Equipado en false, Obtenido, Codigo Usado, Motor y SDK. Las dos ramas llegan a un nodo Response con {{context.steps.validar.respuesta}}.

Dos fallos deliberadamente no queman el código. Una entrada rota del catálogo es tu bug, no del jugador. Y tener ya el cosmético no es un fallo en absoluto: el código sigue vivo para poder regalarlo.

`Mis Cosmeticos` - cinco nodos, de solo lectura

Consulta el inventario filtrado por Jugador Id, consulta el catálogo filtrado por Juego eq Buscaminas, y crúzalos:

// El inventario guarda solo la clave del cosmetico. La config vive en el
// catalogo, para poder retocar un cosmetico sin reescribir la fila de cada
// jugador que lo tiene. Cruzar aca significa que el cliente recibe la config ya
// resuelta y nunca necesita conocer el catalogo.

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;
  if (c.Activo === false) continue;

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

`Equipar Cosmetico` - siete nodos

Busca la fila de inventario por Jugador Id y Cosmetico Clave, valida, y después desequipa todo lo del mismo Tipo antes de marcar este:

// Equipar es una operacion de dos pasos que tiene que quedar consistente:
// primero se desequipa todo lo del mismo tipo, despues se marca este. Si el
// jugador no posee el cosmetico, no se toca nada.
//
// Y es un INTERRUPTOR, no un boton de una sola direccion: tocar un cosmetico
// que ya esta puesto lo saca.

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;

let queda;
if (typeof body.equipar === "boolean") {
  queda = body.equipar;
} else {
  queda = !yaPuesto;
}

let valido = false;
let mensaje = "";

if (!jugadorId) {
  mensaje = "Tenes que iniciar sesion";
} else if (!clave) {
  mensaje = "Falta el cosmetico";
} else if (!it.Cosmetico_Clave && !it["Cosmetico Clave"]) {
  mensaje = "No tenes ese cosmetico";
} else if (!tipo) {
  mensaje = "Ese cosmetico no tiene tipo asignado";
} else {
  valido = true;
  mensaje = queda ? "Equipado" : "Guardado";
}

return {
  valido: valido ? "si" : "no",
  mensaje: mensaje,
  rowId: String(it.ID || ""),
  clave: clave,
  tipo: tipo,
  // Los fields de UpdateRows solo aceptan strings.
  nuevoEstado: queda ? "true" : "false",
  accion: queda ? "equipar" : "desequipar",
  respuesta: JSON.stringify({
    ok: valido,
    mensaje: mensaje,
    clave: clave,
    tipo: tipo,
    equipado: valido ? queda : yaPuesto
  })
};

Por último, siembra una fila de catálogo y un código para tener algo que canjear:

Tabla

Fila

Buscaminas Cosmeticos

Clave = paleta-lava, Nombre = Lava, Tipo Clave = paleta, Juego = Buscaminas, Activo = true, Config = {"oculta":[60,30,30],"revelada":[120,50,30],"bandera":[255,180,60]}

Buscaminas Codigos

Codigo = PRAX-LAVA02, Cosmetico Clave = paleta-lava, Activo = true, Usado = false

Los códigos se comparan en mayúsculas, así que guárdalos en mayúsculas.


Paso 20 - Canjear desde Unity

Agrega los tres ids de endpoint y sus llamadas a PraxsuiteMinesweeperApi:

private const string RedeemEndpoint = "tu-uuid-del-endpoint-canjear";
private const string MyCosmeticsEndpoint = "tu-uuid-del-endpoint-mis-cosmeticos";
private const string EquipEndpoint = "tu-uuid-del-endpoint-equipar";

public bool IsSignedIn => !string.IsNullOrEmpty(Prax.Auth.CurrentUserId);

public void RedeemCode(string code, Action<MinesweeperRedeemResult> onSuccess, Action<string> onError)
{
    EnsureConfigured();

    if (!IsSignedIn)
    {
        onError?.Invoke("Inicia sesión antes de canjear un código.");
        return;
    }

    var body = new Dictionary<string, object>
    {
        { "codigo", (code ?? string.Empty).Trim().ToUpperInvariant() },
        { "jugadorId", CurrentPlayerId },
        { "plataformaId", SystemInfo.deviceUniqueIdentifier },
        { "alias", CurrentAlias },
        { "motor", "unity" },
        { "sdk", "csharp" }
    };

    StartCoroutine(RunTask(
        () => Prax.Endpoints.CallAsync(RedeemEndpoint, body),
        raw => onSuccess?.Invoke(MapRedeem(raw)),
        onError));
}

public void LoadCosmetics(Action<MinesweeperCosmeticsResponse> onSuccess, Action<string> onError)
{
    EnsureConfigured();

    StartCoroutine(RunTask(
        () => Prax.Endpoints.CallAsync(MyCosmeticsEndpoint,
            new Dictionary<string, object> { { "jugadorId", CurrentPlayerId } }),
        raw => onSuccess?.Invoke(MapCosmetics(raw)),
        onError));
}

public void EquipCosmetic(string key, Action<MinesweeperEquipResult> onSuccess, Action<string> onError)
{
    EnsureConfigured();

    var body = new Dictionary<string, object>
    {
        { "jugadorId", CurrentPlayerId },
        { "clave", key }
    };

    StartCoroutine(RunTask(
        () => Prax.Endpoints.CallAsync(EquipEndpoint, body),
        raw => onSuccess?.Invoke(MapEquip(raw)),
        onError));
}

Y las formas sobre las que mapean:

[Serializable]
public class MinesweeperCosmetic
{
    public string clave;
    public string nombre;
    public string descripcion;
    public string tipo;
    public string rareza;
    public bool equipado;
    public Dictionary<string, object> config = new Dictionary<string, object>();
}

[Serializable]
public class MinesweeperRedeemResult
{
    public bool ok;
    public string mensaje;
    public string codigo;
    public MinesweeperCosmetic cosmetico;
}

[Serializable]
public class MinesweeperCosmeticsResponse
{
    public bool ok;
    public int total;
    public List<MinesweeperCosmetic> items = new List<MinesweeperCosmetic>();
    public Dictionary<string, MinesweeperCosmetic> equipado = new Dictionary<string, MinesweeperCosmetic>();
}

[Serializable]
public class MinesweeperEquipResult
{
    public bool ok;
    public string mensaje;
    public string clave;
    public string tipo;
    public bool equipado;
}

Ahora la interfaz. Agrega un InputField y un Button al canvas y conéctalos desde el controller:

[SerializeField] private InputField codeInput;
[SerializeField] private Button redeemButton;
[SerializeField] private Text redeemStatus;

private void Start()
{
    redeemButton.onClick.AddListener(OnRedeemClicked);
}

private void OnRedeemClicked()
{
    var code = codeInput.text?.Trim();
    if (string.IsNullOrEmpty(code))
    {
        redeemStatus.text = "Escribe un código primero";
        return;
    }

    redeemButton.interactable = false;
    api.RedeemCode(code, result =>
    {
        redeemButton.interactable = true;
        redeemStatus.text = result.mensaje;

        if (!result.ok || result.cosmetico == null)
            return;

        codeInput.text = string.Empty;
        // Equipar lo recién desbloqueado, para que el premio se vea al instante
        api.EquipCosmetic(result.cosmetico.clave, _ => ApplyPalette(), ShowError);
    },
    error =>
    {
        redeemButton.interactable = true;
        redeemStatus.text = error;
    });
}

Aplicar la paleta lee equipado, indexado por tipo, con la config del catálogo ya resuelta:

private Color hiddenColor = new Color32(150, 150, 165, 255);
private Color revealedColor = new Color32(90, 90, 100, 255);
private Color flagColor = new Color32(240, 120, 120, 255);

private void ApplyPalette()
{
    api.LoadCosmetics(response =>
    {
        if (response.equipado != null && response.equipado.TryGetValue("paleta", out var paleta))
        {
            hiddenColor = ReadColor(paleta.config, "oculta", hiddenColor);
            revealedColor = ReadColor(paleta.config, "revelada", revealedColor);
            flagColor = ReadColor(paleta.config, "bandera", flagColor);
        }

        if (game != null)
            RenderBoard(game);
    }, ShowError);
}

private static Color ReadColor(Dictionary<string, object> config, string key, Color fallback)
{
    if (config == null || !config.TryGetValue(key, out var raw) || !(raw is IList<object> rgb) || rgb.Count != 3)
        return fallback;

    return new Color32((byte)ToInt(rgb[0]), (byte)ToInt(rgb[1]), (byte)ToInt(rgb[2]), 255);
}

Después usa hiddenColor, revealedColor y flagColor en la vista de celda en vez de los valores fijos. Agregar una paleta nueva pasa a ser una fila de catálogo y un código: sin cambiar la Automation, sin recompilar.

Un cliente Unity manda su propio `jugadorId`, y eso es una diferencia real. En la versión de Roblox lo aporta el servidor del juego, así que no se puede falsear. Acá el valor viaja en el cuerpo de la petición desde un cliente que controla el jugador, y la Automation confía en el cuerpo: una build modificada podría canjear sobre el inventario de otro si averiguara su id de end user. Para cosméticos en una demo es un intercambio aceptable. Antes de que este patrón custodie algo de valor, resuelve al llamador desde la identidad autenticada de la petición dentro de la Automation en vez de leer `body.jugadorId`, o mueve el canje detrás de un servidor que controles.


Código completo

La implementación completa de Unity está en el proyecto bajo:

Assets/
  Editor/
    BuscaminasSceneSetup.cs
  Scripts/
    Buscaminas/
      PraxsuiteMinesweeperApi.cs
      MinesweeperGameController.cs
      MinesweeperCellView.cs
      MinesweeperPraxTokenStore.cs

BuscaminasSceneSetup.cs crea y enlaza los GameObjects fijos de UI. PraxsuiteMinesweeperApi.cs maneja configuración de Praxsuite, auth, llamadas a endpoints y mapeo de respuestas. MinesweeperGameController.cs maneja estado de UI en Unity. MinesweeperCellView.cs convierte un carácter del servidor en una celda clickeable. MinesweeperPraxTokenStore.cs personaliza la persistencia de sesión.

!image.png


Errores comunes y cómo evitarlos

Error

Causa

Solución

PraxsuiteOptions.WorkspaceId is required.

Prax.Configure corrió sin workspace id y ningún settings asset entregó uno

Crea el settings asset o pasa WorkspaceId en PraxsuiteOptions antes del primer llamado a Prax

PraxsuiteOptions.WorkspaceId is not a valid GUID: ...

El workspace id tiene caracteres extra o no es un UUID

Copia sólo el GUID desde la URL del portal

An endpoint slug is required.

Prax.Endpoints.CallAsync recibió un endpoint id vacío

Revisa las constantes de nueva partida, jugada y leaderboard

ChangePasswordAsync needs a signed-in player. Use ForgotPasswordAsync for a player who cannot sign in.

Se llamó un método de auth que requiere sesión antes del login

Deshabilita acciones de cuenta hasta que Prax.Auth.IsSignedIn sea true

No refresh token available; log in first.

Se pidió refresh antes de tener una sesión

Llama LoginAsync o RegisterAsync primero

InvalidOperationException: You are trying to read Input using the UnityEngine.Input class...

La escena tiene StandaloneInputModule mientras Player Settings usa el paquete Input System

Reemplázalo por InputSystemUIInputModule cuando ENABLE_INPUT_SYSTEM esté definido

[Praxsuite] Could not persist the session: GetDeviceUniqueIdentifier can only be called from the main thread.

Un token store tocó SystemInfo.deviceUniqueIdentifier desde un loading thread o background thread

Lee valores de Unity en Awake() y pasa strings planos al código de almacenamiento

HTTP 404 en cada llamada a un endpoint

El id del endpoint no existe en tu workspace, o su Automation no tiene versión publicada

Vuelve a copiar los ids desde Gateway → Endpoints y confirma que cada Automation esté publicada

El tablero se dibuja pero todas las celdas quedan en ? después de una jugada

La respuesta llegó y se descartó; el tablero se redibujó desde estado viejo

Dibuja siempre desde la vista de la última respuesta, nunca desde una copia local

"Coordenada fuera del tablero"

Se envió una fila o columna fuera de 0 a R-1

Los índices de bucle de Unity ya son base 0: pásalos tal cual, no les sumes uno

"Tenes que iniciar sesion para canjear"

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

Deja la interfaz de canje detrás de que Prax.Auth.CurrentUserId no esté vacío

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

Funciona como debe. Un código, un uso

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

Un cosmético desbloqueado como invitado desaparece

Jugando como invitado el inventario queda atado a un id de dispositivo, no a una cuenta

Inicia sesión antes de canjear; ver la nota de la Parte 3


Consejos para producción

  • Mantén el rol de jugador sin escritura directa sobre Buscaminas Partidas y Demos Leaderboard.

  • Mantén Matriz ilegible desde cualquier table scope expuesto al cliente.

  • Envía motor: "unity" y sdk: "csharp" en cada partida nueva para que los leaderboards compartidos sean explicables.

  • Usa CancellationToken en pantallas de larga vida si una request puede vivir más que el objeto que la inició.

  • Mantén VerboseLogging apagado en builds de release porque los cuerpos de request y response pueden contener datos del jugador.

  • Prueba un build, no sólo Play mode, para que el build guard del SDK pueda escanear secret keys y hosts remotos inseguros.


Próximos pasos

  • Agrega una pantalla para retomar partida guardando el codigo actual y pidiendo al servidor la siguiente jugada sobre la misma fila.

  • Muestra filtros para todos los motores lado a lado: Unity, Roblox y navegador.

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

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

  • Agrega soporte de control para poner banderas, porque clic derecho es natural en escritorio pero incómodo en gamepad.

  • Mueve el puntaje a un panel de resultado más completo después de Ganada o Perdida.

  • Mueve el motor a C# para sacar la latencia por clic, como hace el cliente Unity de referencia (PraxsuiteSDKDemo): la jugada se resuelve localmente, Buscaminas: Jugar queda sin usar, y el endpoint Validar Resultado que construiste más arriba revalida el resultado una vez por partida.

Ahora tienes un juego Unity donde la diversión es local y la autoridad es remota, y construiste los dos lados de esa línea. Esa es la forma correcta para un juego basado en puntaje.