feat(know-your-world): add session-based give-up voting and fix cursor emojis

Multiplayer improvements:
- Add unanimous give-up voting for cooperative mode (all sessions must agree)
- Track give-up votes by session (userId) not player, since local players discuss together
- Add activeUserIds to track unique sessions participating in the game

Cursor emoji fix:
- Pass userId through cursor-update socket events to identify sender's session
- Use memberPlayers from roomData (canonical source) for cursor emoji lookup
- Show all emojis from a session's players on that session's network cursor
- Build playerMetadata with correct ownership map from roomData.memberPlayers

Files changed:
- socket-server.ts: Add userId to cursor-update events
- useArcadeSocket.ts: Add userId to cursor update types and functions
- useArcadeSession.ts: Store userId with cursor positions
- Provider.tsx: Expose memberPlayers, pass userId with cursor updates
- PlayingPhase.tsx: Include viewerId in cursor updates
- MapRenderer.tsx: Use memberPlayers for emoji lookup, add voting UI props
- Validator.ts: Session-based give-up voting logic
- types.ts: Add giveUpVotes and activeUserIds to state

🤖 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-26 09:18:47 -06:00
parent e7fbb44fdb
commit bb2d6fc7d8
10 changed files with 254 additions and 31 deletions

View File

@@ -35,8 +35,15 @@ interface KnowYourWorldContextValue {
setContinent: (continent: import('./continents').ContinentId | 'all') => void
// Cursor position sharing (for multiplayer)
otherPlayerCursors: Record<string, { x: number; y: number } | null>
sendCursorUpdate: (playerId: string, cursorPosition: { x: number; y: number } | null) => void
otherPlayerCursors: Record<string, { x: number; y: number; userId: string } | null>
sendCursorUpdate: (
playerId: string,
userId: string,
cursorPosition: { x: number; y: number } | null
) => void
// Member players mapping (userId -> players) for cursor emoji display
memberPlayers: Record<string, Array<{ id: string; name: string; emoji: string; color: string }>>
}
const KnowYourWorldContext = createContext<KnowYourWorldContextValue | null>(null)
@@ -102,8 +109,10 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
guessHistory: [],
startTime: 0,
activePlayers: [],
activeUserIds: [],
playerMetadata: {},
giveUpReveal: null,
giveUpVotes: [],
}
}, [roomData])
@@ -122,11 +131,11 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
applyMove: (state) => state, // Server handles all state updates
})
// Pass through cursor updates with the provided player ID
// Pass through cursor updates with the provided player ID and userId
const sendCursorUpdate = useCallback(
(playerId: string, cursorPosition: { x: number; y: number } | null) => {
if (playerId) {
sessionSendCursorUpdate(playerId, cursorPosition)
(playerId: string, sessionUserId: string, cursorPosition: { x: number; y: number } | null) => {
if (playerId && sessionUserId) {
sessionSendCursorUpdate(playerId, sessionUserId, cursorPosition)
}
},
[sessionSendCursorUpdate]
@@ -134,7 +143,14 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
// Action: Start Game
const startGame = useCallback(() => {
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
// Build ownership map from roomData to correctly map players to their owners
const ownershipMap = buildPlayerOwnershipFromRoomData(roomData)
const playerMetadata = buildPlayerMetadata(
activePlayers,
ownershipMap,
players,
viewerId || undefined
)
sendMove({
type: 'START_GAME',
@@ -152,6 +168,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
activePlayers,
players,
viewerId,
roomData,
sendMove,
state.selectedMap,
state.gameMode,
@@ -385,6 +402,14 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
})
}, [viewerId, sendMove, state.currentPlayer, activePlayers])
// Memoize memberPlayers from roomData for cursor emoji display
const memberPlayers = useMemo(() => {
return (roomData?.memberPlayers ?? {}) as Record<
string,
Array<{ id: string; name: string; emoji: string; color: string }>
>
}, [roomData?.memberPlayers])
return (
<KnowYourWorldContext.Provider
value={{
@@ -406,6 +431,7 @@ export function KnowYourWorldProvider({ children }: { children: React.ReactNode
setContinent,
otherPlayerCursors,
sendCursorUpdate,
memberPlayers,
}}
>
{children}

View File

@@ -47,8 +47,10 @@ describe('KnowYourWorldValidator', () => {
guessHistory: [],
startTime: Date.now(),
activePlayers: ['player-1'],
activeUserIds: ['user-1'],
playerMetadata: { 'player-1': { name: 'Player 1' } },
giveUpReveal: null,
giveUpVotes: [],
...overrides,
})

View File

@@ -26,9 +26,9 @@ export class KnowYourWorldValidator
): Promise<ValidationResult> {
switch (move.type) {
case 'START_GAME':
return await this.validateStartGame(state, move.data)
return await this.validateStartGame(state, move.userId, move.data)
case 'CLICK_REGION':
return this.validateClickRegion(state, move.playerId, move.data)
return this.validateClickRegion(state, move.playerId, move.userId, move.data)
case 'NEXT_ROUND':
return await this.validateNextRound(state)
case 'END_GAME':
@@ -48,13 +48,17 @@ export class KnowYourWorldValidator
case 'SET_CONTINENT':
return this.validateSetContinent(state, move.data.selectedContinent)
case 'GIVE_UP':
return await this.validateGiveUp(state, move.playerId)
return await this.validateGiveUp(state, move.playerId, move.userId)
default:
return { valid: false, error: 'Unknown move type' }
}
}
private async validateStartGame(state: KnowYourWorldState, data: any): Promise<ValidationResult> {
private async validateStartGame(
state: KnowYourWorldState,
userId: string,
data: any
): Promise<ValidationResult> {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only start from setup phase' }
}
@@ -81,10 +85,14 @@ export class KnowYourWorldValidator
// Check if we should go to study phase or directly to playing
const shouldStudy = state.studyDuration > 0
// Track the initial userId (session) - other sessions will be added as they make moves
const activeUserIds = userId ? [userId] : []
const newState: KnowYourWorldState = {
...state,
gamePhase: shouldStudy ? 'studying' : 'playing',
activePlayers,
activeUserIds,
playerMetadata,
selectedMap,
gameMode,
@@ -101,6 +109,7 @@ export class KnowYourWorldValidator
guessHistory: [],
startTime: Date.now(),
giveUpReveal: null,
giveUpVotes: [],
}
return { valid: true, newState }
@@ -109,6 +118,7 @@ export class KnowYourWorldValidator
private validateClickRegion(
state: KnowYourWorldState,
playerId: string,
userId: string,
data: any
): ValidationResult {
if (state.gamePhase !== 'playing') {
@@ -126,6 +136,9 @@ export class KnowYourWorldValidator
return { valid: false, error: 'Not your turn' }
}
// Track this session if not already known
const activeUserIds = this.addUserIdIfNew(state.activeUserIds, userId)
const isCorrect = regionId === state.currentPrompt
const guessRecord: GuessRecord = {
playerId,
@@ -165,6 +178,8 @@ export class KnowYourWorldValidator
guessHistory,
endTime: Date.now(),
giveUpReveal: null,
giveUpVotes: [], // Clear votes when game ends
activeUserIds,
}
return { valid: true, newState }
}
@@ -190,6 +205,8 @@ export class KnowYourWorldValidator
scores: newScores,
guessHistory,
giveUpReveal: null,
giveUpVotes: [], // Clear votes when moving to next region
activeUserIds,
}
return { valid: true, newState }
@@ -213,6 +230,7 @@ export class KnowYourWorldValidator
attempts: newAttempts,
guessHistory,
currentPlayer: nextPlayer,
activeUserIds,
}
return {
@@ -223,6 +241,15 @@ export class KnowYourWorldValidator
}
}
// Helper: Add userId to activeUserIds if not already present
private addUserIdIfNew(activeUserIds: string[] | undefined, userId: string): string[] {
const existing = activeUserIds ?? []
if (!userId || existing.includes(userId)) {
return existing
}
return [...existing, userId]
}
private async validateNextRound(state: KnowYourWorldState): Promise<ValidationResult> {
if (state.gamePhase !== 'results') {
return { valid: false, error: 'Can only start next round from results' }
@@ -264,6 +291,7 @@ export class KnowYourWorldValidator
startTime: Date.now(),
endTime: undefined,
giveUpReveal: null,
giveUpVotes: [],
}
return { valid: true, newState }
@@ -407,7 +435,8 @@ export class KnowYourWorldValidator
private async validateGiveUp(
state: KnowYourWorldState,
playerId: string
playerId: string,
userId: string
): Promise<ValidationResult> {
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Can only give up during playing phase' }
@@ -422,6 +451,47 @@ export class KnowYourWorldValidator
return { valid: false, error: 'Not your turn' }
}
// Track this session if not already known
const activeUserIds = this.addUserIdIfNew(state.activeUserIds, userId)
// For cooperative mode with multiple sessions: require unanimous vote by session
// (All local players on the same session count as one vote since they can discuss together)
const isCooperativeMultiplayer = state.gameMode === 'cooperative' && activeUserIds.length > 1
if (isCooperativeMultiplayer) {
// Check if this session has already voted
const existingVotes = state.giveUpVotes ?? []
if (existingVotes.includes(userId)) {
return { valid: false, error: 'Your session has already voted to give up' }
}
// Add this session's vote
const newVotes = [...existingVotes, userId]
// Check if unanimous (all sessions have voted)
const isUnanimous = activeUserIds.every((uid) => newVotes.includes(uid))
if (!isUnanimous) {
// Not unanimous yet - just record the vote
const newState: KnowYourWorldState = {
...state,
giveUpVotes: newVotes,
activeUserIds,
}
return { valid: true, newState }
}
// Unanimous! Fall through to execute the give up
}
// Execute the actual give up (single session, turn-based, or unanimous cooperative)
return this.executeGiveUp(state, activeUserIds)
}
private async executeGiveUp(
state: KnowYourWorldState,
activeUserIds: string[]
): Promise<ValidationResult> {
// Get region info for the reveal
const mapData = await getFilteredMapDataLazy(
state.selectedMap,
@@ -476,6 +546,8 @@ export class KnowYourWorldValidator
regionName: region.name,
timestamp: Date.now(),
},
giveUpVotes: [], // Clear votes after give up is executed
activeUserIds,
}
return { valid: true, newState }
@@ -507,8 +579,10 @@ export class KnowYourWorldValidator
guessHistory: [],
startTime: 0,
activePlayers: [],
activeUserIds: [],
playerMetadata: {},
giveUpReveal: null,
giveUpVotes: [],
}
}

View File

@@ -113,6 +113,7 @@ interface MapRendererProps {
name: string
emoji: string
color: string
userId?: string // Session ID that owns this player
}
>
// Give up reveal animation
@@ -138,8 +139,14 @@ interface MapRendererProps {
gameMode?: 'cooperative' | 'race' | 'turn-based'
currentPlayer?: string // The player whose turn it is (for turn-based mode)
localPlayerId?: string // The local player's ID (to filter out our own cursor from others)
otherPlayerCursors?: Record<string, { x: number; y: number } | null>
otherPlayerCursors?: Record<string, { x: number; y: number; userId: string } | null>
onCursorUpdate?: (cursorPosition: { x: number; y: number } | null) => void
// Unanimous give-up voting (for cooperative multiplayer)
giveUpVotes?: string[] // Session/viewer IDs (userIds) who have voted to give up
activeUserIds?: string[] // All unique session IDs participating (to show "1/2 sessions voted")
viewerId?: string // This viewer's userId (to check if local session has voted)
// Member players mapping (userId -> players) for cursor emoji display
memberPlayers?: Record<string, Array<{ id: string; name: string; emoji: string; color: string }>>
}
/**
@@ -200,6 +207,10 @@ export function MapRenderer({
localPlayerId,
otherPlayerCursors = {},
onCursorUpdate,
giveUpVotes = [],
activeUserIds = [],
viewerId,
memberPlayers = {},
}: MapRendererProps) {
// Extract force tuning parameters with defaults
const {
@@ -1210,7 +1221,15 @@ export function MapRenderer({
return () => {
clearTimeout(timeoutId)
}
}, [mapData, regionsFound, guessHistory, svgDimensions, excludedRegions, excludedRegionIds, displayViewBox])
}, [
mapData,
regionsFound,
guessHistory,
svgDimensions,
excludedRegions,
excludedRegionIds,
displayViewBox,
])
// Calculate viewBox dimensions for label offset calculations and sea background
const viewBoxParts = displayViewBox.split(' ').map(Number)
@@ -1974,7 +1993,6 @@ export function MapRenderer({
opacity={0.8}
/>
)}
</animated.svg>
{/* HTML labels positioned absolutely over the SVG */}
@@ -3208,6 +3226,14 @@ export function MapRenderer({
const player = playerMetadata[playerId]
if (!player) return null
// In collaborative mode, find all players from the same session and show all their emojis
// Use memberPlayers (from roomData) which is the canonical source of player ownership
const cursorUserId = position.userId
const sessionPlayers =
gameMode === 'cooperative' && cursorUserId && memberPlayers[cursorUserId]
? memberPlayers[cursorUserId]
: [player]
// Convert SVG coordinates to screen coordinates
const svgRect = svgRef.current!.getBoundingClientRect()
const containerRect = containerRef.current!.getBoundingClientRect()
@@ -3308,7 +3334,8 @@ export function MapRenderer({
{/* Center dot */}
<circle cx="12" cy="12" r="2" fill={player.color} />
</svg>
{/* Player emoji label - positioned below crosshair */}
{/* Player emoji label(s) - positioned below crosshair */}
{/* In collaborative mode, show all emojis from the same session */}
<div
style={{
position: 'absolute',
@@ -3317,9 +3344,10 @@ export function MapRenderer({
transform: 'translateX(-50%)',
fontSize: '16px',
textShadow: '0 1px 2px rgba(0,0,0,0.5)',
whiteSpace: 'nowrap',
}}
>
{player.emoji}
{sessionPlayers.map((p) => p.emoji).join('')}
</div>
</div>
)
@@ -3418,11 +3446,79 @@ export function MapRenderer({
},
})}
>
Give Up (G)
{(() => {
// Determine button text based on game mode and voting state
// Voting is per-session (userId), not per-player
const isCooperativeMultiplayer =
gameMode === 'cooperative' && activeUserIds.length > 1
const hasLocalSessionVoted = viewerId && giveUpVotes.includes(viewerId)
const voteCount = giveUpVotes.length
const totalSessions = activeUserIds.length
if (isCooperativeMultiplayer) {
if (hasLocalSessionVoted) {
return (
<span>
Voted ({voteCount}/{totalSessions})
</span>
)
}
if (voteCount > 0) {
return (
<span>
Give Up ({voteCount}/{totalSessions}) (G)
</span>
)
}
}
return 'Give Up (G)'
})()}
</button>
)
})()}
{/* Show waiting message for give up voting (cooperative multiplayer with multiple sessions) */}
{gameMode === 'cooperative' &&
activeUserIds.length > 1 &&
giveUpVotes.length > 0 &&
giveUpVotes.length < activeUserIds.length &&
viewerId &&
giveUpVotes.includes(viewerId) &&
(() => {
if (!svgRef.current || !containerRef.current || svgDimensions.width === 0) return null
const svgRect = svgRef.current.getBoundingClientRect()
const containerRect = containerRef.current.getBoundingClientRect()
const svgOffsetY = svgRect.top - containerRect.top
const buttonRight =
containerRect.width - (svgRect.left - containerRect.left + svgRect.width) + 8
const remaining = activeUserIds.length - giveUpVotes.length
return (
<div
data-element="give-up-voters"
className={css({
position: 'absolute',
fontSize: 'xs',
color: isDark ? 'yellow.300' : 'yellow.700',
bg: isDark ? 'gray.800/90' : 'white/90',
padding: '1 2',
rounded: 'md',
border: '1px solid',
borderColor: isDark ? 'yellow.600' : 'yellow.400',
zIndex: 49,
})}
style={{
top: `${svgOffsetY + 44}px`, // Below the Give Up button
right: `${buttonRight}px`,
}}
>
Waiting for {remaining} other {remaining === 1 ? 'player' : 'players'}...
</div>
)
})()}
{/* Dev-only crop tool for getting custom viewBox coordinates */}
<DevCropTool
svgRef={svgRef}

