import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useBroadcast, useCollection, useIdentity, usePresence } from '@deplixo/sdk'; import { Lobby } from './components/Lobby.jsx'; import { GameBoard } from './components/GameBoard.jsx'; const TURN_LIMIT_MS = 60 * 1000; const TURN_TICK_MS = 250; const TIMER_EVENT = 'wordplay-turn-timer'; const PRESENCE_EVENT = 'wordplay-presence-sync'; const CHAT_EVENT = 'wordplay-room-chat'; const DEFAULT_PLAYERS = 2; function App() { const { user, loading: identityLoading } = useIdentity(); const [currentRoom, setCurrentRoom] = useState(null); const [gameId, setGameId] = useState(null); const [presenceState, setPresenceState] = useState({ roomId: null, gameId: null, status: 'connected' }); const [chatMessages, setChatMessages] = useState([]); const [chatInput, setChatInput] = useState(''); const [chatOpen, setChatOpen] = useState(false); const [timerState, setTimerState] = useState({ active: false, expiresAt: null, remainingMs: TURN_LIMIT_MS, playerId: null, playerName: null, isMyTurn: false, lastExpiredTurnId: null, turnId: null, }); const timerRef = useRef(null); const activeTurnRef = useRef(null); const chatEndRef = useRef(null); const leaderboardUpdatedTurnsRef = useRef(new Set()); const { items: leaderboardItems, loading: leaderboardLoading, add: addLeaderboardEntry, update: updateLeaderboardEntry } = useCollection( 'leaderboard', useMemo(() => ({ sortBy: 'totalScore', sortDirection: 'desc', }), []) ); const { users: presenceUsers, update: updatePresence } = usePresence( useMemo( () => ({ userId: user?.id || null, userName: user?.name || 'Player', roomId: currentRoom, gameId, status: presenceState.status, }), [user?.id, user?.name, currentRoom, gameId, presenceState.status] ) ); const handleChatMessage = useCallback((data, authorId) => { if (!data || data.roomId !== currentRoom) return; setChatMessages((prev) => [ ...prev, { id: data.id || `${Date.now()}-${authorId || 'anon'}`, roomId: data.roomId, gameId: data.gameId || null, authorId: data.authorId || authorId || null, authorName: data.authorName || 'Player', text: data.text || '', createdAt: data.createdAt || Date.now(), isSystem: !!data.isSystem, }, ]); }, [currentRoom]); const { send: sendTimerEvent } = useBroadcast(TIMER_EVENT, useCallback(() => {}, [])); const { send: sendPresenceEvent } = useBroadcast(PRESENCE_EVENT, useCallback(() => {}, [])); const { send: sendChatEvent } = useBroadcast(CHAT_EVENT, handleChatMessage); const clearTurnTimer = useCallback(() => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } }, []); const pushChatMessage = useCallback((message) => { setChatMessages((prev) => [...prev, { ...message, id: message.id || `${Date.now()}-${Math.random()}` }]); }, []); const syncPresence = useCallback( (status, overrides = {}) => { const nextPresence = { userId: user?.id || null, userName: user?.name || 'Player', roomId: currentRoom, gameId, status, updatedAt: Date.now(), ...overrides, }; setPresenceState((prev) => ({ ...prev, ...nextPresence })); updatePresence(nextPresence); sendPresenceEvent(nextPresence); }, [currentRoom, gameId, sendPresenceEvent, updatePresence, user?.id, user?.name] ); const stopTurnTimer = useCallback(() => { clearTurnTimer(); setTimerState((prev) => ({ ...prev, active: false, expiresAt: null, remainingMs: TURN_LIMIT_MS, playerId: null, playerName: null, isMyTurn: false, turnId: null, })); activeTurnRef.current = null; }, [clearTurnTimer]); const recordLeaderboardScore = useCallback(async (turn) => { if (!turn || !turn.playerId || !turn.turnId || leaderboardUpdatedTurnsRef.current.has(turn.turnId)) return; leaderboardUpdatedTurnsRef.current.add(turn.turnId); const scoreDelta = typeof turn.scoreDelta === 'number' ? turn.scoreDelta : 1; const playerName = turn.playerName || user?.name || 'Player'; const now = Date.now(); const existing = (leaderboardItems || []).find((entry) => (entry.playerId || entry.userId || entry.id) === turn.playerId); if (existing?.id) { await updateLeaderboardEntry(existing.id, { ...existing, playerId: turn.playerId, playerName, totalScore: Number(existing.totalScore || 0) + scoreDelta, gamesPlayed: Number(existing.gamesPlayed || 0) + 1, lastPlayedAt: now, updatedAt: now, }); return; } await addLeaderboardEntry({ playerId: turn.playerId, playerName, totalScore: scoreDelta, gamesPlayed: 1, lastPlayedAt: now, createdAt: now, updatedAt: now, }); }, [addLeaderboardEntry, leaderboardItems, updateLeaderboardEntry, user?.name]); const startTurnTimer = useCallback((turn) => { if (!turn || !turn.playerId) return; const expiresAt = Date.now() + TURN_LIMIT_MS; activeTurnRef.current = turn.turnId || `${turn.playerId}-${expiresAt}`; clearTurnTimer(); setTimerState({ active: true, expiresAt, remainingMs: TURN_LIMIT_MS, playerId: turn.playerId, playerName: turn.playerName || null, isMyTurn: user?.id ? turn.playerId === user.id : false, lastExpiredTurnId: null, turnId: activeTurnRef.current, }); syncPresence(turn.playerId === user?.id ? 'in-game' : 'in-game', { activeTurnId: activeTurnRef.current, activePlayerId: turn.playerId, activePlayerName: turn.playerName || null, turnExpiresAt: expiresAt, }); timerRef.current = setInterval(() => { const remaining = Math.max(0, expiresAt - Date.now()); setTimerState((prev) => ({ ...prev, remainingMs: remaining, active: remaining > 0, })); if (remaining <= 0) { clearTurnTimer(); const expiredTurnId = activeTurnRef.current; setTimerState((prev) => ({ ...prev, active: false, expiresAt: null, remainingMs: 0, lastExpiredTurnId: expiredTurnId, })); sendTimerEvent({ roomId: currentRoom, gameId, type: 'turn-expired', turnId: expiredTurnId, playerId: turn.playerId, playerName: turn.playerName || null, }); } }, TURN_TICK_MS); }, [clearTurnTimer, gameId, currentRoom, sendTimerEvent, syncPresence, user?.id]); useEffect(() => { return () => clearTurnTimer(); }, [clearTurnTimer]); useEffect(() => { if (!currentRoom || !gameId) { stopTurnTimer(); syncPresence(currentRoom ? 'connected' : 'connected', { roomId: currentRoom, gameId: null }); setChatOpen(false); setChatMessages([]); setChatInput(''); return; } const fallbackTurn = { turnId: `${gameId}-${currentRoom}-turn-1`, playerId: user?.id || 'player-1', playerName: user?.name || 'Player', scoreDelta: 1, }; syncPresence('in-game', { roomId: currentRoom, gameId, activeTurnId: fallbackTurn.turnId }); startTurnTimer(fallbackTurn); setChatOpen(true); pushChatMessage({ roomId: currentRoom, gameId, authorId: 'system', authorName: 'System', text: 'You joined the room. Chat is now available.', createdAt: Date.now(), isSystem: true, }); }, [currentRoom, gameId, startTurnTimer, stopTurnTimer, syncPresence, user?.id, user?.name, pushChatMessage]); useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [chatMessages, chatOpen]); const handleJoinGame = useCallback((roomId, gId) => { setCurrentRoom(roomId); setGameId(gId); syncPresence('ready', { roomId, gameId: gId }); setChatMessages((prev) => prev.filter((message) => message.roomId === roomId || message.isSystem)); }, [syncPresence]); const handleLeave = useCallback(() => { stopTurnTimer(); pushChatMessage({ roomId: currentRoom, gameId, authorId: 'system', authorName: 'System', text: 'You left the room.', createdAt: Date.now(), isSystem: true, }); syncPresence('connected', { roomId: null, gameId: null }); setCurrentRoom(null); setGameId(null); setChatMessages([]); setChatInput(''); setChatOpen(false); }, [currentRoom, gameId, pushChatMessage, stopTurnTimer, syncPresence]); const handleSendChat = useCallback(() => { const text = chatInput.trim(); if (!text || !currentRoom) return; const message = { id: `${Date.now()}-${user?.id || 'me'}`, roomId: currentRoom, gameId, authorId: user?.id || null, authorName: user?.name || 'Player', text, createdAt: Date.now(), isSystem: false, }; pushChatMessage(message); sendChatEvent(message); setChatInput(''); }, [chatInput, currentRoom, gameId, sendChatEvent, pushChatMessage, user?.id, user?.name]); const playerPresenceList = useMemo(() => { const localPlayers = presenceUsers?.length ? presenceUsers : []; const mapped = localPlayers.map((p) => ({ id: p.userId || p.id || p.clientId || p.name, name: p.userName || p.name || 'Player', status: p.status || (p.gameId ? 'in-game' : p.roomId ? 'ready' : 'connected'), isMe: (p.userId || p.id) && user?.id ? (p.userId || p.id) === user.id : false, })); if (!mapped.length && user) { mapped.push({ id: user.id, name: user.name || 'Player', status: presenceState.status, isMe: true, }); } return mapped; }, [presenceState.status, presenceUsers, user]); const roomChatMessages = useMemo(() => chatMessages.filter((message) => message.roomId === currentRoom), [chatMessages, currentRoom]); const leaderboardRows = useMemo(() => { return (leaderboardItems || []) .slice() .sort((a, b) => Number(b.totalScore || 0) - Number(a.totalScore || 0)) .slice(0, 10); }, [leaderboardItems]); if (identityLoading) { return (
{'WORDPLAY'.split('').map((letter, i) => ( {letter} ))}

Setting up your tiles...

); } if (!currentRoom || !gameId) { return ( ); } return (
{timerState.active ? (timerState.isMyTurn ? 'Your turn' : `${timerState.playerName || 'Another player'} is playing`) : 'Turn timer paused'} {timerState.active ? ` ${Math.ceil(timerState.remainingMs / 1000)}s remaining` : timerState.lastExpiredTurnId ? ' Time expired. Advancing turn...' : ' Waiting for the next turn.'}
Player presence {currentRoom} {gameId ? `• Game ${gameId}` : ''}
{playerPresenceList.map((player) => (
))}
All-time leaderboard {leaderboardLoading ? 'Loading scores...' : `Top ${leaderboardRows.length} players`}
Scores persist across rooms and games
{leaderboardRows.length ? leaderboardRows.map((entry, index) => (
#{index + 1}
{entry.playerName || 'Player'} {Number(entry.gamesPlayed || 0)} games played
{Number(entry.totalScore || 0)} pts
)) : (
No leaderboard entries yet. Play games to build your total score.
)}
{ syncPresence('in-game', { roomId: currentRoom, gameId, activeTurnId: turn?.turnId || null }); startTurnTimer(turn); }} onTurnEnd={(payload) => { stopTurnTimer(); recordLeaderboardScore(payload?.turn || payload?.completedTurn || payload?.nextTurn || timerState); if (payload?.nextTurn) { syncPresence('in-game', { roomId: currentRoom, gameId, activeTurnId: payload.nextTurn.turnId || null }); startTurnTimer(payload.nextTurn); } }} onTurnExpired={(payload) => { stopTurnTimer(); recordLeaderboardScore(payload?.turn || payload?.expiredTurn || timerState); if (payload?.nextTurn) { syncPresence('in-game', { roomId: currentRoom, gameId, activeTurnId: payload.nextTurn.turnId || null }); startTurnTimer(payload.nextTurn); } else { const nextPlayerIndex = (payload?.currentIndex ?? 0) + 1; const nextTurn = { turnId: `${gameId}-${currentRoom}-turn-${nextPlayerIndex}`, playerId: payload?.nextPlayerId || user?.id || 'player-1', playerName: payload?.nextPlayerName || 'Player', }; syncPresence('in-game', { roomId: currentRoom, gameId, activeTurnId: nextTurn.turnId }); startTurnTimer(nextTurn); } }} />
); } // PROGRESS:sc_001:complete:Setting up the word game lobby // PROGRESS:sc_002:complete:Building the game board with letter tiles // PROGRESS:sc_003:complete:Implementing scoring and dictionary validation // PROGRESS:sc_004:complete:Adding turn-limit timers and expiry transitions ReactDOM.createRoot(document.getElementById("root")).render();