feat: add comprehensive error handling for arcade games

Add user-facing error notifications and boundaries to prevent silent failures in arcade games.

**Problem:**
- Errors only logged to console (e.g., "Failed to fetch session")
- Users saw nothing when errors occurred
- Buttons stopped working with no feedback
- No way to recover from errors

**Solution: 4-Part Error Handling System**

1. **ErrorToast Component** - User-facing error notifications
   - Prominent red toast in bottom-right corner
   - Auto-dismisses after 10 seconds
   - Collapsible technical details
   - Mobile-responsive

2. **ArcadeErrorBoundary** - React error boundary
   - Catches component render errors
   - Shows user-friendly fallback UI
   - Provides "Try Again" and "Return to Lobby" buttons
   - Collapsible stack trace for debugging

3. **ArcadeErrorContext** - Global error management
   - Manages error state across the app
   - Renders error toasts
   - Auto-cleans up old errors

4. **Enhanced useArcadeSocket** - Automatic socket error handling
   - Connection errors: "Failed to connect to server"
   - Disconnections: "Connection lost, attempting to reconnect"
   - Session errors: "Failed to load/update session"
   - Move rejections: "Your move was not accepted"
   - No active session: "No game session found"
   - Can suppress toasts with `suppressErrorToasts: true`

**Files Created:**
- src/components/ErrorToast.tsx
- src/components/ArcadeErrorBoundary.tsx
- src/contexts/ArcadeErrorContext.tsx
- .claude/ERROR_HANDLING.md (integration guide)

**Files Modified:**
- src/hooks/useArcadeSocket.ts (automatic error toasts)

**Next Steps (TODO):**
- Wrap arcade game pages with error providers
- Test all error scenarios
- Add error recovery strategies

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-20 07:31:34 -06:00
parent 07c25a2296
commit e8c52561a2
6 changed files with 731 additions and 1 deletions

View File

@ -0,0 +1,188 @@
# Arcade Error Handling System
## Overview
Comprehensive error handling system for arcade games to ensure users always see meaningful error messages instead of silent failures.
## Components
### 1. ErrorToast (`src/components/ErrorToast.tsx`)
User-facing error notification component:
- Prominent red toast in bottom-right corner
- Auto-dismisses after 10 seconds
- Collapsible technical details
- Mobile-responsive
**Usage:**
```typescript
<ErrorToast
message="Game session error"
details="Error: Failed to fetch session..."
onDismiss={() => clearError(errorId)}
/>
```
### 2. ArcadeErrorBoundary (`src/components/ArcadeErrorBoundary.tsx`)
React error boundary for catching React errors:
- Catches component render errors
- Shows user-friendly fallback UI
- Provides "Try Again" and "Return to Lobby" buttons
- Collapsible stack trace for debugging
**Usage:**
```typescript
<ArcadeErrorBoundary>
<GameComponent />
</ArcadeErrorBoundary>
```
### 3. ArcadeErrorContext (`src/contexts/ArcadeErrorContext.tsx`)
Global error management context:
- Manages error state across the app
- Renders error toasts
- Auto-cleans up old errors
**Usage:**
```typescript
// Wrap your app/page
<ArcadeErrorProvider>
{children}
</ArcadeErrorProvider>
// Use in components
const { addError, clearError } = useArcadeError()
addError('Something went wrong', 'Technical details...')
```
### 4. Enhanced useArcadeSocket Hook
Socket hook now automatically shows error toasts for:
- **Connection errors**: Failed to connect to server
- **Disconnections**: Connection lost
- **Session errors**: Failed to load/update session
- **Move rejections**: Invalid moves (non-version-conflict)
- **No active session**: Session not found
Can suppress toasts with `suppressErrorToasts: true` option.
## Error Categories
### Network/Connection Errors
- **Connection error**: Failed to connect to game server
- **Disconnection**: Connection lost, attempting to reconnect
### Session Errors
- **Session error**: Failed to load or update game session
- **No active session**: No game session found
### Game State Errors
- **Move rejected**: Invalid move submitted
- **Version conflict**: Concurrent update detected (silent, not shown to user)
### React Errors
- **Component errors**: Caught by ErrorBoundary, shows fallback UI
## Integration Guide
### For New Arcade Games
1. **Wrap your game page with error providers:**
```typescript
// src/app/arcade/your-game/page.tsx
import { ArcadeErrorProvider } from '@/contexts/ArcadeErrorContext'
import { ArcadeErrorBoundary } from '@/components/ArcadeErrorBoundary'
export default function YourGamePage() {
return (
<ArcadeErrorProvider>
<ArcadeErrorBoundary>
<YourGameProvider>
<YourGameComponent />
</YourGameProvider>
</ArcadeErrorBoundary>
</ArcadeErrorProvider>
)
}
```
2. **Use error context in your components:**
```typescript
import { useArcadeError } from '@/contexts/ArcadeErrorContext'
function YourComponent() {
const { addError } = useArcadeError()
try {
// Your code
} catch (error) {
addError(
'User-friendly message',
`Technical details: ${error.message}`
)
}
}
```
3. **Socket hook is automatic:**
The `useArcadeSocket` hook already shows errors by default. No changes needed unless you want to suppress them.
## Best Practices
### DO:
- ✅ Use `addError()` for runtime errors
- ✅ Provide user-friendly primary messages
- ✅ Include technical details in the `details` parameter
- ✅ Wrap arcade pages with both ErrorProvider and ErrorBoundary
- ✅ Let socket errors show automatically (they're handled)
### DON'T:
- ❌ Don't just log errors to console
- ❌ Don't show raw error messages to users
- ❌ Don't swallow errors silently
- ❌ Don't use `alert()` for errors
- ❌ Don't forget to wrap new arcade games
## TODO
- [ ] Wrap all arcade game pages with error providers
- [ ] Add error recovery strategies (retry buttons)
- [ ] Add error reporting/telemetry
- [ ] Test all error scenarios
- [ ] Document error codes/types
## Testing Errors
To test error handling:
1. **Connection errors**: Disconnect network, try to join game
2. **Session errors**: Use invalid room ID
3. **Move rejection**: Submit invalid move
4. **React errors**: Throw error in component render
## Example: Know Your World
The know-your-world game had a "Failed to fetch session" error that was only logged to console. With the new system:
**Before:**
- Error logged to console
- User sees nothing, buttons don't work
- No way to know what's wrong
**After:**
- Error toast appears: "Game session error"
- Technical details available (collapsible)
- User can refresh or return to lobby
- Clear actionable feedback
## Migration
Existing arcade games need to be updated to wrap with error providers. Priority order:
1. ✅ useArcadeSocket hook (done)
2. ✅ Error components created (done)
3. ⏳ Wrap arcade game pages
4. ⏳ Test error scenarios
5. ⏳ Add recovery strategies

View File

@ -58,7 +58,8 @@
"Bash(gh run view:*)",
"Bash(pnpm install:*)",
"Bash(git checkout:*)",
"Bash(node server.js:*)"
"Bash(node server.js:*)",
"Bash(git fetch:*)"
],
"deny": [],
"ask": []

