import { useState, useRef, useEffect, useMemo } from 'react';
import { useCollection, useIdentity, useReactions, useAI, playSound } from '@deplixo/sdk';
import { GameSetup } from './components/GameSetup.jsx';
import { ScoreBoard } from './components/ScoreBoard.jsx';
import { GameHistory } from './components/GameHistory.jsx';
function ReactionBar({ targetId, targetLabel }) {
const { counts, toggle, loading } = useReactions(targetId);
const emojis = ['👍', '❤️', '🎉', '🔥', '👀'];
if (loading || !targetId) return null;
return (
{emojis.map(emoji => (
))}
);
}
function PlayReactionFeed({ activeGame, turnTiming }) {
const rounds = Array.isArray(activeGame?.value?.rounds) ? activeGame.value.rounds : [];
const currentRound = rounds.length > 0 ? rounds[rounds.length - 1] : null;
const currentRoundId = currentRound ? `${activeGame.id}-round-${currentRound.round || rounds.length}` : null;
const currentPlayerLabel = currentRound?.activePlayerId || activeGame?.value?.currentTurnPlayerId || 'Current play';
return (
Live Reactions
Spectators can react to the current play in real time.
{turnTiming?.isTimedTurns && (
Turn
{turnTiming.remaining}s
)}
{currentRound ? (
<>
Round {currentRound.round || rounds.length}
{currentPlayerLabel}
>
) : (
Reactions will appear here once a round starts.
)}
);
}
function App() {
const [tab, setTab] = useState('play');
const [setupGameData, setSetupGameData] = useState(null);
const [handicapSuggestion, setHandicapSuggestion] = useState(null);
const [handicapLoading, setHandicapLoading] = useState(false);
const [handicapError, setHandicapError] = useState('');
const [applyHandicapToNewGame, setApplyHandicapToNewGame] = useState(true);
const { items: games, loading, add, update, remove } = useCollection('games', { personal: true });
const { user: identityUser, loading: identityLoading } = useIdentity();
const { items: identities, loading: identitiesLoading, add: addIdentity, update: updateIdentity } = useCollection('player-identities', { personal: true });
const { items: completedGameSessions, loading: sessionsLoading, add: addGameSession, update: updateGameSession } = useCollection('game-sessions', { personal: true });
const { generate: generateAI, loading: aiLoading, error: aiError } = useAI();
const lastLeaderIdRef = useRef(null);
const lastGameStatusRef = useRef(null);
const timerTickRef = useRef(null);
const savedIdentity = identities && identities.length > 0 ? identities[0] : null;
const activeGame = games.find(g => g.value.status === 'active');
const playerIdentity = savedIdentity
? savedIdentity.value
: identityUser
? {
id: identityUser.id,
name: identityUser.name,
avatar: identityUser.avatar
}
: null;
const buildGameSummary = (gameValue) => {
const players = Array.isArray(gameValue?.players) ? gameValue.players : [];
const rounds = Array.isArray(gameValue?.rounds) ? gameValue.rounds : [];
const totals = Array.isArray(gameValue?.totals) ? gameValue.totals : [];
const sortedTotals = [...totals].sort((a, b) => (Number(b.total) || 0) - (Number(a.total) || 0));
const winner = sortedTotals.length > 0 ? sortedTotals[0] : null;
const summary = {
playerCount: players.length,
roundCount: rounds.length,
totalScores: sortedTotals.reduce((acc, item) => acc + (Number(item.total) || 0), 0),
winnerName: winner ? winner.name : 'Unknown',
winnerScore: winner ? Number(winner.total) || 0 : 0
};
return {
players,
rounds,
rules: gameValue?.rules || {},
totals: sortedTotals,
winner,
summary
};
};
const getLeaderIdFromGame = (gameValue) => {
const totals = Array.isArray(gameValue?.totals) ? gameValue.totals : [];
if (!totals.length) return null;
const sorted = [...totals].sort((a, b) => (Number(b.total) || 0) - (Number(a.total) || 0));
return sorted[0]?.id || sorted[0]?.playerId || sorted[0]?.name || null;
};
const getRoundTurnConfig = (gameValue) => {
const rules = gameValue?.rules || {};
const turnDuration = Number(rules.turnDurationSeconds || rules.turnDuration || 0);
const isTimedTurns = Boolean(rules.timedTurns) || turnDuration > 0;
return {
isTimedTurns,
turnDuration: turnDuration > 0 ? turnDuration : 0,
autoAdvance: rules.autoAdvanceTurns !== false
};
};
const getElapsedSeconds = (startedAt, now = Date.now()) => {
if (!startedAt) return 0;
return Math.max(0, Math.floor((now - startedAt) / 1000));
};
const getTurnTiming = (gameValue) => {
const { isTimedTurns, turnDuration, autoAdvance } = getRoundTurnConfig(gameValue);
const rounds = Array.isArray(gameValue?.rounds) ? gameValue.rounds : [];
const lastRound = rounds.length > 0 ? rounds[rounds.length - 1] : null;
const startedAt = Number(lastRound?.startedAt || lastRound?.createdAt || gameValue?.currentTurnStartedAt || gameValue?.createdAt || Date.now());
const elapsed = getElapsedSeconds(startedAt);
const remaining = isTimedTurns ? Math.max(0, turnDuration - elapsed) : null;
const expired = isTimedTurns && remaining === 0;
const activePlayerId = lastRound?.activePlayerId || gameValue?.currentTurnPlayerId || null;
return { isTimedTurns, turnDuration, autoAdvance, startedAt, elapsed, remaining, expired, activePlayerId };
};
const analyzeHandicapSuggestion = async (gameData) => {
const players = Array.isArray(gameData?.players) ? gameData.players : [];
const rules = gameData?.rules || {};
const totals = Array.isArray(gameData?.totals) ? gameData.totals : [];
const names = players.map(player => player?.name || player?.playerName || 'Player');
const strongestSignals = totals.length
? [...totals].sort((a, b) => (Number(b.total) || 0) - (Number(a.total) || 0)).slice(0, 2).map(item => ({
name: item?.name || item?.playerName || 'Player',
total: Number(item?.total) || 0
}))
: [];
const prompt = `Analyze this board game setup and suggest handicap adjustments only if the group appears unbalanced.
Players: ${names.join(', ') || 'None'}
Player count: ${players.length}
Rules: ${JSON.stringify(rules)}
Recent totals or skill signals: ${JSON.stringify(strongestSignals)}
Return JSON with this shape:
{
"needsHandicap": boolean,
"balanceLevel": "low" | "medium" | "high",
"reason": string,
"suggestions": [
{
"playerName": string,
"adjustmentType": "bonus_points" | "starting_points" | "extra_action" | "delayed_start" | "target_reduction" | "other",
"value": number,
"description": string
}
],
"applyToNewSession": boolean
}
Guidelines:
- Keep suggestions small and practical.
- If the group looks balanced, set needsHandicap to false and suggestions to [].
- Prefer one recommendation per weaker player.
- Do not invent complex rules; keep it simple.`;
const response = await generateAI({
system: 'You are a helpful board game balance assistant that suggests fair, concise handicap adjustments for tabletop game sessions.',
user: prompt,
json: true
});
return response;
};
const buildGameWithHandicap = (gameData, suggestion) => {
if (!suggestion?.needsHandicap || !Array.isArray(suggestion?.suggestions) || suggestion.suggestions.length === 0) {
return gameData;
}
const players = Array.isArray(gameData?.players) ? gameData.players : [];
const handicapMap = new Map();
suggestion.suggestions.forEach(item => {
if (!item?.playerName) return;
handicapMap.set(item.playerName, {
adjustmentType: item.adjustmentType || 'other',
value: Number(item.value) || 0,
description: item.description || ''
});
});
return {
...gameData,
players: players.map(player => {
const key = player?.name || player?.playerName;
const handicap = handicapMap.get(key);
return handicap
? {
...player,
handicapAdjustment: handicap,
handicapApplied: true
}
: player;
}),
rules: {
...(gameData.rules || {}),
handicapSuggestionApplied: true,
handicapSuggestionSummary: suggestion.reason || 'AI suggested handicap adjustments were applied.'
}
};
};
const upsertGameSession = async (gameId, gameValue) => {
const completedAt = Date.now();
const summaryData = buildGameSummary(gameValue);
const payload = {
sourceGameId: gameId,
gameName: gameValue?.name || 'Untitled Game',
players: summaryData.players,
rules: summaryData.rules,
rounds: summaryData.rounds,
totals: summaryData.totals,
winner: summaryData.winner,
summary: summaryData.summary,
createdAt: gameValue?.createdAt || completedAt,
endedAt: completedAt,
updatedAt: completedAt,
status: 'completed'
};
const existingSession = completedGameSessions && completedGameSessions.find(session => session.value && session.value.sourceGameId === gameId);
if (existingSession) {
await updateGameSession(existingSession.id, { ...existingSession.value, ...payload });
} else {
await addGameSession(payload);
}
};
const handleSaveIdentity = async (playerData) => {
if (!playerData) return null;
const nextIdentity = {
id: playerData.id || (savedIdentity && savedIdentity.value && savedIdentity.value.id) || (identityUser && identityUser.id) || Date.now().toString(),
name: playerData.name || (savedIdentity && savedIdentity.value && savedIdentity.value.name) || (identityUser && identityUser.name) || 'Player',
avatar: playerData.avatar || (savedIdentity && savedIdentity.value && savedIdentity.value.avatar) || (identityUser && identityUser.avatar) || '',
updatedAt: Date.now()
};
if (savedIdentity) {
await updateIdentity(savedIdentity.id, nextIdentity);
} else {
await addIdentity({ ...nextIdentity, createdAt: Date.now() });
}
return nextIdentity;
};
const advanceTimedTurn = async (gameId, gameValue, reason = 'timer') => {
const currentRounds = Array.isArray(gameValue?.rounds) ? [...gameValue.rounds] : [];
const players = Array.isArray(gameValue?.players) ? gameValue.players : [];
const totals = Array.isArray(gameValue?.totals) ? [...gameValue.totals] : [];
if (!players.length) return;
const lastRound = currentRounds.length > 0 ? currentRounds[currentRounds.length - 1] : null;
const nextPlayerIndex = Math.max(0, (players.findIndex(p => (p?.id || p?.playerId || p?.name) === (lastRound?.activePlayerId || gameValue?.currentTurnPlayerId)) + 1) % players.length);
const nextActivePlayer = players[nextPlayerIndex] || players[0];
const now = Date.now();
const nextRoundNumber = currentRounds.length + 1;
const nextRound = {
round: nextRoundNumber,
startedAt: now,
createdAt: now,
reason,
activePlayerId: nextActivePlayer?.id || nextActivePlayer?.playerId || nextActivePlayer?.name || null,
status: 'open',
scores: []
};
const updatedGame = {
...gameValue,
rounds: [...currentRounds, nextRound],
totals: totals.length ? totals : gameValue?.totals || [],
currentTurnStartedAt: now,
currentTurnPlayerId: nextRound.activePlayerId,
updatedAt: now
};
await update(gameId, updatedGame);
playSound('@success');
};
const handleStartGame = async (gameData) => {
const existing = games.filter(g => g.value.status === 'active');
for (const g of existing) {
await update(g.id, { ...g.value, status: 'completed', endedAt: Date.now() });
await upsertGameSession(g.id, { ...g.value, status: 'completed', endedAt: Date.now() });
}
const identityToUse = playerIdentity || {
id: identityUser ? identityUser.id : Date.now().toString(),
name: identityUser ? identityUser.name : 'Player',
avatar: identityUser ? identityUser.avatar : ''
};
const now = Date.now();
const initialRules = gameData?.rules || {};
const timedTurnsEnabled = Boolean(initialRules.timedTurns) || Number(initialRules.turnDurationSeconds || initialRules.turnDuration || 0) > 0;
const baseGameData = {
...gameData,
rules: {
...initialRules,
timedTurns: timedTurnsEnabled,
turnDurationSeconds: Number(initialRules.turnDurationSeconds || initialRules.turnDuration || 0) || 0,
autoAdvanceTurns: initialRules.autoAdvanceTurns !== false
},
hostIdentity: identityToUse,
playerIdentity: identityToUse,
players: Array.isArray(gameData.players)
? gameData.players.map(player => {
if (player && player.id && player.id === identityToUse.id) {
return { ...player, ...identityToUse, returningPlayer: true };
}
return player;
})
: gameData.players,
status: 'active',
createdAt: now,
currentTurnStartedAt: timedTurnsEnabled ? now : null,
currentTurnPlayerId: Array.isArray(gameData.players) && gameData.players.length > 0 ? (gameData.players[0]?.id || gameData.players[0]?.playerId || gameData.players[0]?.name || null) : null,
rounds: timedTurnsEnabled && Array.isArray(gameData.players) && gameData.players.length > 0 ? [{ round: 1, startedAt: now, createdAt: now, activePlayerId: (gameData.players[0]?.id || gameData.players[0]?.playerId || gameData.players[0]?.name || null), status: 'open', scores: [] }] : (Array.isArray(gameData.rounds) ? gameData.rounds : [])
};
const finalGameData = applyHandicapToNewGame && handicapSuggestion ? buildGameWithHandicap(baseGameData, handicapSuggestion) : baseGameData;
await add(finalGameData);
await handleSaveIdentity(identityToUse);
setTab('play');
setSetupGameData(null);
};
const handlePrepareGame = async (gameData) => {
setSetupGameData(gameData);
setHandicapError('');
setHandicapSuggestion(null);
try {
setHandicapLoading(true);
const suggestion = await analyzeHandicapSuggestion(gameData);
setHandicapSuggestion(suggestion);
} catch (error) {
setHandicapError(error?.message || aiError || 'Unable to generate a handicap suggestion right now.');
setHandicapSuggestion(null);
} finally {
setHandicapLoading(false);
}
};
const handleEndGame = async (gameId) => {
const target = games.find(g => g.id === gameId);
if (!target) return;
const endedAt = Date.now();
const completedGame = {
...target.value,
status: 'completed',
endedAt,
updatedAt: endedAt
};
await update(gameId, completedGame);
await upsertGameSession(gameId, completedGame);
};
const historySessions = (completedGameSessions || [])
.filter(session => session && session.value && session.value.status === 'completed')
.map(session => ({ id: session.id, value: session.value }));
const activeTiming = useMemo(() => (activeGame ? getTurnTiming(activeGame.value) : null), [activeGame, games]);
useEffect(() => {
if (!activeGame) return;
if (!activeTiming?.isTimedTurns || !activeTiming.expired || !activeTiming.autoAdvance) return;
const existingTick = timerTickRef.current;
if (existingTick === activeGame.id) return;
timerTickRef.current = activeGame.id;
advanceTimedTurn(activeGame.id, activeGame.value, 'timer');
}, [activeGame, activeTiming]);
useEffect(() => {
if (loading) return;
const active = games.find(g => g.value && g.value.status === 'active');
if (!active) {
lastLeaderIdRef.current = null;
return;
}
const leaderId = getLeaderIdFromGame(active.value);
const lastLeaderId = lastLeaderIdRef.current;
if (leaderId && lastLeaderId && leaderId !== lastLeaderId) {
playSound('@success');
}
lastLeaderIdRef.current = leaderId;
}, [games, loading]);
useEffect(() => {
const active = games.find(g => g.value && g.value.status === 'active');
const activeStatus = active ? 'active' : 'none';
if (lastGameStatusRef.current === 'active' && activeStatus === 'none') {
playSound('@ding');
}
lastGameStatusRef.current = activeStatus;
}, [games]);
if (loading || identityLoading || identitiesLoading || sessionsLoading) {
return (
);
}
return (
🎲
ScoreKeep
{tab === 'setup' && (
<>
{handicapSuggestion?.needsHandicap && (
{Array.isArray(handicapSuggestion.suggestions) && handicapSuggestion.suggestions.map((item, index) => (
{item.playerName}
{item.description || `${item.adjustmentType} ${item.value}`}
))}
)}
>
)}
{tab === 'play' && (
setTab('setup')}
playerIdentity={playerIdentity}
onSaveIdentity={handleSaveIdentity}
turnTiming={activeTiming}
onAdvanceTurn={async () => {
if (!activeGame) return;
await advanceTimedTurn(activeGame.id, activeGame.value, 'manual');
}}
/>
{activeGame && (
)}
)}
{tab === 'history' && (
{
await remove(id);
}}
onReplay={handleStartGame}
/>
)}
);
}
ReactDOM.createRoot(document.getElementById('root')).render();