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

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.
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.
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.
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 →Build de Unity en batchmode, hosting del WebGL con headers Brotli y frame-ancestors, y deploy por entornos QA/producción con wrangler.

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.
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.
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.
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.
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.
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.
Esto es lo que se lee sin abrir los repos: la opción que descarté, la que elegí, y por qué.
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.
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.
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.
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.
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.
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.
Corren en orden y cortan al primer fallo: lo barato —verificar una firma— va antes que lo caro —decodificar un trail entero—.
| Check | Qué cubre | Consecuencia |
|---|---|---|
| 01runToken HMAC válido, no expirado, no consumido | Replays del mismo payload | rejected |
| 02Wall-clock del servidor ≥ tiempo reportado (margen 500 ms) | Tiempos imposibles — el único check verdaderamente autoritativo | rejected |
| 03Suma de sectores == total, orden correcto, ninguno en 0 | Payloads armados a mano | rejected |
| 04Cada sector ≥ mínimo teórico de la pista (KV) | Teleport o skip de sección | flagged |
| 05Trail coherente: velocidad ≤ vmax, posiciones dentro de los bounds | Speedhack, salirse del circuito | flagged |
| 06Rate limit por device + IP en RateLimiterDO | Spam de submits | rejected |
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.
El Durable Object ya es el paso 1 hacia multijugador. Va en el README como next step, no en el MVP.
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.
Cuatro sectores con carácter distinto bastan para que la gráfica de velocidad diga algo. Sumar pistas es contenido, no arquitectura.
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.
Plataforma de seguros · Web, móvil y CRM