Juego multijugador online de póker de dados con física 3D, invitaciones por push y modo bot IA.
Bule Bule es una webapp multijugador en tiempo real que implementa el juego tradicional de póker de dados (caras AS, K, Q, J, 8, 7). Los jugadores se reúnen en salas con código, turnan para tirar dados, descartar y quedarse con la mejor mano posible. Quien pierde cada ronda rompe un palillo; sin palillos queda en capilla y una derrota más lo elimina. Un repóker (quintilla) libera al jugador de la partida.
Salas con código de 4 caracteres. Hasta 10 jugadores. Lobby de espera con vista en tiempo real vía WebSocket.
Dados con Three.js y Rapier3D. Sombras PCF, rebotes y colisiones deterministas por semilla de tirada.
Modo 1 vs Bot. La IA evalúa la mano, decide qué dados guardar y cuándo plantarse según probabilidad.
Palillos visuales por jugador. Capilla, liberación por quintilla y modo de juego limitado por rondas.
Web Push VAPID para invitar amigos aunque la app esté cerrada. Búsqueda de usuarios por nombre.
Login opcional con Google. Perfil editable (nombre, avatar). Los no registrados juegan como invitado.
Cubilete, rebotes, descarte, palillo roto, música de lobby y partida. Todo sincronizado con animaciones.
Agitar el móvil para tirar, gestos táctiles para descartar dados. Service worker instalable.
| Mano | Rango | Descripción |
|---|---|---|
| Quintilla | 7 | 5 iguales → libera al jugador |
| Póker | 6 | 4 iguales |
| Full | 5 | 3 + 2 iguales |
| Escalera | 4 | 5 valores consecutivos |
| Trío | 3 | 3 iguales |
| Dobles parejas | 2 | 2 pares distintos |
| Pareja | 1 | 2 iguales |
| Carta alta | 0 | Sin combinación |
Arquitectura de tres capas
| Capa | Tecnología | Versión | Rol |
|---|---|---|---|
| UI framework | React | 18.3 | Gestión de estado y render |
| Bundler | Vite | 5.4 | Build, HMR, dev server |
| 3D render | Three.js | 0.184 | Escena WebGL, materiales, sombras PCFSoft |
| Física | Rapier3D (WASM) | 0.19 | Simulación de cuerpos rígidos de dados |
| Tiempo real | Socket.io | 4.7 | Comunicación bidireccional cliente-servidor |
| Auth | @react-oauth/google | 0.13 | OAuth 2.0 con Google |
| Backend HTTP | Express | 4.18 | API REST mínima + VAPID endpoint |
| Push | web-push | 3.6 | Notificaciones push VAPID/WebPush |
| Hosting frontend | Firebase Hosting | — | CDN global, HTTPS automático |
| Hosting backend | Railway | — | Contenedor Node.js, auto-SSL |
Protocolo WebSocket cliente ↔ servidor
bule-bule/
├── backend/
│ ├── server.js # Lógica de servidor (659 líneas)
│ ├── gameLogic.js # Evaluación y comparación de manos (51 líneas)
│ ├── Dockerfile
│ └── railway.json
└── frontend/
├── public/
│ └── assets/ # Imágenes, audios (.mp3)
└── src/
├── App.jsx # Routing, música, auth (controlador raíz)
├── socket.js # Singleton Socket.io
├── index.css # Todos los estilos (1.544 líneas)
└── components/
├── GameBoard.jsx # Pantalla de juego principal
├── DiceRollerScene.jsx # Escena 3D Three.js + Rapier
├── AnimacionNextPlayer.jsx
├── AnimacionPalilloRoto.jsx
├── WaitingRoom.jsx
├── RoomList.jsx / CreateRoom.jsx
├── UserSettings.jsx
└── Die.jsx / AlaCaidaToast.jsx
report_faces sincroniza la cara visible con el servidor.preventDefault en touchend para móvil.awaitContinue serverside, contador 30s.BOT_CONTINUE_TIMEOUT separado del humano (30s).ended del mp3.world.timestep × 1.15. Ghost colliders fijos para dados quietos durante nueva tirada.rooms{} — diccionario en memoria indexado por código de sala. Cada sala es un objeto mutable con jugadores, fase de juego, índice del jugador activo, timers y metadatos de ronda.
registeredUsers{} — usuarios OAuth en sesión. Mapea userId → { name, email, picture, socketId, pushSubscription }.
Módulo puro sin efectos secundarios. Exporta rollDie(), evaluateHand(dice[]) y compareHands(h1, h2).
rollDie() usa Math.random() serverside. La semilla (rollSeed) es solo para animación visual. Si report_faces diverge, la mano mostrada puede no coincidir con la evaluada.Ciclo de vida de una sala
| Timer | Duración | Acción al expirar |
|---|---|---|
turnTimerId | 30s | Si no ha tirado: tira automático. Si ya tiró: planta. |
continueTimerId | 30s (human) / 3s (bot) | Llama a proceedTurn, arrancando el turno. |
setTimeout único por sala. Una reconexión con ID diferente durante el turno puede ejecutar finishTurn sobre el jugador equivocado si el índice se modificó mientras tanto.El jugador desconectado permanece en sala 20 segundos para permitir reconexiones. Si era el host, el siguiente jugador hereda el rol.
currentPlayer. Si le toca tirar, el turno queda bloqueado hasta que expire el turnTimer (hasta 30s adicionales). Total: hasta 50s de partida congelada sin notificación.Fases del bot — sincronización servidor ↔ cliente
bot_ready (fallo de render, tab en segundo plano), el bot queda bloqueado indefinidamente. Hay un fallback de 8s en el cliente, pero no existe timeout serverside para botPhase.Tras cada tirada, el cliente reporta las caras físicas visibles al servidor. El servidor valida que sean 5 valores del conjunto válido.
Si no se configuran VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY, el servidor genera nuevas claves en cada inicio.
Configurado con origin: '*' tanto en Express como en Socket.io.
registeredUsers{}. Sin rate limiting en ningún evento Socket.io. Un uso abusivo puede agotar la memoria del proceso.Gestiona la pantalla activa ('list' | 'create' | 'settings'), estado de usuario, música, service worker y reconexión por URL (?join=CODE). Singleton de socket importado de socket.js — una sola conexión por sesión.
room_state, muestra WaitingRoom o GameBoard según room.phase.Audio precargadas: musicRef (lobby) y gameMusicRef (partida).Componente principal. Gestiona pendingDiscards[], sceneValues / rollingIndices / rollSeed, botDiscards[], detección de nueva tirada, animaciones de cambio de jugador y palillo roto, shake-to-roll e intercepción de pull-to-refresh.
toggleDiscard actualiza estado local sin confirmación del servidor. Si la conexión cae justo antes de emitir roll, los descartes marcados localmente persisten hasta la reconexión.turnDeadline) se calcula como diferencia entre Date.now() cliente y timestamp de servidor. Una desincronización de reloj puede mostrar 0s aunque el turno no haya expirado.Gestiona el ciclo completo: sonidos → física → animación → callback.
idle reciben un cuerpo fijo temporal durante doRoll para que los dados nuevos reboten contra ellos.ctx.pendingRoll. Si el componente se desmontó mientras cargaba, la tirada se pierde silenciosamente._eliminarAudio.duration. Si el audio no ha cargado (primera tirada muy rápida), duration es NaN y se usa el fallback de 800ms, que puede no coincidir con el mp3 real.| Archivo | Trigger | Implementación |
|---|---|---|
dado1/2/3.mp3 | Rebote físico dado | Rotación cíclica, volumen proporcional al impacto |
cubilete.mp3 | Antes de lanzar dados | La física espera al evento ended |
eliminar_dados.mp3 | Fade-out de dados | Duración del mp3 = duración del tween α→0 |
cogerdado.mp3 | Marcar dado para descartar | Click táctil/ratón + bot picking |
romper_palillo.mp3 | Fin de ronda con perdedor | Simultáneo con AnimacionPalilloRoto |
button_press.mp3 | Cualquier botón | Listener global en document, excluye "Tirar dados" |
bule-escaleira.mp3 | Pantalla de inicio | Loop, pausa al entrar en partida |
dice-lemonlight.mp3 | Durante la partida | Loop vol 0.2, para al volver al lobby |
rooms{} y registeredUsers{} a Redis. Permite reiniciar el servidor sin perder partidas y escalar con Socket.io adapter.report_faces y calcular la orientación en servidor.origin: 'https://bulebule.web.app' en Express y Socket.io.roll/s).Arquitectura propuesta para escalar a múltiples instancias
| Área | Opción actual | Alternativa | Motivo |
|---|---|---|---|
| Estado servidor | In-memory | Redis | Persistencia, multi-instancia |
| Base de datos | — | PostgreSQL + Prisma | Historial, stats, usuarios |
| Deploy backend | Railway | Fly.io / Render | Mejor pricing a escala, regiones |
| Auth | OAuth manual | Clerk / Auth.js | Sesiones, múltiples proveedores |
| Tests | — | Vitest + Playwright | Unit + E2E multijugador |
| Monitoring | — | Sentry + Uptime Robot | Errores + disponibilidad |
| Physics | Rapier WASM | — | Rapier es ya la mejor opción en JS |