// DEPLOY_CONFIG: {"collections": [{"name": "game_history", "schema": {"game_id": "string", "played_at": "datetime", "winner_id": "string", "winner_name": "string", "score": "number", "metadata": "json"}}, {"name": "win_records", "schema": {"user_id": "string", "user_name": "string", "wins": "number", "last_win_at": "datetime"}}]} import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useIdentity, useCollection, useBroadcast, usePresence } from '@deplixo/sdk'; import { HostView } from './components/HostView.jsx'; import { PlayerView } from './components/PlayerView.jsx'; import { LobbyScreen } from './components/LobbyScreen.jsx'; function normalizeRoomCode(value) { return String(value || '') .trim() .toUpperCase() .replace(/[^A-Z0-9]/g, '') .slice(0, 8); } function makeRoomCode() { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let code = ''; for (let i = 0; i < 6; i += 1) { code += chars[Math.floor(Math.random() * chars.length)]; } return code; } function playSoundEffect(name) { try { if (typeof window === 'undefined') return; const AudioCtor = window.Audio; if (!AudioCtor) return; const sounds = { mark: 'https://actions.google.com/sounds/v1/cartoon/pop.ogg', bingo: 'https://actions.google.com/sounds/v1/cartoon/ta_da.ogg', call: 'https://actions.google.com/sounds/v1/cartoon/clang_and_wobble.ogg', }; const src = sounds[name]; if (!src) return; const audio = new AudioCtor(src); audio.volume = 0.7; audio.play().catch(() => {}); } catch (error) { // no-op } } function App() { const { user, loading: idLoading } = useIdentity(); const [role, setRole] = useState(null); const [roomCodeInput, setRoomCodeInput] = useState(''); const [activeRoomCode, setActiveRoomCode] = useState(''); const [joinError, setJoinError] = useState(''); const [broadcastCall, setBroadcastCall] = useState(null); const [broadcastEvent, setBroadcastEvent] = useState(null); const [callIntervalMs, setCallIntervalMs] = useState(10000); const [callTimerEnabled, setCallTimerEnabled] = useState(false); const timerRef = useRef(null); const { items: gameStateItems, loading: gsLoading, add: addGameState, update: updateGameState } = useCollection('gameState', { personal: false }); const { items: roomMembers, loading: roomsLoading, add: addRoomMember, update: updateRoomMember } = useCollection('roomMembers', { personal: false }); const { items: userDataItems, add: addUserData, update: updateUserData } = useCollection('userData', { personal: true }); const gameStates = Array.isArray(gameStateItems) ? gameStateItems : []; const rooms = Array.isArray(roomMembers) ? roomMembers : []; const getOrCreateStableUserId = useCallback(() => { const USER_ID_KEY = 'deplixo_user_id'; let userId = userDataItems.find(item => item.key === USER_ID_KEY)?.value; if (!userId) { userId = `user_${crypto.randomUUID()}`; addUserData({ key: USER_ID_KEY, value: userId }); } return userId; }, [userDataItems, addUserData]); const userRoomMemberships = useMemo(() => { if (!user?.id) return []; return rooms.filter((room) => room?.userId === user.id); }, [rooms, user?.id]); const activeMembership = useMemo(() => { if (!user?.id) return null; return userRoomMemberships.find((room) => room?.roomCode === activeRoomCode) || null; }, [user?.id, userRoomMemberships, activeRoomCode]); const activeRoomState = useMemo(() => { if (!activeRoomCode) return null; const localState = gameStates.find((state) => state?.roomCode === activeRoomCode) || null; if (!localState) return null; if (broadcastCall && broadcastCall.roomCode === activeRoomCode) { return { ...localState, currentCall: broadcastCall.currentCall ?? localState.currentCall, gameStarted: broadcastCall.gameStarted ?? localState.gameStarted }; } return localState; }, [gameStates, activeRoomCode, broadcastCall]); const [presenceState, setPresenceState] = useState({ users: [], update: null }); const { users: presenceUsers, update: updatePresence } = usePresence({ userId: user?.id || '', userName: user?.name || 'Player', roomCode: activeRoomCode || '', role: role || '', }); useEffect(() => { setPresenceState({ users: presenceUsers || [], update: updatePresence }); }, [presenceUsers, updatePresence]); const handleRoomCallBroadcast = useCallback((data) => { if (!data?.roomCode) return; if (data.roomCode !== activeRoomCode) return; setBroadcastEvent(data); if (Object.prototype.hasOwnProperty.call(data, 'currentCall') || Object.prototype.hasOwnProperty.call(data, 'gameStarted')) { setBroadcastCall({ roomCode: data.roomCode, currentCall: data.currentCall ?? null, gameStarted: data.gameStarted ?? false, type: data.type || 'call', calledAt: data.calledAt || Date.now(), }); if (Object.prototype.hasOwnProperty.call(data, 'currentCall')) { playSoundEffect('call'); } } }, [activeRoomCode]); const { send: sendRoomBroadcast } = useBroadcast('bingo-room-event', handleRoomCallBroadcast); useEffect(() => { if (!activeRoomCode) return; if (typeof updatePresence === 'function') { updatePresence({ userId: user?.id || '', userName: user?.name || 'Player', roomCode: activeRoomCode, role: role || '', }); } }, [activeRoomCode, role, user?.id, user?.name, updatePresence]); const activeRoomPresenceCount = useMemo(() => { const list = Array.isArray(presenceState.users) ? presenceState.users : []; if (!activeRoomCode) return 0; return list.filter((u) => u?.roomCode === activeRoomCode).length; }, [presenceState.users, activeRoomCode]); const createRoom = async (desiredRole) => { const roomCode = makeRoomCode(); const roomId = `${roomCode}-${Date.now()}`; setJoinError(''); setActiveRoomCode(roomCode); setRole(desiredRole); if (typeof addRoomMember === 'function') { await addRoomMember({ roomId, roomCode, userId: user?.id, userName: user?.name || 'Player', role: desiredRole, createdAt: Date.now() }); } if (desiredRole === 'host' && typeof addGameState === 'function') { await addGameState({ roomId, roomCode, hostId: user?.id || '', gameStarted: false, currentCall: null, calledItems: [], winners: [], players: [], callTimerEnabled: false, callIntervalMs: 10000, createdAt: Date.now(), }); } }; const joinRoom = async (desiredRole) => { const roomCode = normalizeRoomCode(roomCodeInput); if (!roomCode) { setJoinError('Enter a room code to join.'); return; } const existingRoom = rooms.find((room) => room?.roomCode === roomCode); const existingState = gameStates.find((state) => state?.roomCode === roomCode); if (!existingRoom && !existingState) { setJoinError('Room not found. Ask the host for the code.'); return; } setJoinError(''); setActiveRoomCode(roomCode); setRole(desiredRole); const roomId = existingRoom?.roomId || existingState?.roomId || `${roomCode}-${Date.now()}`; if (typeof addRoomMember === 'function') { await addRoomMember({ roomId, roomCode, userId: user?.id, userName: user?.name || 'Player', role: desiredRole, joinedAt: Date.now(), }); } if (desiredRole === 'host' && !existingState && typeof addGameState === 'function') { await addGameState({ roomId, roomCode, hostId: user?.id || '', gameStarted: false, currentCall: null, calledItems: [], winners: [], players: [], callTimerEnabled: false, callIntervalMs: 10000, createdAt: Date.now(), }); } }; const switchRoom = (roomCode, nextRole) => { setActiveRoomCode(roomCode); setRole(nextRole || 'player'); setJoinError(''); }; const handleHostCallBroadcast = useCallback((payload) => { if (typeof sendRoomBroadcast === 'function') { sendRoomBroadcast({ ...payload, roomCode: activeRoomCode, sentBy: user?.id || '', sentByName: user?.name || 'Player', }); } }, [activeRoomCode, sendRoomBroadcast, user?.id, user?.name]); const handleRoomEventBroadcast = useCallback((payload) => { if (typeof sendRoomBroadcast === 'function') { if (payload?.type === 'mark-cell') playSoundEffect('mark'); if (payload?.type === 'bingo') playSoundEffect('bingo'); sendRoomBroadcast({ ...payload, roomCode: activeRoomCode, sentBy: user?.id || '', sentByName: user?.name || 'Player', }); } }, [activeRoomCode, sendRoomBroadcast, user?.id, user?.name]); const updateRoomState = useCallback(async (patch) => { const roomState = gameStates.find((state) => state?.roomCode === activeRoomCode); if (!roomState || typeof updateGameState !== 'function') return; await updateGameState(roomState.id || roomState.roomId, patch); }, [activeRoomCode, gameStates, updateGameState]); const setCallTimerState = useCallback(async (enabled, intervalMs) => { setCallTimerEnabled(enabled); setCallIntervalMs(intervalMs); await updateRoomState({ callTimerEnabled: enabled, callIntervalMs: intervalMs }); }, [updateRoomState]); const callNextFromTimer = useCallback(() => { handleHostCallBroadcast({ type: 'timer-call-next', action: 'call-next' }); }, [handleHostCallBroadcast]); useEffect(() => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } if (!callTimerEnabled || role !== 'host' || !activeRoomCode) return; timerRef.current = setInterval(() => { callNextFromTimer(); }, Math.max(3000, Number(callIntervalMs) || 10000)); return () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } }; }, [callTimerEnabled, callIntervalMs, role, activeRoomCode, callNextFromTimer]); if (idLoading || gsLoading || roomsLoading) { return (
Loading Bingo...
Welcome, {user?.name || 'Player'}!
{joinError}
: null}