Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 38 additions & 25 deletions src/engine/core/Game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
getAdaptiveCommandDelay,
getLatencyStats,
getAllPeerIds,
getSlotIdForPeer,
getAllRemoteSlotIds,
} from '@/store/multiplayerStore';

// Multiplayer message types
Expand Down Expand Up @@ -168,20 +170,26 @@ export class Game extends GameCore {
if (message.payload) {
const command = message.payload as GameCommand;

// SECURITY: Validate playerId matches remote peer
const remotePeerId = useMultiplayerStore.getState().remotePeerId;
if (command.playerId !== remotePeerId) {
console.error(
`[Game] SECURITY: Rejected command with spoofed playerId. ` +
`Expected: ${remotePeerId}, Got: ${command.playerId}`
);
this.eventBus.emit('security:spoofedPlayerId', {
expectedPlayerId: remotePeerId,
spoofedPlayerId: command.playerId,
commandType: command.type,
tick: command.tick,
});
return;
// SECURITY: Validate playerId is from a connected remote player
// Commands use slot IDs (e.g., "player1", "player2"), not peer IDs
const validRemoteSlotIds = getAllRemoteSlotIds();
if (!validRemoteSlotIds.includes(command.playerId)) {
// Also check legacy single-peer mode
const legacyPeerId = useMultiplayerStore.getState().remotePeerId;
const legacySlotId = legacyPeerId ? getSlotIdForPeer(legacyPeerId) : null;
if (command.playerId !== legacySlotId) {
console.error(
`[Game] SECURITY: Rejected command with invalid playerId. ` +
`Valid slot IDs: [${validRemoteSlotIds.join(', ')}], Got: ${command.playerId}`
);
this.eventBus.emit('security:spoofedPlayerId', {
expectedPlayerId: validRemoteSlotIds.join(', '),
spoofedPlayerId: command.playerId,
commandType: command.type,
tick: command.tick,
});
return;
}
}

// SECURITY: Validate command tick is within acceptable range
Expand Down Expand Up @@ -477,25 +485,30 @@ export class Game extends GameCore {
// ============================================================================

/**
* Get the set of player IDs that should send commands for lockstep.
* Supports up to 8 players: local player + all remote peers.
* Get the set of player slot IDs that should send commands for lockstep.
* Uses slot IDs (e.g., "player1", "player2") not network peer IDs.
* Supports up to 8 players: local player + all remote players.
*/
private getExpectedPlayerIds(): string[] {
const players: string[] = [this.config.playerId];

// Get all remote peer IDs (supports 2-8 players)
const remotePeerIds = getAllPeerIds();
for (const peerId of remotePeerIds) {
if (!players.includes(peerId)) {
players.push(peerId);
// Get all remote player slot IDs (supports 2-8 players)
// These are mapped from peer IDs to slot IDs when peers connect
const remoteSlotIds = getAllRemoteSlotIds();
for (const slotId of remoteSlotIds) {
if (!players.includes(slotId)) {
players.push(slotId);
}
}

// Fallback for legacy single-peer mode
if (remotePeerIds.length === 0) {
// Fallback for legacy single-peer mode: try to get slot ID from peer ID mapping
if (remoteSlotIds.length === 0) {
const remotePeerId = useMultiplayerStore.getState().remotePeerId;
if (remotePeerId && !players.includes(remotePeerId)) {
players.push(remotePeerId);
if (remotePeerId) {
const slotId = getSlotIdForPeer(remotePeerId);
if (slotId && !players.includes(slotId)) {
players.push(slotId);
}
}
}

Expand Down
25 changes: 24 additions & 1 deletion src/hooks/useMultiplayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,26 @@ export function useLobby(options: UseLobbyOptions = {}): UseLobbyReturn {
}
}, [hostConnection]);

// Set up peer-to-slot mapping for guests once we receive lobby state
useEffect(() => {
if (!hostConnection || hostConnection.readyState !== 'open' || !receivedLobbyState) {
return;
}

const store = useMultiplayerStore.getState();
const hostPubkey = pubkeyRef.current ? `host-${pubkeyRef.current.slice(0, 8)}` : 'host';

// Find the host's slot - the first human player that isn't a guest
const hostSlot = receivedLobbyState.playerSlots.find(
slot => slot.type === 'human' && !slot.isGuest
);

if (hostSlot) {
debugNetworking.log(`[Lobby] Setting peer-slot mapping: ${hostPubkey} -> ${hostSlot.id}`);
store.setPeerSlotMapping(hostPubkey, hostSlot.id);
}
}, [hostConnection, receivedLobbyState]);

// Sync ALL guest data channels to multiplayerStore (for host mode - 8 player support)
useEffect(() => {
const connectedGuests = guests.filter(g => g.dataChannel?.readyState === 'open');
Expand All @@ -871,7 +891,7 @@ export function useLobby(options: UseLobbyOptions = {}): UseLobbyReturn {
store.setMultiplayer(true);
store.setHost(true);

// Add all connected guests to the peer channels
// Add all connected guests to the peer channels and set up peer-slot mapping
for (const guest of connectedGuests) {
if (guest.dataChannel) {
// Check if this peer is already added
Expand All @@ -880,6 +900,9 @@ export function useLobby(options: UseLobbyOptions = {}): UseLobbyReturn {
debugNetworking.log(`[Lobby] Adding guest ${guest.name} (${guest.pubkey.slice(0, 8)}...) to multiplayerStore`);
store.addPeer(guest.pubkey, guest.dataChannel);
}
// Set up peer-to-slot mapping for command validation
// This maps the guest's peer ID to their assigned player slot
store.setPeerSlotMapping(guest.pubkey, guest.slotId);
}
}

Expand Down
67 changes: 67 additions & 0 deletions src/store/multiplayerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ export interface MultiplayerState {
remotePeerId: string | null; // Legacy: first peer for backwards compatibility
remotePeerIds: string[]; // All remote peer IDs

// Peer ID to slot ID mapping (e.g., "pubkey123" -> "player2")
// This maps network peer IDs to game slot IDs for command validation
peerToSlotId: Map<string, string>;

// WebRTC objects - supports multiple peers
dataChannel: RTCDataChannel | null; // Legacy: first channel for backwards compatibility
peerChannels: Map<string, PeerConnection>; // All peer connections by ID
Expand Down Expand Up @@ -121,6 +125,13 @@ export interface MultiplayerState {
getAllPeerIds: () => string[];
getConnectedPeerCount: () => number;

// Peer-to-slot mapping for command validation
setPeerSlotMapping: (peerId: string, slotId: string) => void;
removePeerSlotMapping: (peerId: string) => void;
getSlotIdForPeer: (peerId: string) => string | null;
getPeerIdForSlot: (slotId: string) => string | null;
getAllRemoteSlotIds: () => string[];

// Network pause
setNetworkPaused: (paused: boolean, reason?: string) => void;

Expand Down Expand Up @@ -185,6 +196,7 @@ const initialState = {
localPeerId: null,
remotePeerId: null,
remotePeerIds: [] as string[],
peerToSlotId: new Map<string, string>(),
dataChannel: null,
peerChannels: new Map<string, PeerConnection>(),
reconnectCallback: null,
Expand Down Expand Up @@ -383,6 +395,39 @@ export const useMultiplayerStore = create<MultiplayerState>((set, get) => ({
return count;
},

// Peer-to-slot mapping for command validation
setPeerSlotMapping: (peerId: string, slotId: string) => {
const newMapping = new Map(get().peerToSlotId);
newMapping.set(peerId, slotId);
set({ peerToSlotId: newMapping });
debugNetworking.log(`[Multiplayer] Set peer-slot mapping: ${peerId} -> ${slotId}`);
},

removePeerSlotMapping: (peerId: string) => {
const newMapping = new Map(get().peerToSlotId);
newMapping.delete(peerId);
set({ peerToSlotId: newMapping });
debugNetworking.log(`[Multiplayer] Removed peer-slot mapping for ${peerId}`);
},

getSlotIdForPeer: (peerId: string) => {
return get().peerToSlotId.get(peerId) || null;
},

getPeerIdForSlot: (slotId: string) => {
const mapping = get().peerToSlotId;
for (const [peerId, slot] of mapping.entries()) {
if (slot === slotId) {
return peerId;
}
}
return null;
},

getAllRemoteSlotIds: () => {
return Array.from(get().peerToSlotId.values());
},

setDataChannel: (channel) => {
// Set up message handler on the channel
// IMPORTANT: Use addEventListener instead of onmessage assignment to avoid
Expand Down Expand Up @@ -914,6 +959,7 @@ export const useMultiplayerStore = create<MultiplayerState>((set, get) => ({
latencyStats: { ...defaultLatencyStats },
pendingPings: new Map(),
peerChannels: new Map(),
peerToSlotId: new Map(),
remotePeerIds: [],
});
},
Expand Down Expand Up @@ -1012,3 +1058,24 @@ export function broadcastMessage(data: unknown, excludePeerId?: string): number
export function sendToPeer(peerId: string, data: unknown): boolean {
return useMultiplayerStore.getState().sendToPeer(peerId, data);
}

// Peer-to-slot mapping utilities
export function setPeerSlotMapping(peerId: string, slotId: string): void {
useMultiplayerStore.getState().setPeerSlotMapping(peerId, slotId);
}

export function removePeerSlotMapping(peerId: string): void {
useMultiplayerStore.getState().removePeerSlotMapping(peerId);
}

export function getSlotIdForPeer(peerId: string): string | null {
return useMultiplayerStore.getState().getSlotIdForPeer(peerId);
}

export function getPeerIdForSlot(slotId: string): string | null {
return useMultiplayerStore.getState().getPeerIdForSlot(slotId);
}

export function getAllRemoteSlotIds(): string[] {
return useMultiplayerStore.getState().getAllRemoteSlotIds();
}
Loading