View File

@@ -11,7 +11,8 @@ import { useViewerId } from '@/lib/arcade/game-sdk'
import { useGameMode } from '@/lib/arcade/game-sdk'
export function PlayingPhase() {
const { state, clickRegion, giveUp, otherPlayerCursors, sendCursorUpdate } = useKnowYourWorld()
const { state, clickRegion, giveUp, otherPlayerCursors, sendCursorUpdate, memberPlayers } =
useKnowYourWorld()
const { data: viewerId } = useViewerId()
const { activePlayers, players } = useGameMode()
@@ -28,12 +29,14 @@ export function PlayingPhase() {
return Array.from(activePlayers)[0] || ''
}, [activePlayers, players])
// Wrap sendCursorUpdate to include localPlayerId
// Wrap sendCursorUpdate to include localPlayerId and viewerId (session ID)
const handleCursorUpdate = useCallback(
(cursorPosition: { x: number; y: number } | null) => {
sendCursorUpdate(localPlayerId, cursorPosition)
if (viewerId) {
sendCursorUpdate(localPlayerId, viewerId, cursorPosition)
}
},
[localPlayerId, sendCursorUpdate]
[localPlayerId, viewerId, sendCursorUpdate]
)
const mapData = getFilteredMapDataSync(
@@ -145,6 +148,10 @@ export function PlayingPhase() {
localPlayerId={localPlayerId}
otherPlayerCursors={otherPlayerCursors}
onCursorUpdate={handleCursorUpdate}
giveUpVotes={state.giveUpVotes}
activeUserIds={state.activeUserIds}
viewerId={viewerId ?? undefined}
memberPlayers={memberPlayers}
/>
</div>
</Panel>

View File

@@ -17,7 +17,7 @@ export const customCrops: CropOverrides = {
europe: '399.10 106.44 200.47 263.75',
africa: '472.47 346.18 95.84 227.49',
oceania: '775.56 437.22 233.73 161.35',
}
},
}
/**

View File

@@ -72,6 +72,7 @@ export interface KnowYourWorldState extends GameState {
// Multiplayer
activePlayers: string[]
activeUserIds: string[] // Unique session/viewer IDs participating in the game
playerMetadata: Record<string, any>
// Give up reveal state (for animation)
@@ -80,6 +81,9 @@ export interface KnowYourWorldState extends GameState {
regionName: string
timestamp: number // For animation timing key
} | null
// Unanimous give-up voting (for cooperative multiplayer)
giveUpVotes: string[] // Session/viewer IDs (userIds) who have voted to give up on current prompt
}
// Move types

View File

@@ -81,17 +81,19 @@ export interface UseArcadeSessionReturn<TState> {
/**
* Other players' cursor positions (ephemeral, real-time)
* Map of playerId -> { x, y } in SVG coordinates, or null if cursor left
* Map of playerId -> { x, y, userId } in SVG coordinates, or null if cursor left
*/
otherPlayerCursors: Record<string, { x: number; y: number } | null>
otherPlayerCursors: Record<string, { x: number; y: number; userId: string } | null>
/**
* Send cursor position update to other players (ephemeral, real-time)
* @param playerId - The player ID sending the cursor update
* @param userId - The session/viewer ID that owns this cursor
* @param cursorPosition - SVG coordinates, or null when cursor leaves the map
*/
sendCursorUpdate: (
playerId: string,
userId: string,
cursorPosition: { x: number; y: number } | null
) => void
}
@@ -169,7 +171,7 @@ export function useArcadeSession<TState>(
// Track other players' cursor positions (ephemeral, real-time)
const [otherPlayerCursors, setOtherPlayerCursors] = useState<
Record<string, { x: number; y: number } | null>
Record<string, { x: number; y: number; userId: string } | null>
>({})
// WebSocket connection
@@ -273,7 +275,9 @@ export function useArcadeSession<TState>(
onCursorUpdate: (data) => {
setOtherPlayerCursors((prev) => ({
...prev,
[data.playerId]: data.cursorPosition,
[data.playerId]: data.cursorPosition
? { ...data.cursorPosition, userId: data.userId }
: null,
}))
},
})
@@ -321,9 +325,9 @@ export function useArcadeSession<TState>(
// Send cursor position update to other players (ephemeral, real-time)
const sendCursorUpdate = useCallback(
(playerId: string, cursorPosition: { x: number; y: number } | null) => {
(playerId: string, sessionUserId: string, cursorPosition: { x: number; y: number } | null) => {
if (!roomId) return // Only works in room-based games
socketSendCursorUpdate(roomId, playerId, cursorPosition)
socketSendCursorUpdate(roomId, playerId, sessionUserId, cursorPosition)
},
[roomId, socketSendCursorUpdate]
)

View File

@@ -19,6 +19,7 @@ export interface ArcadeSocketEvents {
/** Cursor position update from another player (ephemeral, real-time) */
onCursorUpdate?: (data: {
playerId: string
userId: string // Session ID that owns this cursor
cursorPosition: { x: number; y: number } | null
}) => void
/** If true, errors will NOT show toasts (for cases where game handles errors directly) */
@@ -36,6 +37,7 @@ export interface UseArcadeSocketReturn {
sendCursorUpdate: (
roomId: string,
playerId: string,
userId: string,
cursorPosition: { x: number; y: number } | null
) => void
}
@@ -215,9 +217,14 @@ export function useArcadeSocket(events: ArcadeSocketEvents = {}): UseArcadeSocke
)
const sendCursorUpdate = useCallback(
(roomId: string, playerId: string, cursorPosition: { x: number; y: number } | null) => {
(
roomId: string,
playerId: string,
userId: string,
cursorPosition: { x: number; y: number } | null
) => {
if (!socket) return
socket.emit('cursor-update', { roomId, playerId, cursorPosition })
socket.emit('cursor-update', { roomId, playerId, userId, cursorPosition })
},
[socket]
)

View File

@@ -700,15 +700,18 @@ export function initializeSocketServer(httpServer: HTTPServer) {
({
roomId,
playerId,
userId,
cursorPosition,
}: {
roomId: string
playerId: string
userId: string // Session ID that owns this cursor
cursorPosition: { x: number; y: number } | null // SVG coordinates, null when cursor leaves
}) => {
// Broadcast to all other sockets in the game room (exclude sender)
socket.to(`game:${roomId}`).emit('cursor-update', {
playerId,
userId,
cursorPosition,
})
}