Ghostline
Time-trial en el navegador con leaderboard verificado en el edge.
Compites contra el récord de la pista y contra tu propia mejor vuelta — nada más. Descarté mostrar los ghosts del top 3 a propósito: varios carros translúcidos sobre la misma trazada se amontonan y dejan de decir algo. Unity no decide quién ganó: se lo pregunta a un backend propio en Cloudflare Workers, y ese backend no le cree nada al cliente sin verificarlo.
- Unity 6
- WebGL
- Cloudflare Workers
- Durable Objects
- R2
- KV
- Hono
- TypeScript
Leaderboard
conectando…| # | Piloto | Tiempo | vs. líder |
|---|---|---|---|
| — | |||
El WS solo transmite runs verified: el servidor arma el top-100 únicamente con runs verificados, así que un flagged nunca llega por push.
El delta «vs. líder» no viene en el payload — lo calculo en el cliente restando el tiempo del líder al de cada fila.
Al conectar, el servidor manda el top-100 completo y mantiene la conexión viva con ping/pong. Hay un tope de 64 sockets por pista — si se excede responde 429 con Retry-After de 30 s — y cada IP puede intentar suscribirse máximo 30 veces por minuto.
Ver también flagged (vía REST)
Estos no están en el stream: se piden aparte con GET /v1/tracks/:id/leaderboard?verified=false.
Las dos mitades juntas, no por separado
No es «sé Unity» ni «sé backend» cada uno por su lado: es un cliente de juego en tiempo real sostenido por el mismo tipo de infraestructura de servidor que ya opero en producción. El demo tiene que hacer visible esa combinación en menos de 60 segundos para quien abra el link.
Jugable
El juego es deliberadamente simple; la profundidad está en el pipeline. Abres el link y ya estás corriendo, sin instalar nada. Con jugar un minuto ya viste de qué va el proyecto.
Cliente hostil
Los endpoints son públicos y el payload se lee en el bundle de JavaScript: cualquiera puede saltarse el juego y llamar la API con datos fabricados. No prometo un anti-cheat perfecto; prometo decir con claridad qué cubre y qué no.
Producción real
Idempotencia dentro de un Durable Object, rate limiting, validación server-side, observabilidad. La misma garantía que da el lock distribuido en Redis de Mecánica MX, con otra tecnología debajo: el patrón se transfiere, la infraestructura no.
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.
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.
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.
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.
Cada push a main
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.
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
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
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
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
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
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
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.
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—.
| Check | Qué cubre | Consecuencia |
|---|---|---|
| 1.runToken HMAC válido, no expirado, no consumido | Replays del mismo payload | rejected |
| 2.Wall-clock del servidor ≥ tiempo reportado (margen 500 ms) | Tiempos imposibles — el único check verdaderamente autoritativo | rejected |
| 3.Suma de sectores == total, orden correcto, ninguno en 0 | Payloads armados a mano | rejected |
| 4.Cada sector ≥ mínimo teórico de la pista (KV) | Teleport o skip de sección | flagged |
| 5.Trail coherente: velocidad ≤ vmax, posiciones dentro de los bounds | Speedhack, salirse del circuito | flagged |
| 6.Rate limit por device + IP en RateLimiterDO | Spam de submits | rejected |
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.
El trail reconstruye la vuelta
10 muestras por segundo de posición, yaw y velocidad — suficiente para reconstruir el ritmo real de la vuelta sin guardar más que un puñado de KB por run.
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.
Dos repos, un contrato
La misma regla de dependencias rige los dos lados del repo: la capa domain de la API no depende de nada de Cloudflare y la capa Core de Unity no depende de nada de red. Esa simetría es deliberada.
- Unity 6
- WebGL
- Cloudflare Workers
- Durable Objects
- R2
- KV
- Hono
- TypeScript
