@bonfire-ember/client
v0.1.0
Published
React hooks and UI components for Bonfire
Downloads
23
Maintainers
Readme
@bonfire/client
React hooks and utilities for building Bonfire party game UIs.
Status: Milestone 4 + 5 + 6 + 7 Complete — 242 tests, all passing
Features
- EmberClient - Promise-based Socket.io wrapper with subscription model
- EmberProvider - React context provider with auto-connect/cleanup
- 11 React hooks - Type-safe hooks for state, connection, room, player, phase, events, turn management, countdown timers, and session restoration
- EmberErrorBoundary - Error boundary component for graceful error handling
- 8 UI components - Lobby, PlayerAvatar, Timer, PromptCard, ResponseInput, RevealPhase, GameProgress, VotingInterface
- colorHash utility - Deterministic player color generation
- Storybook 8 - Visual documentation for all components
- Inline styles - Zero-dependency styling via shared
theme.tsconstants — no CSS setup required - useSyncExternalStore - Native React 18 external state synchronization
- TypeScript - Full type safety for game state and events
- Comprehensive tests - MockEmberClient for easy testing
Installation
npm install @bonfire/client socket.io-clientBuild order matters: When using local
file:references, build the Bonfire packages first before running your game app:cd bonfire && npm run build # build @bonfire/core, /server, /client first cd ../my-game && npm install # then install
Dependencies:
@bonfire/core- Core types and interfacessocket.io-client- Realtime communicationreact- React 18+ (peer dependency)
Quick Start
1. Set up EmberProvider
Wrap your app with EmberProvider to make Bonfire hooks available:
import { EmberProvider, EmberClient } from '@bonfire/client';
import { GameState } from '@bonfire/core';
// Option A: Pass config (provider creates client)
function App() {
return (
<EmberProvider config={{ url: 'http://localhost:3000' }}>
<GameUI />
</EmberProvider>
);
}
// Option B: Pass pre-created client (advanced usage)
const client = new EmberClient({ url: 'http://localhost:3000' });
function App() {
return (
<EmberProvider client={client}>
<GameUI />
</EmberProvider>
);
}2. Use Bonfire Hooks in Your Components
import { useGameState, useConnection, useRoom, usePlayer } from '@bonfire/client';
function GameUI() {
const { state } = useGameState();
const { status } = useConnection();
const { createRoom, joinRoom, startGame } = useRoom();
const { player, isHost } = usePlayer();
if (status !== 'connected') {
return <div>Connecting...</div>;
}
if (!state) {
return (
<div>
<button onClick={() => createRoom()}>Create Room</button>
<button onClick={() => joinRoom('ABC123', 'Player1')}>Join Room</button>
</div>
);
}
return (
<div>
<h1>Room: {state.roomId}</h1>
<p>Phase: {state.phase}</p>
<p>Players: {state.playerOrder?.length ?? 0}</p>
{isHost && state.phase === 'lobby' && (
<button onClick={() => startGame()}>Start Game</button>
)}
{/* Your game UI here */}
</div>
);
}3. Add Error Boundary
Wrap components with EmberErrorBoundary to catch and display errors:
import { EmberErrorBoundary } from '@bonfire/client';
function App() {
return (
<EmberProvider config={{ url: 'http://localhost:3000' }}>
<EmberErrorBoundary
fallback={<div>Something went wrong. <button onClick={() => window.location.reload()}>Reload</button></div>}
>
<GameUI />
</EmberErrorBoundary>
</EmberProvider>
);
}API Reference
EmberClient
Low-level Socket.io client wrapper. Usually used via EmberProvider and hooks.
import { EmberClient } from '@bonfire/client';
const client = new EmberClient({
url: 'http://localhost:3000',
autoConnect: false, // optional, default: false
});Methods:
// Connection
client.connect(): void
client.disconnect(): void
// Room Management
await client.createRoom(gameType: string, hostName: string): Promise<RoomCreateResponse>
await client.joinRoom(roomId: string, playerName: string): Promise<RoomJoinResponse>
await client.leaveRoom(): Promise<BaseResponse>
await client.reconnectToRoom(roomId: string, playerId: string): Promise<RoomReconnectResponse>
// Session persistence (localStorage)
client.loadSession(): { roomId: string; playerId: string } | null
// Game Actions
await client.startGame(): Promise<BaseResponse>
await client.sendAction(actionType: string, payload: unknown): Promise<ActionResponse>
await client.requestState(): Promise<StateResponse>
// Subscriptions (return unsubscribe functions)
client.onStateChange(callback: (state: GameState) => void): () => void
client.onStatusChange(callback: (status: ConnectionStatus) => void): () => void
client.onError(callback: (error: ErrorResponse) => void): () => void
client.onGameEvent(eventType: string, callback: (payload: any) => void): () => void
client.onRoomClosed(callback: (reason: string) => void): () => void
// Advanced
client.getSocket(): TypedClientSocketProperties:
client.gameState: GameState | null // Current game state
client.status: ConnectionStatus // 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
client.playerId: string | null
client.roomId: string | null
client.isConnected: booleanEmberProvider
React context provider for EmberClient. Auto-connects on mount and cleans up on unmount.
interface EmberProviderProps {
// Option 1: Pass client directly (advanced)
client?: EmberClient;
// Option 2: Pass config (provider creates client)
config?: EmberClientConfig; // { url: string; autoConnect?: boolean; ... }
autoConnect?: boolean;
children: React.ReactNode;
}Example:
// Simple setup — note: config.url, not serverUrl
<EmberProvider config={{ url: 'http://localhost:3000' }}>
<App />
</EmberProvider>
// Advanced setup with custom client
const client = new EmberClient({ url: process.env.SERVER_URL });
<EmberProvider client={client}>
<App />
</EmberProvider>Hooks
All hooks must be used inside a EmberProvider.
useGameState()
Access current game state with reactive updates.
function useGameState(): {
state: GameState | null;
requestState: () => Promise<void>;
}Example:
function GameBoard() {
const { state, requestState } = useGameState();
useEffect(() => {
// Request latest state on mount
requestState();
}, []);
if (!state) return <div>No active game</div>;
return (
<div>
<h2>Phase: {state.phase}</h2>
<p>Players: {state.playerOrder.length}/{state.config.maxPlayers}</p>
</div>
);
}Type-safe custom state:
interface MyGameState extends GameState {
score: Record<string, number>;
currentRound: number;
}
function ScoreBoard() {
const { state } = useGameState();
if (!state) return null;
// Access custom fields (type-safe if you use TypeScript generics)
const scores = (state as MyGameState).score;
return (
<ul>
{Object.entries(scores).map(([playerId, score]) => (
<li key={playerId}>{playerId}: {score}</li>
))}
</ul>
);
}useConnection()
Manage connection status and manual connect/disconnect.
function useConnection(): {
status: ConnectionStatus; // 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
connect: () => void;
disconnect: () => void;
}Example:
function ConnectionIndicator() {
const { status, connect, disconnect } = useConnection();
return (
<div>
<span>Status: {status}</span>
{status === 'disconnected' && <button onClick={connect}>Connect</button>}
{status === 'connected' && <button onClick={disconnect}>Disconnect</button>}
</div>
);
}useRoom()
Room management and game actions.
function useRoom(): {
roomId: string | null;
isInRoom: boolean;
createRoom: (gameType: string, hostName: string) => Promise<RoomCreateResponse>;
joinRoom: (roomId: string, playerName: string) => Promise<RoomJoinResponse>;
leaveRoom: () => Promise<BaseResponse>;
startGame: () => Promise<BaseResponse>;
sendAction: (actionType: string, payload: unknown) => Promise<ActionResponse>;
reconnectToRoom: (roomId: string, playerId: string) => Promise<RoomReconnectResponse>;
}Example:
function LobbyScreen() {
const { createRoom, joinRoom } = useRoom();
const [roomCode, setRoomCode] = useState('');
const [playerName, setPlayerName] = useState('');
return (
<div>
<button onClick={createRoom}>Create New Room</button>
<div>
<input
placeholder="Room Code"
value={roomCode}
onChange={(e) => setRoomCode(e.target.value)}
/>
<input
placeholder="Your Name"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
/>
<button onClick={() => joinRoom(roomCode, playerName)}>Join Room</button>
</div>
</div>
);
}Sending game actions:
function GameControls() {
const { sendAction } = useRoom();
const submitAnswer = async (answer: string) => {
try {
// sendAction takes two args: actionType string + payload object
await sendAction('submit_answer', { answer });
} catch (error) {
console.error('Failed to submit answer:', error);
}
};
return <button onClick={() => submitAnswer('My answer')}>Submit</button>;
}usePlayer()
Access current player information and player list.
function usePlayer(): {
player: Player | null; // The current player (NOT currentPlayer)
playerId: string | null;
players: Player[];
isHost: boolean;
}Example:
function PlayerList() {
const { player, players, isHost } = usePlayer();
return (
<div>
<h3>Players ({players.length})</h3>
<ul>
{players.map((p) => (
<li key={p.id}>
{p.name}
{p.id === player?.id && ' (You)'}
{p.isHost && ' 👑'}
</li>
))}
</ul>
{isHost && <p>You are the host!</p>}
</div>
);
}usePhase()
Track current game phase.
function usePhase(): Phase | null // Returns the value directly, not an objectExample:
function GameScreen() {
const phase = usePhase(); // Direct value, not { phase }
if (phase === 'lobby') return <LobbyUI />;
if (phase === 'playing') return <GameplayUI />;
if (phase === 'results') return <ResultsUI />;
return <div>Unknown phase: {phase}</div>;
}useEmberEvent()
Subscribe to custom game events with auto-cleanup.
function useEmberEvent<T = any>(
eventType: string,
callback: (payload: T) => void
): voidExample:
function GameNotifications() {
const [message, setMessage] = useState('');
// Listen for custom 'player_scored' events
useEmberEvent('player_scored', (payload: { playerId: string; points: number }) => {
setMessage(`Player ${payload.playerId} scored ${payload.points} points!`);
setTimeout(() => setMessage(''), 3000);
});
return message ? <div className="notification">{message}</div> : null;
}Auto-cleanup: The event listener is automatically removed when the component unmounts or when the event type changes.
useTurn()
Convenience hook for turn-based games. Derives who the current turn player is from currentTurnIndex in game state, eliminating manual playerOrder indexing in game UIs.
Requires currentTurnIndex to be set in game state by server-side game logic.
function useTurn(): {
isMyTurn: boolean;
currentPlayerId: PlayerId | null;
currentPlayer: Player | null;
turnIndex: number | null;
}Example:
function TurnIndicator() {
const { isMyTurn, currentPlayer, turnIndex } = useTurn();
if (isMyTurn) return <div className="banner">Your turn!</div>;
return <div>Waiting for {currentPlayer?.name}…</div>;
}Note: Returns all nulls when state.currentTurnIndex or state.playerOrder is not set (e.g., in non-turn-based phases).
useCountdown()
Synchronized countdown timer. All clients show the same remaining time regardless of when they mounted, because the hook computes remaining time from an absolute timestamp rather than counting down from mount.
function useCountdown(timerEndsAt: number | null | undefined): number
// Returns seconds remaining (integer, ≥ 0). Returns 0 when expired or timerEndsAt is falsy.Example:
function TurnTimer() {
const { state } = useGameState();
const secondsLeft = useCountdown(state?.timerEndsAt);
return <div>{secondsLeft}s remaining</div>;
}How it works: The server sets state.timerEndsAt = Date.now() + durationMs when a turn starts. Each client calls useCountdown(state.timerEndsAt) and derives remaining seconds from the same absolute timestamp — so all clients count down in sync.
useSession()
Automatically restores a saved Bonfire session on mount. Handles the page-refresh reconnect flow without manual wiring.
function useSession(): {
isRestoring: boolean; // true while reconnect attempt is in flight
restored: boolean; // true if reconnect succeeded
failed: boolean; // true if reconnect was attempted but failed (room gone, etc.)
}Example:
function GameRouter() {
const { isRestoring } = useSession();
const phase = usePhase();
// isRestoring starts true when a saved session exists — prevents landing-screen flash
if (isRestoring) return <ReconnectingScreen />;
if (!phase) return <LandingScreen />;
if (phase === 'lobby') return <Lobby />;
if (phase === 'playing') return <Game />;
return null;
}How it works: On mount, useSession checks localStorage for a saved session. When the socket connects, it calls reconnectToRoom automatically. isRestoring is initialized to true (not false) when a saved session exists — this prevents the landing screen from flashing before the reconnect completes.
Reconnection (Page Refresh Recovery)
Bonfire automatically saves session data to localStorage whenever a player creates or joins a room. The recommended approach is to use useSession() (above), which handles reconnect automatically. For manual control, use loadSession() + reconnectToRoom():
function App() {
const { reconnectToRoom } = useRoom();
const { client } = useEmberContext();
useEffect(() => {
const session = client.loadSession();
if (session) {
reconnectToRoom(session.roomId, session.playerId);
}
}, []);
// ...
}How it works:
createRoom/joinRoomautomatically save{ roomId, playerId }tolocalStorageleaveRoom/room:closedautomatically clear the saved sessionreconnectToRoomemitsroom:reconnectto the server and restores client state on success
EmberErrorBoundary
React error boundary for catching and displaying errors in game UI.
interface EmberErrorBoundaryProps {
fallback?: React.ReactNode | ((error: Error, reset: () => void) => React.ReactNode);
children: React.ReactNode;
}Example with static fallback:
<EmberErrorBoundary fallback={<div>Something went wrong</div>}>
<GameUI />
</EmberErrorBoundary>Example with render function:
<EmberErrorBoundary
fallback={(error, reset) => (
<div>
<h2>Error: {error.message}</h2>
<button onClick={reset}>Try Again</button>
<button onClick={() => window.location.reload()}>Reload</button>
</div>
)}
>
<GameUI />
</EmberErrorBoundary>UI Components
Pre-built React components for common party game UI patterns. Components use inline styles — no Tailwind or external CSS required. Import and use with zero consumer setup.
PlayerAvatar
Renders a player's avatar as a colored circle with initials. Color is deterministically generated from the player's name.
import { PlayerAvatar } from '@bonfire/client';
<PlayerAvatar
name="Alice"
size="md" // 'xs' | 'sm' | 'md' | 'lg' | 'xl'
showStatus={true}
isOnline={true}
isHost={true}
/>Timer
Countdown timer with an optional circular SVG progress ring.
import { Timer } from '@bonfire/client';
<Timer
duration={60} // seconds
onComplete={() => nextPhase()}
showProgress={true}
variant="default" // 'default' | 'warning' | 'danger'
size="md" // 'sm' | 'md' | 'lg'
autoStart={true}
/>Lobby
Full pre-built lobby screen. Connects to game state via hooks internally — no wiring required.
import { Lobby } from '@bonfire/client';
// Minimal usage — reads room code and players from game state automatically
<Lobby />
// With overrides
<Lobby
roomCode="ABC123"
showReadyStates={true}
hideStartButton={false}
onStart={() => customStart()}
renderPlayer={(player, isHost) => <MyPlayerRow player={player} isHost={isHost} />}
/>PromptCard
Themed card for displaying questions, prompts, or dares.
import { PromptCard } from '@bonfire/client';
<PromptCard
prompt="What is your biggest fear?"
variant="spicy" // 'standard' | 'spicy' | 'creative' | 'dare'
category="Deep Dive" // overrides the variant badge label
round={2}
totalRounds={5}
subtitle="Everyone answers, then compare."
animate={true}
/>ResponseInput
Polymorphic input component — mode is determined by config.type.
import { ResponseInput } from '@bonfire/client';
// Text input
<ResponseInput
config={{ type: 'text', placeholder: 'Type your answer…', maxLength: 200, multiline: false }}
value={answer}
onChange={setAnswer}
onSubmit={handleSubmit}
/>
// Multiple choice (single-select)
<ResponseInput
config={{
type: 'multiple-choice',
choices: [
{ id: 'a', label: 'Option A', description: 'The first option' },
{ id: 'b', label: 'Option B' },
],
allowMultiple: false,
}}
value={selected}
onChange={setSelected}
onSubmit={handleSubmit}
/>
// Ranking
<ResponseInput
config={{
type: 'ranking',
items: [
{ id: 'p1', label: 'Alice' },
{ id: 'p2', label: 'Bob' },
{ id: 'p3', label: 'Charlie' },
],
}}
value={ranking}
onChange={setRanking}
onSubmit={handleSubmit}
/>Composing PromptCard + ResponseInput
The PromptCard children slot is designed for ResponseInput:
<PromptCard prompt="Rank these from best to worst" variant="creative" round={1} totalRounds={3}>
<ResponseInput
config={{ type: 'ranking', items: choices }}
value={ranking}
onChange={setRanking}
onSubmit={handleSubmit}
/>
</PromptCard>RevealPhase
Sequentially reveals a list of items with configurable animation delays. Useful for answer reveals, score announcements, or any staged disclosure pattern.
import { RevealPhase } from '@bonfire/client';
<RevealPhase
items={[
{ id: '1', content: 'Alice answered: Spaghetti' },
{ id: '2', content: 'Bob answered: Pizza' },
]}
delayBetween={800} // ms between each reveal (default: 600)
animateIn={true} // slide-in animation (default: true)
onRevealComplete={() => nextPhase()}
/>Custom render per item:
<RevealPhase
items={answers}
renderItem={(item, index) => (
<div className="answer-card">
<span className="rank">#{index + 1}</span>
<span>{item.content}</span>
</div>
)}
delayBetween={1000}
onRevealComplete={handleComplete}
/>GameProgress
Displays current progress through rounds or phases. Supports three visual variants.
import { GameProgress } from '@bonfire/client';
// Progress bar
<GameProgress
current={2}
total={5}
variant="bar" // 'bar' | 'dots' | 'number'
label="Round"
/>
// Dot indicators
<GameProgress current={3} total={5} variant="dots" />
// Numeric display
<GameProgress current={3} total={5} variant="number" label="Question" />All variants include an ARIA progressbar role for accessibility.
VotingInterface
Full voting UI with live results display, vote counts, percentage bars, and winner highlighting.
import { VotingInterface } from '@bonfire/client';
// Voting in progress
<VotingInterface
options={[
{ id: 'a', label: 'Option A' },
{ id: 'b', label: 'Option B' },
{ id: 'c', label: 'Option C' },
]}
onVote={(optionId) => sendAction({ type: 'vote', payload: { optionId } })}
selectedId={myVoteId}
disabled={hasVoted}
/>
// Results display (after voting closes)
<VotingInterface
options={[
{ id: 'a', label: 'Option A', votes: 3 },
{ id: 'b', label: 'Option B', votes: 7 },
{ id: 'c', label: 'Option C', votes: 1 },
]}
showResults={true}
totalVotes={11}
/>colorHash Utility
import { getPlayerColor, getPlayerInitials } from '@bonfire/client';
getPlayerColor('Alice') // '#...' — deterministic hex color
getPlayerInitials('Alice') // 'AL'
getPlayerInitials('Bob') // 'B'Storybook
Run the interactive component playground:
cd packages/client && npm run storybookTypeScript Types
All types are fully exported and type-safe:
import type {
EmberClientConfig,
ConnectionStatus,
BaseResponse,
RoomCreateResponse,
RoomJoinResponse,
RoomReconnectResponse,
StateResponse,
ActionResponse,
ErrorResponse,
EmberGameEvent,
} from '@bonfire/client';Type Definitions:
interface EmberClientConfig {
url: string; // Server URL, e.g. "http://localhost:3000"
socketOptions?: Record<string, unknown>;
autoConnect?: boolean; // default: false
reconnection?: boolean; // default: true
reconnectionAttempts?: number; // default: 5
}
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
interface BaseResponse {
success: boolean;
error?: string;
code?: string;
}
interface RoomCreateResponse extends BaseResponse {
roomId?: string;
state?: GameState;
}
interface RoomJoinResponse extends BaseResponse {
playerId?: string;
state?: GameState;
}
interface RoomReconnectResponse extends BaseResponse {
playerId?: string;
state?: GameState;
}
interface EmberGameEvent {
type: string;
payload: unknown;
}Testing
Use MockEmberClient from test fixtures for easy testing:
import { renderWithProvider } from './__tests__/fixtures/renderWithProvider';
import { mockEmberClient } from './__tests__/fixtures/mockEmberClient';
test('displays player count', () => {
const client = mockEmberClient();
client.simulateState({
phase: 'lobby',
playerOrder: ['p1', 'p2'],
// ... other state
});
const { getByText } = renderWithProvider(<PlayerList />, client);
expect(getByText('Players (2)')).toBeInTheDocument();
});MockEmberClient methods:
client.simulateState(state: GameState): void
client.simulateStatus(status: ConnectionStatus): void
client.simulateError(error: ErrorResponse): void
client.simulateEvent(type: string, payload: any): void
client.simulateRoomClosed(): voidArchitecture
The client library uses React 18's useSyncExternalStore to synchronize React component state with the external Socket.io client. This ensures:
- Automatic re-renders when server state changes
- No stale state from race conditions
- Concurrent Mode compatible for React 18+
- Efficient updates only when subscribed data changes
Architecture diagram:
Socket.io Server
↓
EmberClient (subscription model)
↓
EmberProvider (React Context)
↓
Hooks (useSyncExternalStore)
↓
Your Components (re-render on state change)For detailed architecture documentation, see docs/architecture/client-library.md.
Examples
Complete Game UI Example
import { EmberProvider, useGameState, useRoom, usePlayer, usePhase } from '@bonfire/client';
function App() {
return (
<EmberProvider config={{ url: 'http://localhost:3000' }}>
<EmberErrorBoundary>
<Game />
</EmberErrorBoundary>
</EmberProvider>
);
}
function Game() {
const { state } = useGameState();
const phase = usePhase();
if (!state) return <LobbyScreen />;
if (phase === 'lobby') return <WaitingRoom />;
if (phase === 'playing') return <Gameplay />;
if (phase === 'results') return <Results />;
return null;
}
function LobbyScreen() {
const { createRoom, joinRoom } = useRoom();
const [code, setCode] = useState('');
const [name, setName] = useState('');
return (
<div>
<h1>Party Game</h1>
<button onClick={createRoom}>Create Room</button>
<input placeholder="Room Code" value={code} onChange={(e) => setCode(e.target.value)} />
<input placeholder="Your Name" value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={() => joinRoom(code, name)}>Join Room</button>
</div>
);
}
function WaitingRoom() {
const { state } = useGameState();
const { player, players, isHost } = usePlayer();
const { startGame } = useRoom();
return (
<div>
<h2>Room: {state?.roomId}</h2>
<h3>Players:</h3>
<ul>
{players.map((p) => (
<li key={p.id}>{p.name} {p.isHost && '👑'}</li>
))}
</ul>
{isHost && <button onClick={startGame}>Start Game</button>}
</div>
);
}
function Gameplay() {
const { sendAction } = useRoom();
return (
<div>
<h2>Playing...</h2>
<button onClick={() => sendAction('submit', { answer: 'my answer' })}>
Submit Answer
</button>
</div>
);
}
function Results() {
const { state } = useGameState();
return (
<div>
<h2>Results</h2>
<p>Game over!</p>
</div>
);
}Common Gotchas
These cause silent failures with no helpful error message:
| Issue | Correct Usage |
|-------|---------------|
| usePlayer() key is player, not currentPlayer | const { player } = usePlayer() |
| sendAction takes two args, not an object | sendAction('type', payload) |
| usePhase() returns the value directly | const phase = usePhase() |
| handleAction() receives a single object; player is inside | action.playerId |
| EmberProvider uses config={{ url }}, not serverUrl | config={{ url: serverUrl }} |
| onGameStart() does NOT auto-transition phases | call transitionPhase() yourself |
| transitionPhase() throws if phase not in config.phases | list ALL phases upfront in config |
Vite Setup (Required)
When using Bonfire packages from a Vite app, add this to your vite.config.ts to avoid "does not provide an export named" errors:
// vite.config.ts
export default defineConfig({
optimizeDeps: {
include: ['@bonfire/client', '@bonfire/core'],
},
build: {
commonjsOptions: {
include: [/@bonfire\//, /node_modules/],
},
},
});Related Documentation
- Architecture:
docs/architecture/client-library.md- Detailed design and architecture - Server Package:
packages/server/README.md- Server API reference - Core Package:
packages/core/README.md- Game engine API - Milestones:
docs/MILESTONES.md- Development roadmap
License
MIT