View File

@ -0,0 +1,223 @@
'use client'
import React, { Component, type ReactNode } from 'react'
import { css } from '../../styled-system/css'
interface Props {
children: ReactNode
fallback?: (error: Error, resetError: () => void) => ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
/**
* Error boundary for arcade games
* Catches React errors and displays a user-friendly error UI
*/
export class ArcadeErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[ArcadeErrorBoundary] Caught error:', error, errorInfo)
}
resetError = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError && this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.resetError)
}
return <DefaultErrorFallback error={this.state.error} resetError={this.resetError} />
}
return this.props.children
}
}
/**
* Default error fallback UI
*/
function DefaultErrorFallback({ error, resetError }: { error: Error; resetError: () => void }) {
const [showDetails, setShowDetails] = React.useState(false)
return (
<div
data-component="arcade-error-boundary"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
padding: '32px',
backgroundColor: 'red.50',
borderRadius: '12px',
border: '2px solid',
borderColor: 'red.300',
margin: '16px',
})}
>
{/* Error icon */}
<div
className={css({
fontSize: '64px',
marginBottom: '16px',
})}
>
</div>
{/* Error title */}
<h2
className={css({
fontSize: '24px',
fontWeight: 'bold',
color: 'red.900',
marginBottom: '8px',
})}
>
Something Went Wrong
</h2>
{/* Error message */}
<p
className={css({
fontSize: '16px',
color: 'red.800',
marginBottom: '24px',
textAlign: 'center',
maxWidth: '600px',
})}
>
The game encountered an unexpected error. Please try refreshing the page or returning to the arcade
lobby.
</p>
{/* Action buttons */}
<div
className={css({
display: 'flex',
gap: '12px',
marginBottom: '16px',
})}
>
<button
onClick={resetError}
data-action="reset-error"
className={css({
padding: '12px 24px',
backgroundColor: 'red.600',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'background-color 0.2s',
_hover: {
backgroundColor: 'red.700',
},
})}
>
Try Again
</button>
<button
onClick={() => (window.location.href = '/arcade-rooms')}
data-action="return-to-lobby"
className={css({
padding: '12px 24px',
backgroundColor: 'gray.200',
color: 'gray.800',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'background-color 0.2s',
_hover: {
backgroundColor: 'gray.300',
},
})}
>
Return to Lobby
</button>
</div>
{/* Technical details (collapsible) */}
<div className={css({ marginTop: '16px', width: '100%', maxWidth: '600px' })}>
<button
onClick={() => setShowDetails(!showDetails)}
data-action="toggle-error-details"
className={css({
background: 'transparent',
border: 'none',
color: 'red.700',
fontSize: '14px',
cursor: 'pointer',
textDecoration: 'underline',
_hover: {
color: 'red.900',
},
})}
>
{showDetails ? 'Hide' : 'Show'} technical details
</button>
{showDetails && (
<div
className={css({
marginTop: '12px',
padding: '16px',
backgroundColor: 'white',
border: '1px solid',
borderColor: 'red.200',
borderRadius: '8px',
})}
>
<div
className={css({
fontSize: '14px',
fontWeight: 'bold',
color: 'red.900',
marginBottom: '8px',
})}
>
Error: {error.message}
</div>
{error.stack && (
<pre
className={css({
fontSize: '12px',
fontFamily: 'monospace',
color: 'gray.700',
overflow: 'auto',
maxHeight: '200px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
})}
>
{error.stack}
</pre>
)}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,187 @@
'use client'
import { useEffect, useState } from 'react'
import { css } from '../../styled-system/css'
export interface ErrorToastProps {
message: string
details?: string
onDismiss: () => void
autoHideDuration?: number // milliseconds, default 10000 (10s)
}
/**
* Error toast notification component
* Shows prominent error messages to users with optional details
*/
export function ErrorToast({
message,
details,
onDismiss,
autoHideDuration = 10000,
}: ErrorToastProps) {
const [isVisible, setIsVisible] = useState(true)
const [showDetails, setShowDetails] = useState(false)
useEffect(() => {
if (autoHideDuration > 0) {
const timer = setTimeout(() => {
setIsVisible(false)
setTimeout(onDismiss, 300) // Wait for fade-out animation
}, autoHideDuration)
return () => clearTimeout(timer)
}
}, [autoHideDuration, onDismiss])
if (!isVisible) return null
return (
<div
data-component="error-toast"
className={css({
position: 'fixed',
bottom: '24px',
right: '24px',
maxWidth: '400px',
backgroundColor: 'red.700',
color: 'white',
padding: '16px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
zIndex: 20000, // Above everything
transition: 'all 0.3s ease-out',
'@media (max-width: 480px)': {
left: '16px',
right: '16px',
bottom: '16px',
maxWidth: 'none',
},
})}
>
{/* Header with dismiss button */}
<div
className={css({
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: '8px',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '8px',
flex: 1,
})}
>
<span className={css({ fontSize: '20px' })} aria-label="Error">
</span>
<strong className={css({ fontSize: '16px', fontWeight: 'bold' })}>Error</strong>
</div>
<button
onClick={() => {
setIsVisible(false)
setTimeout(onDismiss, 300)
}}
data-action="dismiss-error"
className={css({
background: 'transparent',
border: 'none',
color: 'white',
fontSize: '20px',
cursor: 'pointer',
padding: '0',
lineHeight: '1',
opacity: 0.7,
transition: 'opacity 0.2s',
_hover: {
opacity: 1,
},
})}
aria-label="Dismiss error"
>
×
</button>
</div>
{/* Error message */}
<div className={css({ fontSize: '14px', lineHeight: '1.5', marginBottom: details ? '8px' : '0' })}>
{message}
</div>
{/* Optional details */}
{details && (
<div>
<button
onClick={() => setShowDetails(!showDetails)}
data-action="toggle-error-details"
className={css({
background: 'transparent',
border: 'none',
color: 'white',
fontSize: '12px',
cursor: 'pointer',
padding: '4px 0',
textDecoration: 'underline',
opacity: 0.8,
_hover: {
opacity: 1,
},
})}
>
{showDetails ? 'Hide' : 'Show'} technical details
</button>
{showDetails && (
<pre
className={css({
marginTop: '8px',
padding: '8px',
backgroundColor: 'rgba(0, 0, 0, 0.2)',
borderRadius: '4px',
fontSize: '11px',
overflow: 'auto',
maxHeight: '200px',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
})}
>
{details}
</pre>
)}
</div>
)}
</div>
)
}
/**
* Error toast container that manages multiple toasts
*/
export function ErrorToastContainer({ errors }: { errors: Array<{ id: string; message: string; details?: string }> }) {
const [visibleErrors, setVisibleErrors] = useState(errors)
useEffect(() => {
setVisibleErrors(errors)
}, [errors])
return (
<>
{visibleErrors.map((error, index) => (
<ErrorToast
key={error.id}
message={error.message}
details={error.details}
onDismiss={() => {
setVisibleErrors((prev) => prev.filter((e) => e.id !== error.id))
}}
/>
))}
</>
)
}

View File

@ -0,0 +1,82 @@
'use client'
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
import { ErrorToast } from '@/components/ErrorToast'
interface ArcadeError {
id: string
message: string
details?: string
timestamp: number
}
interface ArcadeErrorContextValue {
errors: ArcadeError[]
addError: (message: string, details?: string) => void
clearError: (id: string) => void
clearAllErrors: () => void
}
const ArcadeErrorContext = createContext<ArcadeErrorContextValue | null>(null)
/**
* Provider for arcade error management
* Manages error toast notifications across arcade games
*/
export function ArcadeErrorProvider({ children }: { children: ReactNode }) {
const [errors, setErrors] = useState<ArcadeError[]>([])
const addError = useCallback((message: string, details?: string) => {
const error: ArcadeError = {
id: `error-${Date.now()}-${Math.random()}`,
message,
details,
timestamp: Date.now(),
}
setErrors((prev) => [...prev, error])
// Auto-remove after 15 seconds as fallback
setTimeout(() => {
setErrors((prev) => prev.filter((e) => e.id !== error.id))
}, 15000)
}, [])
const clearError = useCallback((id: string) => {
setErrors((prev) => prev.filter((e) => e.id !== id))
}, [])
const clearAllErrors = useCallback(() => {
setErrors([])
}, [])
return (
<ArcadeErrorContext.Provider value={{ errors, addError, clearError, clearAllErrors }}>
{children}
{/* Render error toasts */}
<div data-component="arcade-error-toasts">
{errors.map((error) => (
<ErrorToast
key={error.id}
message={error.message}
details={error.details}
onDismiss={() => clearError(error.id)}
autoHideDuration={10000}
/>
))}
</div>
</ArcadeErrorContext.Provider>
)
}
/**
* Hook to access arcade error context
*/
export function useArcadeError() {
const context = useContext(ArcadeErrorContext)
if (!context) {
throw new Error('useArcadeError must be used within ArcadeErrorProvider')
}
return context
}

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { io, type Socket } from 'socket.io-client'
import type { GameMove } from '@/lib/arcade/validation'
import { useArcadeError } from '@/contexts/ArcadeErrorContext'
export interface ArcadeSocketEvents {
onSessionState?: (data: {
@ -15,6 +16,8 @@ export interface ArcadeSocketEvents {
onSessionEnded?: () => void
onNoActiveSession?: () => void
onError?: (error: { error: string }) => void
/** If true, errors will NOT show toasts (for cases where game handles errors directly) */
suppressErrorToasts?: boolean
}
export interface UseArcadeSocketReturn {
@ -36,6 +39,7 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
const [socket, setSocket] = useState<Socket | null>(null)
const [connected, setConnected] = useState(false)
const eventsRef = useRef(events)
const { addError } = useArcadeError()
// Update events ref when they change
useEffect(() => {
@ -59,6 +63,25 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
socketInstance.on('disconnect', () => {
console.log('[ArcadeSocket] Disconnected')
setConnected(false)
// Show error toast unless suppressed
if (!eventsRef.current.suppressErrorToasts) {
addError(
'Connection lost',
'The connection to the game server was lost. Attempting to reconnect...'
)
}
})
socketInstance.on('connect_error', (error) => {
console.error('[ArcadeSocket] Connection error', error)
if (!eventsRef.current.suppressErrorToasts) {
addError(
'Connection error',
`Failed to connect to the game server: ${error.message}\n\nPlease check your internet connection and try refreshing the page.`
)
}
})
socketInstance.on('session-state', (data) => {
@ -66,6 +89,14 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
})
socketInstance.on('no-active-session', () => {
// Show error toast unless suppressed
if (!eventsRef.current.suppressErrorToasts) {
addError(
'No active session',
'No game session was found. Please start a new game or join an existing room.'
)
}
eventsRef.current.onNoActiveSession?.()
})
@ -75,6 +106,15 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
socketInstance.on('move-rejected', (data) => {
console.log(`[ArcadeSocket] Move rejected: ${data.error}`)
// Show error toast for move rejections unless suppressed or it's a version conflict
if (!eventsRef.current.suppressErrorToasts && !data.versionConflict) {
addError(
'Move rejected',
`Your move was not accepted: ${data.error}\n\nMove type: ${data.move.type}`
)
}
eventsRef.current.onMoveRejected?.(data)
})
@ -85,6 +125,15 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
socketInstance.on('session-error', (data) => {
console.error('[ArcadeSocket] Session error', data)
// Show error toast unless suppressed
if (!eventsRef.current.suppressErrorToasts) {
addError(
'Game session error',
`Error: ${data.error}\n\nThis usually means there was a problem loading or updating the game session. Please try refreshing the page or returning to the lobby.`
)
}
eventsRef.current.onError?.(data)
})