Underground 2 Revived
Play Need for Speed: Underground 2 (2004) in Chrome and Firefox with WebGPU graphics, Web Audio, up to 120 fps widescreen, controller support, free roam and Online races with friends. Fan project, not
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
Plataforma web para jugar a clásicos arcade y competir por la mayor puntuación, con estética CRT de salón recreativo. El repo contiene una maqueta navegable completa: siete pantallas reales sobre Next.js App Router, con navegación, filtros, formulario de sesión y tablas de puntuaciones funcionando de extremo a extremo. Lo que todavía no existe:查看完整 README ↗
Plataforma web para jugar a clásicos arcade y competir por la mayor puntuación, con estética CRT de salón recreativo.
El repo contiene una maqueta navegable completa: siete pantallas reales sobre Next.js App Router, con navegación, filtros, formulario de sesión y tablas de puntuaciones funcionando de extremo a extremo. Lo que todavía no existe:
TETRIX (SPEC 13): tablero de 10 × 20 dentro de la pantalla CRT, siete tetrominós, rotación con wall kicks, pieza fantasma, vista de la siguiente, soft y hard drop, una vida y diez niveles. ASTEROIDES (SPEC 14): nave con inercia en un mundo que envuelve por los bordes, rocas que se parten en dos a cada impacto, oleadas que crecen hasta diez rocas, dos objetos —disparo triple y escudo— y una vida. ARKANOID (SPEC 15, SPEC 24): bola que rebota entre tres paredes, pala que apunta según dónde golpee, muro distinto en cada nivel —los cinco de la referencia y, del sexto en adelante, generados de forma determinista—, bola cada vez más rápida hasta un tope, premios que caen —segunda bola y pala que crece— y tres vidas. BUSCAMINAS (SPEC 20): rejilla de 16 × 12 jugable por cursor o por ratón —clic primario revela, secundario marca bandera—, primera casilla siempre segura, relleno en cascada, y cada rejilla despejada sube el nivel y añade una mina, con una sola vida. Y SERPIENTE (SPEC 25): rejilla de 20 × 15 que una serpiente recorre sin detenerse nunca, giros encolados —dos pulsaciones dentro del mismo paso no la hacen morderse el cuello— y descartando la marcha atrás, fruta que vale 10 × el nivel, el paso acortándose de 150 ms hasta un suelo de 60, sin final y con una sola vida.public.games (con public.categorias) sostiene la ficha de cada juego —nombre, niveles, vidas, dificultad, nº de jugadores, periféricos y un contador plays real que sube cada vez que alguien empieza una partida—; public.scores sostiene el historial de puntuaciones. lib/games.ts y lib/scores.ts ya no existen: la lectura vive en lib/supabase/games.ts y lib/supabase/scores.ts.GUARDAR PUNTUACIÓN llama a save_score, una función de Postgres que inserta en scores solo cuando el resultado supera la mejor marca anterior del jugador en ese juego; si no la supera, ni siquiera hay petición al servidor. Las vidas y el tope de nivel de cada motor también vienen de games.vidas/games.niveles, no de constantes en el código. A un invitado el modal le pide entrar con Google, GitHub o correo antes de guardar, y al volver de /auth —incluso si acaba de crear la cuenta— recupera su puntuación y la guarda sola si sigue siendo récord./auth habla con Supabase Auth: correo y contraseña con confirmación por correo, y OAuth de Google y GitHub si están dados de alta. La sesión vive en cookies, no en localStorage, y /jugar/[id] exige estar dentro.Node.js >= 20.9.0 (lo exige [email protected]; comprobable en engines.node de node_modules/next/package.json).
npm (el repo trae package-lock.json).
Navegador de Playwright, solo si vas a ejecutar las pruebas:
npx playwright install chromium
Es el único motor que usa la suite: los dos proyectos corren sobre Chromium.
Docker, para levantar el stack local de Supabase. La CLI no hace falta instalarla: se invoca siempre con npx supabase.
npm install
npx supabase start # arranca Postgres, Auth, Studio y Mailpit en Docker
npx supabase db reset # aplica la migración y siembra el usuario de pruebas
npm run dev
La aplicación queda en http://localhost:3000.
npx supabase status imprime los datos del stack local:
| Servicio | URL |
|---|---|
| API | http://127.0.0.1:54321 |
| Studio | http://127.0.0.1:54323 |
| Mailpit (buzón de pruebas) | http://127.0.0.1:54324 |
Para trabajar contra el stack local, pon en .env.local la URL de la API y la PUBLISHABLE_KEY que imprime ese comando. npm test no las necesita: playwright.config.ts fija las suyas en el env del webServer, para que la suite no compile nunca contra el proyecto remoto.
npm test exige el stack arrancado: su pretest hace npx supabase db reset y espera a que Auth responda. Si Docker no está en marcha, falla ahí.
Las usan POST /api/contacto (el formulario de /acerca) y Supabase Auth. Copia la plantilla y rellena lo que necesites:
cp .env.example .env.local
| Variable | Para qué |
|---|---|
RESEND_API_KEY | Clave de Resend. Vacía = modo simulado. |
CONTACT_TO_EMAIL | Destinatario de los mensajes. |
CONTACT_FROM_EMAIL | Remitente. Por defecto el de pruebas, [email protected]. |
NEXT_PUBLIC_SUPABASE_URL | URL del proyecto de Supabase (o http://127.0.0.1:54321 en local). |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | Clave publicable (sb_publishable_…), no la anon heredada. |
NEXT_PUBLIC_TURNSTILE_SITE_KEY | Clave pública del captcha de /auth. Ausente = captcha apagado. |
Las NEXT_PUBLIC_* son públicas: Next las incrusta en el bundle del navegador. Lo que protege los datos es la RLS de public.profiles, no el secreto de esas claves. Aun así, .env.example lleva marcadores y no los valores reales, porque hay rastreos automáticos que buscan proyectos Supabase sin RLS para abusar de /auth/v1/signup. Ninguna clave de servicio (service_role, sb_secret_…) entra en el repo ni llega al cliente.
Modo simulado. Sin RESEND_API_KEY el formulario sigue funcionando: el endpoint responde { ok: true, simulated: true } y escribe el mensaje en la consola del servidor, sin mandar ningún correo. Es el camino que recorre un clon recién clonado y el que fuerza npm test, así que la suite nunca envía correo de verdad.
Para que salga correo de verdad hacen falta tres cosas, ninguna de código:
CONTACT_TO_EMAIL. El remitente de pruebas [email protected] solo entrega a la dirección de la propia cuenta; escribir a cualquier otra exige un dominio verificado.re_…) desde el panel de Resend.RESEND_API_KEY= en .env.local y reiniciar el servidor.El endpoint se defiende con un honeypot, validación en servidor y un rate limit de 3 envíos cada 10 minutos por IP. Ese límite es best effort: vive en memoria, se pierde al reiniciar y no se comparte entre instancias.
/auth usa Supabase Auth. Cuatro caminos de entrada:
public.profiles.username, en mayúsculas, único y de 2 a 10 caracteres. Un nombre ocupado se rechaza en el navegador, antes de llamar a Supabase.http://127.0.0.1:54324), no sale de la máquina. El enlace pasa por /auth/confirm, que canjea el token y deja la sesión iniciada.JUGAR COMO INVITADO abre una sesión anónima de Supabase: usuario real con is_anonymous = true, su JWT en cookie y su fila en profiles. Juega como cualquiera y el Nav le llama INVITADO, pero no guarda nada ni puede convertirse todavía en cuenta permanente — si se registra estando dentro, crea una cuenta nueva y la anónima queda huérfana. El username que el trigger le asigna (INV70A7E1D) es técnico y no se enseña: lo decide displayName() en lib/supabase/user.ts.La sesión vive en cookies, la refresca proxy.ts en cada petición y app/layout.tsx la resuelve en servidor. /jugar/[id] es la única ruta protegida: sin sesión redirige a /auth?next=/jugar/<id> y vuelve al juego tras entrar.
Los paneles son manuales; el repo no puede automatizarlos.
<SUPABASE_URL>/auth/v1/callback.http://127.0.0.1:3000/** y http://127.0.0.1:3100/**).En local lo enciende enable_anonymous_sign_ins = true en supabase/config.toml, y hace falta npx supabase stop && npx supabase start: db reset no recoge ese flag. En el proyecto remoto es otro interruptor manual, Authentication → Sign In / Providers → Anonymous. Si se olvida, el botón pinta EL MODO INVITADO NO ESTÁ DISPONIBLE en vez de romperse.
Cada clic crea una fila en auth.users. El rate limit por IP (anonymous_users en [auth.rate_limit]) contiene el abuso, y de barrer los caducados se encarga la purga automática de abajo.
/authJUGAR COMO INVITADO crea un usuario real con un solo clic y sin correo: es el endpoint más barato de abusar que tiene el proyecto, y los invitados que un bot cree este mes ya cuentan en la factura aunque la purga los borre a los treinta días. La defensa es un widget de Cloudflare Turnstile cuyo token viaja en signInWithPassword, signUp y signInAnonymously. signInWithOAuth queda fuera: es una redirección y Supabase no le aplica captcha.
El widget va en modo always: la caja de Cloudflare se pinta siempre, centrada sobre el botón de envío. En el caso normal se resuelve sola y se queda en verde sin pedir nada — always decide si la caja se ve, no la dificultad del desafío, que la sigue eligiendo Cloudflare con el widget en modo Managed.
Se pinta a propósito, y la SPEC 11 explica por qué: en interaction-only un captcha que funciona y uno roto se ven exactamente igual, un hueco vacío. Eso costó una investigación entera el día del despliegue, con todo bien configurado. Un captcha invisible que deja de funcionar no avisa.
Todo cuelga de NEXT_PUBLIC_TURNSTILE_SITE_KEY. Sin esa variable no se carga el script, no se pinta el contenedor y el token viaja como undefined. Por eso el stack local, npm test y las capturas de referencia funcionan sin tocar nada, y por eso supabase/config.toml deja el bloque [auth.captcha] comentado: encenderlo en local obligaría a la suite a resolver un captcha real en cada prueba de /auth.
Poner en marcha el captcha son cuatro pasos manuales, en este orden:
localhost. Widget mode Managed. Salen dos claves.0x4AAAA…) va al entorno de la aplicación como NEXT_PUBLIC_TURNSTILE_SITE_KEY: .env.local en local, variables del hosting en producción./auth sigue funcionando: entrar, registrarse, invitado y los dos botones de OAuth.El orden es la parte importante. El flag de Supabase es por proyecto, no por endpoint: en cuanto se activa, los tres formularios exigen token. Si se enciende antes de desplegar la variable, /auth queda inservible en el hueco entre ambos pasos. En sentido contrario no pasa nada: con la variable puesta y el flag apagado, el widget se pinta y el token se ignora.
La clave secreta no entra en el repositorio ni en el entorno de la aplicación: la guarda Supabase, que es quien la verifica contra Cloudflare. Y si Turnstile se cae, el flag se apaga desde el panel de Supabase en segundos, sin desplegar nada.
Si el script no carga — un bloqueador de anuncios, la red — los tres botones siguen pulsables: el envío sale sin token, Supabase lo rechaza y el terminal rojo dice VERIFICACIÓN ANTI-BOT FALLIDA, INTÉNTALO DE NUEVO. Un bloqueador no deja la pantalla muerta y sin explicación.
RLS en public.games y public.scores ya estaba habilitado desde supabase/migrations/20260927082152_catalogo_juegos_y_puntuaciones.sql (SPEC 16), y la política de insert insegura que un audit identificó en scores ya la retiró la SPEC 29 (20261002130000_retira_policy_insert_scores.sql). Nada pendiente ahí — queda constancia aquí porque es uno de los cinco puntos del checklist que trajo esta spec.
De los otros cuatro, dos son configuración del repo (minimum_password_length subido a 8 en supabase/config.toml, y los headers de next.config.ts de más abajo) y dos son interruptores manuales del panel remoto, porque no existe clave equivalente para el stack local autoalojado — mismo patrón que el captcha y el modo invitado de arriba:
"Leaked password protection" no se activa. No es un paso pendiente — es una función exclusiva del plan Pro de Supabase; el proyecto de este repo corre en el plan gratuito, donde el toggle ni siquiera aparece en el panel. El Advisor de seguridad seguirá marcándolo en WARN indefinidamente; se acepta como riesgo conocido (ver SECURITY.md) a menos que el proyecto pase a Pro.
sign_in_sign_ups = 1000 se queda así en config.toml local a propósito. No es el valor de producción: es el que necesita la suite de Playwright, que corre en paralelo (workers: 2) contra la misma IP — con 30 se cuelga a mitad de ejecución. El 30/5min de arriba es solo para el panel remoto.
Un invitado que no vuelve deja su fila en auth.users y en profiles para siempre: Supabase no recoge nada por su cuenta. Una vez al día, a las 04:00 UTC, un job de pg_cron llamado purga-invitados borra a los invitados que llevan más de 30 días sin iniciar sesión, hasta 200 por pasada. Sólo toca a los anónimos; una cuenta registrada nunca entra en la selección.
El borrado no se hace en SQL. Un delete from auth.users salta las cascadas y el estado interno de GoTrue (identidades, sesiones, refresh tokens), así que la cadena es más larga:
pg_cron → public.purge_guests_tick() → pg_net (POST) → Edge Function borrar-invitados
→ public.stale_guest_ids() → auth.admin.deleteUser() → profiles por cascada
→ fila en public.guest_cleanup_runs
Postgres filtra y dispara; la Edge Function orquesta, porque el Admin API exige clave de servicio. pg_net dispara y no espera, de modo que el cron nunca ve el resultado: el rastro de cada pasada queda en public.guest_cleanup_runs. Una pasada sin fila es una pasada que no llegó. Esa tabla tiene RLS activada y ninguna política, así que no se lee con la clave publicable; tampoco son invocables por anon ni authenticated las dos funciones.
purge_guests_tick() lee del Vault a dónde llamar y con qué credencial. Sin esos dos secretos el job sale sin hacer nada — es la guarda que deja la purga muda tras un db reset, para que npm test no dispare peticiones HTTP. Hay que crearlos a mano, una vez por entorno; un db reset en local se los lleva.
El bearer no es la clave de servicio: es un secreto propio, PURGE_SECRET, generado al azar. Así la clave potente nunca sale de la Edge Function.
openssl rand -base64 32 # el mismo valor va en los dos sitios de abajo
En la base, para que el cron sepa a dónde mandarlo:
-- Local: la API vista desde el contenedor de Postgres, no 127.0.0.1.
select vault.create_secret('http://supabase_kong_05-arcade_vault:8000/functions/v1/borrar-invitados', 'guest_purge_url');
-- Remoto: https://<project-ref>.supabase.co/functions/v1/borrar-invitados
select vault.create_secret('<PURGE_SECRET>', 'guest_purge_key');
En la función, para que lo compare. En local va en supabase/functions/.env (ignorado por git); en remoto:
npx supabase secrets set PURGE_SECRET=<PURGE_SECRET> GUEST_RETENTION_DAYS=30
GUEST_RETENTION_DAYS es el plazo en días, 30 por defecto. Cambiarlo no exige migración. Antes de dar un despliegue por bueno, comprobar que select count(*) from vault.decrypted_secrets no da error de permisos: si el rol no pudiera leer la vista, la guarda no distinguiría «no hay secreto» de «no puedo leerlo» y la purga callaría para siempre.
start_game_session y save_score firman y verifican el token de sesión de partida con un secreto propio del Vault, game_session_secret — puramente de Postgres, no lo lee ninguna Edge Function ni entra en Vercel. En local lo siembra supabase/seed.sql en cada db reset, sin paso manual. En producción hay que crearlo a mano, una vez, antes del db push de esta spec: sin él, todo guardado real de puntuaciones queda roto (falla cerrado, no abierto) hasta crearlo.
openssl rand -base64 32 # el valor va solo en el sitio de abajo
select vault.create_secret('<el valor generado>', 'game_session_secret');
Nunca reutilizar el valor fijo de seed.sql en producción: es público, está en el repo, y reusarlo tira por la borda el techo de seguridad del token.
?dry_run=1 cuenta lo que se habría borrado sin borrar nada:
curl -i -X POST 'http://127.0.0.1:54321/functions/v1/borrar-invitados?dry_run=1' \
-H "Authorization: Bearer $PURGE_SECRET"
# {"dry_run":true,"candidates":12,"deleted":12,"failed":0,"error":null}
Sin Authorization o con una clave que no cuadra responde 401 y no toca la base. El guion completo de extremo a extremo, con el stack arrancado y npx supabase functions serve en otra terminal:
-- 1. Envejecer a los invitados existentes (abre antes una sesión con JUGAR COMO INVITADO).
update auth.users set last_sign_in_at = now() - interval '60 days' where is_anonymous;
select * from public.stale_guest_ids(30, 200); -- deben aparecer
-- 2. Disparar la purga como lo haría el cron.
select public.purge_guests_tick();
-- 3. Comprobar. pg_net es asíncrono: la respuesta tarda un instante en aparecer.
select status_code, content from net._http_response order by id desc limit 1;
select count(*) from auth.users where is_anonymous; -- 0
select * from public.guest_cleanup_runs order by id desc; -- una fila por pasada
Para ver el job: select jobname, schedule, active from cron.job;.
Vive en supabase/templates/confirmation.html y supabase/config.toml la enlaza. Eso solo vale para el stack local: en el proyecto remoto hay que copiarla a mano en Authentication → Email Templates. Si se olvida, el enlace del correo va al verificador por defecto de Supabase, /auth/confirm nunca se ejecuta y la confirmación no inicia sesión.
| Comando | Qué hace |
|---|---|
npm run dev | Servidor de desarrollo. También regenera el bloque nextjs-agent-rules de AGENTS.md. |
npm run build | Compilación de producción. |
npm run start | Sirve la compilación de producción (requiere un build previo). |
npm run lint | ESLint con configuración plana; recorre todo el proyecto, sin argumento --dir. |
npm test | Suite de Playwright completa, proyectos desktop y mobile. Su pretest resetea la base local, así que exige el stack de Supabase arrancado. |
npm run test:update | Igual, pero regenerando las capturas de referencia. |
npx tsc --noEmit | Comprobación de tipos. No hay script de npm para esto. |
npx supabase start / stop | Levanta o para el stack local (Postgres, Auth, Studio, Mailpit). |
npx supabase db reset | Recrea la base local: migración de supabase/migrations/ más supabase/seed.sql. |
| Ruta | Fichero | Qué muestra |
|---|---|---|
/ | app/page.tsx | Portada: hero, por qué Arcade Vault, avance de seis juegos, cifras, actividad en vivo, precios y llamada final. |
/biblioteca | app/biblioteca/page.tsx | Biblioteca: hero, buscador, chips de categoría y rejilla con los tres juegos, leídos de Supabase. |
/juego/[id] | app/juego/[id]/page.tsx | Detalle: portada grande, etiquetas, descripción, estadísticas y las diez mejores puntuaciones. notFound() si el id no existe. |
/jugar/[id] | app/jugar/[id]/page.tsx | Reproductor: HUD con puntuación, vidas y nivel, pausa, FIN y modal de fin. El registro ENGINES decide qué monta dentro: TETRIX, ASTEROIDES y ARKANOID llevan motor real; el resto, una escena CRT animada. notFound() si el id no existe. |
/auth | app/auth/page.tsx | Entrar, crear cuenta o jugar como invitado contra Supabase Auth. Aterriza en ?next= o, si no lo hay, en la biblioteca. |
/salon | app/salon/page.tsx | Salón de la Fama: podio, tabla de puntuaciones y selector de juego. |
/acerca | app/acerca/page.tsx | Acerca de: misión, destacados y formulario de contacto que envía por Resend. |
| — | app/not-found.tsx | Pantalla 404 con el tema arcade. |
| — | app/error.tsx | Límite de error de React con botón de reintento. |
Las URL están en español a propósito y coinciden con la maqueta original.
app/ # App Router: rutas, layout raíz y CSS global
layout.tsx # fuentes, metadatos, SessionProvider, Nav y Footer
globals.css # tema arcade completo (~2.600 líneas) + import de Tailwind
page.tsx # portada
biblioteca/page.tsx # biblioteca
juego/[id]/page.tsx # detalle del juego
jugar/[id]/page.tsx # reproductor
auth/page.tsx # entrar / crear cuenta
salon/page.tsx # salón de la fama
acerca/page.tsx # acerca de + contacto
auth/callback/route.ts # retorno de OAuth: canjea el code por la sesión
auth/confirm/route.ts # destino del enlace del correo de confirmación
api/contacto/route.ts # POST del formulario de contacto (Resend)
not-found.tsx # 404
error.tsx # límite de error
proxy.ts # refresca el token en cada petición y protege /jugar/[id]
components/ # componentes de interfaz
nav.tsx # barra, enlaces activos y panel móvil con la sesión dentro
footer.tsx # pie (componente de servidor)
session-provider.tsx # contexto de sesión sobre Supabase, expone useSession()
library-browser.tsx # buscador y filtrado por categoría
game-card.tsx # tarjeta con efecto tilt
leaderboard.tsx # tabla de puntuaciones del detalle (componente de servidor)
game-player.tsx # reproductor CRT, HUD y modal de fin de partida
tetris-game.tsx # canvas, bucle y mandos de TETRIX dentro del CRT
asteroids-game.tsx # canvas, bucle y mandos de ASTEROIDES dentro del CRT
arkanoid-game.tsx # canvas, bucle y mandos de ARKANOID dentro del CRT
auth-form.tsx # entrar, crear cuenta, OAuth y terminales de estado
hall-of-fame.tsx # podio y tabla del salón
use-reveal.ts # aparición de las secciones .reveal al hacer scroll
home/ # secciones de la portada (hero, features, carril,
# cifras, actividad, precios y cierre)
about/ # mitades de /acerca y el formulario de contacto
lib/
tetris.ts # motor de TETRIX: puro, sin DOM ni canvas (SPEC 13)
asteroids.ts # motor de ASTEROIDES: puro, sin DOM ni canvas (SPEC 14)
arkanoid.ts # motor de ARKANOID: puro, sin DOM ni canvas (SPEC 15)
supabase/
client.ts # cliente de navegador
server.ts # cliente de servidor sobre cookies()
session.ts # SessionUser y getServerSession()
games.ts # tipos + getGames()/getGameBySlug()/getCategorias() (SPEC 16, 17)
scores.ts # tipo ScoreRow + getLeaderboard()/getBestScores()/getUserBestScore() (SPEC 16, 17)
types.ts # tipos generados del esquema; se regeneran al cambiarlo
supabase/ # stack local y esquema
config.toml # configuración del stack de Docker
migrations/ # profiles, trigger de alta, RLS, purga de invitados, catálogo y puntuaciones
functions/
borrar-invitados/ # Edge Function que borra invitados por el Admin API
seed.sql # usuario de pruebas PX_KAI, confirmado
templates/ # plantilla del correo de confirmación
tests/ # suite de Playwright
screens.spec.ts
screens.spec.ts-snapshots/ # catorce capturas de referencia
specs/ # specs del proyecto, una por funcionalidad
references/templates/ # maqueta HTML/JSX original de la que salieron las pantallas
La suite vive entera en tests/screens.spec.ts (105 bloques test(), SPEC 28) y cubre humo, interacción y comparación visual. Se organiza por bloques: capturas de referencia, portada, biblioteca, detalle, reproductor, tetrix, asteroides, arkanoid, buscaminas, serpiente, mando de consola en móvil, auth, salón de la fama, acerca, endpoint de contacto y responsive, más los motores puros de ARKANOID y SERPIENTE sin navegador. Los bloques de juego no congelan el reloj: los motores necesitan requestAnimationFrame vivo, y ninguno afirma nada que dependa del azar de la partida.
Tres proyectos, todos sobre Chromium (playwright.config.ts):
| Proyecto | Viewport | Qué corre |
|---|---|---|
orden-global | 1440 × 900 | Solo los 3 tests @orden-global (ver abajo), una vez cada uno |
desktop | 1440 × 900 | El resto de la suite, salvo @orden-global |
mobile | iPhone 13 emulado (390 × 844) | El resto de la suite, salvo @orden-global |
El webServer de Playwright ejecuta npm run build y luego next start -p 3100, así que la primera ejecución tarda: se prueba contra la compilación de producción, no contra el servidor de desarrollo. Ese webServer fija las dos variables NEXT_PUBLIC_SUPABASE_* del stack local: son NEXT_PUBLIC_* y se incrustan en ese build, así que sin ellas la suite compilaría contra el proyecto remoto y crearía usuarios de verdad en cada ejecución.
La suite corre con workers: 2. Desde que la sesión es real, cada navegación pasa por el stack de Docker —el proxy valida el token y el layout resuelve la sesión—, y con un worker por núcleo Auth agota su pool de conexiones: las peticiones empiezan a morir con 504 sin que nada esté roto.
Condición de carrera del salón (arreglada en SPEC 28). Tres tests guardan una puntuación real de la cuenta semilla PX_KAI (ARKANOID, ASTEROIDES, BUSCAMINAS); otros asumen que ningún jugador tiene puntuación en ningún juego, o que PX_KAI no tiene marca en su pestaña por defecto del salón (siempre ARKANOID: empate de created_at, gana el slug). Con fullyParallel: true y workers: 2 no hay garantía de orden entre ellos. El arreglo: los tests que asumen el catálogo vacío llevan { tag: "@orden-global" }, y desktop/mobile declaran dependencies: ["orden-global"] + grepInvert: /@orden-global/ — así esos tres corren una sola vez, en serie, antes de que arranque el paralelismo habitual, y nunca compiten con los tests que guardan puntuación. Por separado, las pruebas de guardado real de ASTEROIDES y BUSCAMINAS llevan test.skip(isMobile, …) (mismo patrón que ya tenía ARKANOID): comparten cuenta y juego entre desktop y mobile, así que si los dos proyectos jugaran a la vez, el que puntuara menos no superaría la marca que acaba de guardar el otro.
El bloque registro por correo registra una cuenta nueva y lee el mensaje de Mailpit por su API (http://127.0.0.1:54324/api/v1/search) para seguir el enlace de confirmación. Busca por destinatario y no "el último mensaje": los dos proyectos pueden estar registrando a la vez.
Las capturas de referencia están en tests/screens.spec.ts-snapshots/: siete por proyecto, una por ruta. Regenéralas solo cuando un cambio visual sea intencionado, y solo las del proyecto afectado:
npx playwright test --project=mobile --update-snapshots # solo móvil
npm run test:update # todas
npx playwright test --project=desktop --update-snapshots=all # reescribe aunque pasen
Antes de regenerar, verifica el cambio a mano en el navegador. Una captura regenerada a ciegas convierte una regresión en la nueva referencia.
La comparación usa maxDiffPixelRatio: 0.01, así que un cambio visual pequeño —un enlace más en la barra, por ejemplo— no rompe la suite ni actualiza la referencia: --update-snapshots sólo reescribe lo que falla. Para poner las capturas al día tras un cambio así hace falta --update-snapshots=all.
La aplicación vive en Vercel, y hay un solo entorno remoto: producción. Se despliega desde main contra el proyecto de Supabase que ya existe — el mismo que tiene OAuth, captcha, migraciones y purga. No hay staging, ni segundo proyecto de Supabase, ni despliegues de preview por rama: lo que sustituye a la preview es la revisión local de siempre, npm run dev más las capturas de npm test.
| Local | Producción | |
|---|---|---|
| URL de la aplicación | http://localhost:3000 | https://arcade-vault-chi.vercel.app |
| Rama | la que sea | main, y solo main |
| Supabase | stack de Docker (supabase start) | el proyecto remoto |
| Captcha | apagado | activo |
| Resend | modo simulado | clave real |
| Purga de invitados | manual, tras crear los secretos | activa, 0 4 * * * UTC |
| Quién lo mira | tú, con npm run dev y npm test | cualquiera |
El nombre del proyecto en Vercel decide el dominio: arcade-vault daría arcade-vault.vercel.app si estuviera libre, pero no lo estaba — ese dominio es de otra aplicación ajena a este proyecto —, así que Vercel sufijó y el dominio real es arcade-vault-chi.vercel.app. El dominio que salga es el valor que va a las Redirect URLs de Supabase y a los hostnames de Turnstile, así que compruébalo en el panel de Vercel antes de seguir en vez de darlo por hecho a partir del nombre del proyecto.
Todo lo de este apartado es manual y se hace una sola vez. El repositorio no lo automatiza: no hay vercel.json ni acciones que hablen con Vercel.
Importar el repositorio. Vercel → Add New… → Project → importar arcade-vault desde GitHub.
No tocar la configuración de build. El preset Next.js se detecta solo y acierta: Root Directory la raíz, Build Command next build, Install Command npm ci. No se añade vercel.json — un fichero que solo repite los valores por defecto es una copia más que mantener, y no hay nada aquí que el preset no resuelva. La aplicación no es estática: proxy.ts corre en cada petición y las siete rutas son dinámicas porque el layout resuelve la sesión en servidor. El preset de Next lo sirve sin configuración ni adaptador.
Production Branch: main (Settings → Git).
Cortar los despliegues de rama. En Settings → Git, limitar los despliegues a la rama de producción. Una rama spec-NN-slug empujada a GitHub no debe producir ningún deployment ni gastar minutos de build. Si esa opción no estuviera en el plan, el equivalente es un Ignored Build Step:
# Ojo con los códigos, que son al revés de lo que parecen:
# exit 0 → cancela el build
# exit 1 → deja que continúe
[ "$VERCEL_ENV" = "production" ] && exit 1 || exit 0
Variables de entorno (abajo), todas marcadas solo para Production.
Desplegar y apuntar el dominio que ha quedado.
Project Settings → Environment Variables. Las seis, y solo en Production:
| Variable | Valor |
|---|---|
NEXT_PUBLIC_SUPABASE_URL | https://<ref>.supabase.co |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | la clave publicable del proyecto |
NEXT_PUBLIC_TURNSTILE_SITE_KEY | la site key de Turnstile |
RESEND_API_KEY | la clave real de Resend |
CONTACT_TO_EMAIL | el correo de destino |
CONTACT_FROM_EMAIL | Arcade Vault <[email protected]> |
Tres reglas que no conviene saltarse:
PURGE_SECRET y la clave de servicio son secretos de la Edge Function y del Vault de la base, no del hosting de Next. La secret key de Turnstile tampoco: esa la guarda el panel de Supabase, que es quien verifica el token contra Cloudflare.NEXT_PUBLIC_* se incrusta en el build. Cambiar una en el panel no toca lo ya desplegado: hay que hacer un Redeploy. Es el mismo motivo por el que playwright.config.ts las fija en el env de su webServer.No existe NEXT_PUBLIC_SITE_URL y no hace falta. components/auth-form.tsx construye emailRedirectTo y redirectTo con window.location.origin, y /auth/callback y /auth/confirm resuelven el destino contra la cabecera Host. El código ya es portable entre dominios; una variable con la URL del sitio solo podría desincronizarse.
El proyecto remoto ya quedó configurado en las SPEC 06 a 09. Lo único que cambia al publicar es el dominio desde el que se le habla:
https://arcade-vault-chi.vercel.app, y en Redirect URLs https://arcade-vault-chi.vercel.app/**. Sin esto, /auth/callback canjea el código y redirige a localhost: el jugador acaba en una pantalla sin sesión.*.vercel.app convertiría /auth/callback en un redirector abierto hacia cualquier despliegue de cualquier cuenta. El comodín de ruta (/**) sobre el hostname exacto sí es lo correcto.localhost puede quedarse en la lista: es lo que permite seguir probando OAuth en local contra el proyecto remoto.https://<ref>.supabase.co/auth/v1/callback, que no depende del dominio de la aplicación.arcade-vault-chi.vercel.app a los hostnames del widget. Vale el hostname exacto; un comodín sobre vercel.app no, porque es un sufijo público. Cuidado con registrar arcade-vault.vercel.app por descuido: es de otra aplicación, y el widget no validaría nada en el dominio real.Esto es lo que se repite en cada spec a partir de la SPEC 10:
Implementar. /spec-impl NN-slug crea la rama spec-NN-slug y ahí se trabaja.
Revisar en local. Este es el paso que sustituye a la preview, y es obligatorio:
npm run dev # recorrer a mano las pantallas que toca la spec
npx tsc --noEmit
npm run lint
npm test # con el stack de Docker levantado
Si la spec es visual, regenerar solo las capturas del proyecto afectado.
Abrir el pull request. git push -u origin spec-NN-slug. CI (.github/workflows/ci.yml) tiene que quedar en verde. Vercel no despliega nada en este paso.
Si la spec trae migraciones o Edge Functions, aplicarlas al proyecto remoto antes de mergear:
npx supabase link --project-ref <ref-prod>
npx supabase db push
npx supabase functions deploy <nombre> # solo si la spec toca funciones
La base primero, el código después. Al revés hay una ventana en la que el código nuevo pega contra un esquema viejo y la aplicación en vivo se rompe.
Con un solo entorno remoto, entre este paso y el merge el código que está en producción es el viejo corriendo contra el esquema nuevo. Por eso la migración tiene que ser compatible hacia atrás: añadir tablas, columnas o funciones, nunca renombrar ni borrar lo que el código en vivo usa. Retirar algo es una segunda spec, posterior al despliegue de la primera.
Merge a main. Vercel despliega a producción solo, sin intervención.
Comprobar en la URL de producción: las pantallas que tocaba la spec, y /auth con OAuth y como invitado. Si algo va mal, Instant Rollback al deployment anterior desde el panel, en segundos. El rollback devuelve el código, no el esquema — de ahí la regla del paso 4.
Cerrar la spec: estado Implementado en su cabecera y en el índice de abajo.
Borrar la rama y devolver la CLI a local:
npx supabase unlink
supabase link deja el project_ref en supabase/.temp/, y olvidarse de eso es lo que convierte el siguiente db reset en un susto.
Sin dominio propio, el SMTP por defecto de Supabase solo entrega a direcciones del equipo del proyecto, y Resend con [email protected] solo escribe a la dirección de la propia cuenta. En producción se entra por Google, por GitHub o como invitado; el registro con correo y contraseña queda a medias hasta que haya dominio propio y SMTP, que es otra spec. En local no afecta: los correos aterrizan en Mailpit.
Cada funcionalidad se escribe primero como spec y solo después como código. Tres skills gobiernan el flujo:
/spec — hace las preguntas necesarias y deja la spec en specs/NN-slug.md, en estado Borrador./spec-impl NN-slug — implementa una spec ya Aprobado, paso a paso, en la rama spec-NN-slug./add-game — el /spec específico de un juego nuevo: da por sentado el contrato que ya cumplen los cuatro motores y solo pregunta lo que de verdad está por decidir. De aquí salió la SPEC 20.Y un paso antes que todos ellos, el subagente game-planner (.agents/agents/game-planner.md), que decide qué juego entra: recalcula los huecos del catálogo desde las migraciones, pasa cada candidato por ocho puertas duras y devuelve una terna con una recomendación marcada. Su memoria entre ejecuciones es references/started-games/suggestions-games.md, donde queda cada sugerencia con su veredicto, incluidas las descartadas. No escribe specs ni código: la cadena es game-planner → /add-game → /spec-impl.
Las skills son las de Klerith/fernando-skills y están incluidas en el repo, en .agents/skills/, con enlaces simbólicos desde .claude/skills/ para que Claude Code las vea. El subagente sigue el mismo patrón desde .agents/agents/, con .claude/agents/game-planner.md como enlace simbólico al fichero versionado — y, a diferencia de las skills, un agente nuevo no aparece hasta reiniciar la sesión, porque el registro se lee al arrancar. Se instalaron con:
npx skills@latest add Klerith/fernando-skills
specs/.spec-config.yml controla la creación automática de rama (AutoCreateBranch).
| Spec | Estado | Depende de |
|---|---|---|
| 01 — MVP visual de las pantallas | Implementado | — |
| 02 — Barra móvil: sesión en la hamburguesa | Implementado | SPEC 01 |
| 03 — Documentación del repo | Implementado | SPEC 01, SPEC 02 |
04 — Portada en / y biblioteca en /biblioteca | Implementado | SPEC 01, SPEC 02, SPEC 03 |
05 — /acerca con contacto por Resend | Implementado | SPEC 01, SPEC 02, SPEC 03, SPEC 04 |
| 06 — Autenticación real con Supabase | Implementado | SPEC 01–05 |
| 07 — Modo invitado con sesión anónima | Implementado | SPEC 06 |
| 08 — Purga automática de invitados con pg_cron | Implementado | SPEC 07 |
| 09 — Captcha con Cloudflare Turnstile en |
Cosas detectadas y no arregladas, cada una porque cae fuera del alcance de la spec en la que aparecieron. Van aquí para que la siguiente las recoja, no para que se olviden.
getGames() (lib/supabase/games.ts) ordena con .order("created_at") y sin desempate. Los tres
primeros juegos entraron en un único insert de la misma migración, así que comparten un único now():
slug | created_at
asteroides | 2026-09-29 11:12:14.298547+00 ← las tres, el mismo valor
arkanoid | 2026-09-29 11:12:14.298547+00
tetrix | 2026-09-29 11:12:14.298547+00
buscaminas | 2026-09-29 11:12:14.453316+00 ← migración aparte, fecha propia
Con el empate, Postgres devuelve esas tres filas en un orden arbitrario que cambia entre reconstrucciones
de la base. Eso mueve las tarjetas de /biblioteca, el carril de / y las pestañas de /salon, y deja
cinco pruebas rojas de forma permanente: home › el carril enlaza al detalle de cada juego, las
capturas home y biblioteca en mobile, y las dos de salón de la fama. La firma que lo confirma es
que BUSCAMINAS nunca se mueve: es la única fila con fecha propia.
No se arregló en la SPEC 21 porque el arreglo —un desempate en la consulta, o una columna orden en
public.games— cambia el orden del catálogo en toda la aplicación y obliga a regenerar capturas que
esa spec se comprometió a no tocar. Es una decisión de producto, no una errata: qué orden quiere verse en
la biblioteca es algo que hay que decidir, no deducir.
registro por correo › el enlace de Mailpit confirma la cuenta y deja dentro termina en
/auth?error=confirm en los dos proyectos. Se reprodujo también sobre main limpio, así que no lo
introdujo ninguna spec reciente. Sin diagnosticar.
El token de start_game_session certifica que un usuario real cargó /jugar/[id] hace poco para ese
juego — no que la puntuación que manda save_score se jugó de verdad. Un usuario autenticado puede
navegar a mano a /jugar/<slug>?puntuacion=<N>&nivel=<M> y autoguardar <N> como marca suya, siempre
que <N> no pase del techo global (10.000.000) y <M> sea un nivel real de ese juego. Queda acotado
—no es ajeno a la cuenta de quien lo hace, no escala, caduca a los 60 minutos— pero sigue siendo
tramposo. Aceptado a propósito en la SPEC 29 (ver su sección «Riesgos identificados»); dos vías para
cerrarlo de verdad, candidatas a spec futura:
save_score exigir que la puntuación no supere un tope de "puntos por segundo" propio
de cada juego multiplicado por now() - token.issued_at. Cambio pequeño —un campo más en el token y
una cuenta en la función—, pero no es a prueba de balas: deja la pestaña abierta el tiempo que haga
falta y el ataque sigue siendo posible, solo que acotado a lo "cronológicamente plausible".lib/tetris.ts, lib/asteroides.ts,
lib/arkanoid.ts, lib/buscaminas.ts y lib/serpiente.ts ya son funciones puras sin document ni
window — podrían correr en una Edge Function. El cliente mandaría el registro de inputs
(tecla + marca de tiempo) en vez del score; el servidor reproduce la partida con el mismo motor y
calcula la puntuación él mismo, así que nunca hay un número que venga del cliente en el que confiar.
Mucho más trabajo —capturar y transmitir el input stream, mantener el replay sincronizado con
cualquier cambio futuro en las reglas de cada motor, hacerlo para los cinco juegos—, pero es la única
vía que cierra el riesgo del todo en vez de acotarlo.references/templates/ guarda la maqueta original de la que salieron las pantallas: HTML con React UMD y Babel en el navegador, más un styles.css de 950 líneas. En references/templates/home-about/ está la maqueta de la portada y de la pantalla «Acerca de». No se compila ni se despliega; sigue en el repo como fuente visual de verdad para comparar cuando un estilo portado no cuadra.
/auth| Implementado |
| SPEC 07 |
10 — Despliegue en Vercel: producción desde main | Implementado | SPEC 09 |
11 — Captcha visible en /auth | Implementado | SPEC 09 |
| 12 — Correcciones responsive en móvil | Implementado | SPEC 11 |
| 13 — TETRIX: primer juego con motor real | Implementado | SPEC 01, SPEC 04 |
| 14 — ASTEROIDES: segundo juego con motor real | Implementado | SPEC 01, SPEC 04, SPEC 13 |
| 15 — ARKANOID: tercer juego con motor real | Implementado | SPEC 01, SPEC 04, SPEC 13 |
| 16 — Tablas Supabase para juegos y puntuaciones | Implementado | SPEC 06, SPEC 13, SPEC 14, SPEC 15 |
| 17 — Catálogo real desde Supabase y limpieza de decorativos | Implementado | SPEC 06, SPEC 13, SPEC 14, SPEC 15, SPEC 16 |
18 — Guardado real de puntuaciones en scores | Implementado | SPEC 07, SPEC 16, SPEC 17 |
| 19 — Pantalla completa en el reproductor, solo en móvil | Implementado | SPEC 12, SPEC 13, SPEC 14, SPEC 15, SPEC 18 |
| 20 — BUSCAMINAS: cuarto motor jugable | Implementado | SPEC 01, 04, 13, 14, 15, 16, 17, 18, 19 |
| 21 — Mando de consola en móvil: los controles salen del CRT | Implementado | SPEC 12, 13, 14, 15, 19, 20 |
| 22 — El MENÚ dentro del tubo: el fin de partida sale del modal | Aprobado | SPEC 18, SPEC 19, SPEC 21 |
| 24 — ARKANOID: premios que caen, segunda bola y pala que crece | Implementado | SPEC 15, SPEC 18, SPEC 21, SPEC 22 |
| 25 — SERPIENTE: quinto motor jugable | Aprobado | SPEC 01, 04, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23 |
| 26 — Versión 1.0.0 y blog de cambios | Implementado | SPEC 01, SPEC 02 |
| 27 — Consultas redundantes, redirección de invitado y actividad real del home | Implementado | SPEC 04, 06, 07, 16, 17, 18, 26 |
| 28 — Limpieza de la suite de tests y condición de carrera del salón | Implementado | SPEC 06, 07, 16, 17, 18, 21, 22, 26, 27 |
29 — RLS en scores y token de sesión de partida | Implementado | SPEC 06, 07, 08, 16, 17, 18, 26 |
| 30 — Checklist básico de seguridad | Implementado | SPEC 16, SPEC 29 |
| 31 — Cierre de warnings del Advisor de seguridad de Supabase | Implementado | SPEC 08, SPEC 16, SPEC 29, SPEC 30 |
| 32 — Replay en servidor para TETRIX | Implementado | SPEC 13, SPEC 18, SPEC 29 |
| 33 — Replay en servidor para SERPIENTE | Implementado | SPEC 25, SPEC 29, SPEC 32 |
| 34 — Replay en servidor para BUSCAMINAS | Implementado | SPEC 20, SPEC 29, SPEC 32, SPEC 33 |
| 35 — Replay en servidor para ASTEROIDES | Implementado | SPEC 14, SPEC 29, SPEC 32, SPEC 33 |
| 36 — Replay en servidor para ARKANOID | Implementado | SPEC 15, SPEC 24, SPEC 29, SPEC 32, SPEC 33, SPEC 35 |