Saltar al contenido
Desarrollador Backend / DevOps

Ghostline

API autoritativa en Cloudflare Workers · Durable Objects · leaderboard en vivo por WebSocket

Time-trial en el navegador: compites contra el récord de la pista y contra tu propia mejor vuelta. El juego no decide nada — cada tiempo pasa por un backend propio en Cloudflare Workers que lo valida con checks del lado del servidor, idempotencia y rate limiting antes de publicarlo en el leaderboard. Los dos repos son públicos: el del juego en C# y el de la API en TypeScript.

  • TypeScript
  • Cloudflare Workers
  • Durable Objects
  • Hono
Ghostline
~60 ms
Validación de un run en el edge
6
Checks server-side por submit
64
Sockets en vivo por pista (WS + hibernación)
2
Repos públicos: juego (C#) y API (TypeScript)
01

La historia

01.1

Problema

Un portafolio de simulación se apoya casi siempre en video: se ve el resultado, nunca el criterio. Quise poner encima de la mesa un sistema completo que cualquiera pudiera abrir, jugar y auditar.

01.2

Solución

Construí un juego público de punta a punta: time-trial en Unity WebGL contra el ghost del récord de la pista, con un backend en Workers que valida cada tiempo por cotas, con idempotencia y rate limiting en Durable Objects.

01.3

Resultado

Un demo jugable desde el enlace, un leaderboard en vivo por WebSocket y el repo de la API abierto donde el criterio se puede auditar commit a commit.

02

Qué construí

  1. 02.1Desarrollador Backend

    API autoritativa en Cloudflare Workers

    Seis endpoints en Hono con validación por cotas, tokens HMAC de un solo uso, idempotencia y rate limiting dentro de Durable Objects, y observabilidad con logs estructurados.

    Repositorio
  2. 02.2DevOps

    Pipeline de build y deploy

    Build de Unity en batchmode, hosting del WebGL con headers Brotli y frame-ancestors, y deploy por entornos QA/producción con wrangler.

03
04

Un run, del juego al leaderboard

Desde que Unity pide un token hasta que el tiempo existe en el leaderboard: quién le habla a quién, en qué orden, y qué máquina de estados lo garantiza. Cinco diagramas del mismo mecanismo.

Quién le habla a quién

El juego habla solo HTTP: Unity WebGL no tiene WebSockets funcionales. La web sí abre el WS /v1/tracks/:id/live — la arista en magenta —, que queda hibernando en el LeaderboardDO de la pista (Hibernation API, tag v:{trackVersion}) y recibe el top-100 completo al conectar, sin esperar a que algo cambie. La llave que firma el runToken nunca sale del Worker: el cliente recibe el token ya firmado y solo lo devuelve — nunca firma nada.

Loading...

La secuencia de un run

De Countdown a Result, con el camino feo incluido. Si el POST de finish falla, el resultado no se pierde: SubmitQueue lo guarda en disco y reintenta con el mismo Idempotency-Key hasta que el servidor confirme. La idempotencia es lo que vuelve seguro ese reintento: el servidor responde lo mismo que la primera vez en lugar de duplicar el run. El broadcast al WS solo se dispara con un submit aceptado — nunca con un rejected ni con un reintento duplicado.

Loading...

La máquina de estados que lo garantiza

RunDirector es el dueño único del estado. Regla dura: nadie más congela el auto ni toca Time.timeScale. Si dos sistemas pueden pausar, tarde o temprano aparece un bug de estado que se reproduce una vez cada 50 runs y no se encuentra nunca.

Loading...

Los seis checks en cascada

Cada check que falla corta el camino antes de llegar al siguiente. La tabla de la sección de anti-cheat es la referencia rápida; este diagrama es la forma real del mecanismo.

Loading...

Cada push a main

Loading...

El último paso importa: la config de pista se sube a KV desde el repo, no a mano desde el dashboard. Si TrackConfig se edita en Unity y no se exporta, el desfase truena en CI, no en producción.

05

Seis decisiones con dos caminos razonables

Esto es lo que se lee sin abrir los repos: la opción que descarté, la que elegí, y por qué.

Cronómetro

ATime.time

BFixedUpdate

El Fixed Timestep está clavado a 50 Hz y todo el cronómetro depende de él: con muestreo a 10 Hz salen exactamente cinco pasos de física por muestra, con dt = 0.1 exacto. Time.time varía con el framerate, y ese jitter se filtraría directo al tiempo reportado. Regla dura: nadie toca Time.timeScale durante un run.

Validación del run

ARe-simulación del run

BValidación por cotas

La física de Unity no es determinista entre máquinas: re-simular el run en el servidor para compararlo bit a bit es una promesa que no puedo cumplir. Las cotas —wall-clock, mínimos por sector, velocidad máxima— no prueban que el run es honesto, pero sí que es internamente coherente, y eso ya sube mucho el costo del ataque.

Ghost

ARe-simulación de inputs

BPosiciones interpoladas

Re-simular inputs hereda el mismo no-determinismo de la decisión 2, y perseguir el determinismo del ghost es un pozo sin fondo. El trail graba posición, yaw y velocidad cuantizados a 10 Hz; el ghost solo interpola esos puntos. Está en el README como decisión, no como omisión.

Fuente de verdad

AD1 (relacional)

BDurable Object

El DO serializa sus propias escrituras: dos submits simultáneos del mismo dispositivo no pueden intercalarse, y esa garantía sale gratis. En D1 costaría una transacción con reintentos, y un ranking por pista no necesita una base relacional completa.

Runs sospechosos

ABorrar en silencio

BMarcar flagged

Los checks 4 y 5 —sectores al mínimo teórico, velocidad y posición del trail— pueden fallar por una conexión mala, no solo por trampa. Marcar en vez de borrar en silencio es más honesto, y hace la página de detalle de un run mucho más interesante.

Idempotencia

AEn KV

BDentro del DO

KV es eventualmente consistente: dos reintentos rápidos del mismo submit podrían no verse entre sí, que es justo el caso que la idempotencia existe para cubrir. Guardo la respuesta completa contra la Idempotency-Key dentro del propio LeaderboardDO: un reintento recibe exactamente el mismo rank y el mismo status, no un 409 que el cliente tenga que interpretar.

06

Seis checks, de barato a caro

Corren en orden y cortan al primer fallo: lo barato —verificar una firma— va antes que lo caro —decodificar un trail entero—.

CheckQué cubreConsecuencia
01runToken HMAC válido, no expirado, no consumidoReplays del mismo payloadrejected
02Wall-clock del servidor ≥ tiempo reportado (margen 500 ms)Tiempos imposibles — el único check verdaderamente autoritativorejected
03Suma de sectores == total, orden correcto, ninguno en 0Payloads armados a manorejected
04Cada sector ≥ mínimo teórico de la pista (KV)Teleport o skip de secciónflagged
05Trail coherente: velocidad ≤ vmax, posiciones dentro de los boundsSpeedhack, salirse del circuitoflagged
06Rate limit por device + IP en RateLimiterDOSpam de submitsrejected

Qué NO cubre

Los endpoints son públicos y el formato del payload se descubre leyendo el bundle de JavaScript: cualquiera puede saltarse el juego y llamar la API con datos fabricados pero plausibles. Por eso el diseño no se vende como anti-cheat, sino como tres cosas concretas. Un piso duro: el wall-clock del servidor hace imposible reportar un tiempo menor al que realmente transcurrió entre start y finish. Cotas por sector y por trail: un tiempo fabricado tiene que ser internamente coherente para pasar, y eso sube mucho el costo del ataque. Y detectabilidad: lo que pasa las cotas pero se ve raro entra como flagged y queda auditable, en vez de desaparecer en silencio.

07

Qué no cubre, y por qué está bien

Sin multijugador en tiempo real

El Durable Object ya es el paso 1 hacia multijugador. Va en el README como next step, no en el MVP.

Sin cuentas ni login

El ranking identifica dispositivo, no persona. Para un time-trial de portafolio es la fricción correcta: cero fricción para jugar, cero superficie de credenciales que proteger.

Una sola pista

Cuatro sectores con carácter distinto bastan para que la gráfica de velocidad diga algo. Sumar pistas es contenido, no arquitectura.

Sin variedad, a propósito

Sin IA rival, sin daño, sin tuning, sin varios autos, sin clima, sin ciclo día/noche. Cada una de esas features cuesta un fin de semana entero, y preferí gastarlos en el pipeline.

Siguiente proyecto

Ecosistema Insurtech

Plataforma de seguros · Web, móvil y CRM