Table of Contents
Introduction
In Part 1 of our Skills Popping Game series, we built a fun, interactive bubble-popping game where visitors could click floating skills to earn points. While entertaining on its own, the experience was limited to a single player with scores saved only to localStorage. Now, it's time to take our game to the next level by adding real-time multiplayer capabilities!
This Part 2 guide focuses on transforming our solo game into a shared experience where visitors can see each other popping bubbles in real-time, compete on a live leaderboard, and interact within the same virtual space. We'll implement this using Pusher Channels for real-time WebSocket communication, create a clean architecture for managing connections, and explore various approaches to building real-time multiplayer experiences.
Live Demo
Experience the multiplayer version in action! Open this demo in multiple browser windows to see how players interact in real-time. Notice how skills popped by other players turn red, and how the leaderboard updates instantly:
Each player is assigned a random username when they join. Skills popped by other players will appear in red, while the ones you pop remain green. The leaderboard shows all connected players and their current scores in real-time.
Real-time Multiplayer Tech Stack
Before diving into implementation details, let's understand the key technologies that make our multiplayer game possible:
Pusher Channels
Pusher Channels is a hosted service that provides real-time WebSocket communication between clients and servers. It offers features like secure authentication, presence channels for tracking online users, and high-reliability infrastructure that scales automatically with your users. Unlike self-hosted solutions, Pusher eliminates the need to manage WebSocket infrastructure.
Next.js Route Handlers
With Next.js App Router, we use Route Handlers (the successor to API Routes) to create server-side endpoints that interact with Pusher's API. These endpoints handle user management, event broadcasting, and authentication for our multiplayer game.
React Context
To manage our Pusher connection and share real-time data across components, we'll create a React Context provider that encapsulates all the real-time functionality. This provides a clean API for components to interact with the multiplayer system while abstracting away the implementation details.
Let's start by adding the necessary packages to our project:
npm install pusher pusher-js
# or
yarn add pusher pusher-js
Step-by-Step Implementation
Now let's walk through the implementation process for adding multiplayer functionality to our skills popping game.
1. Server-Side Pusher Setup
First, we need to create a Route Handler that will interact with Pusher's API. This endpoint will manage user state, broadcast events, and handle actions. Create a file at app/api/pusher/route.ts
:
import { NextResponse } from "next/server";
import Pusher from "pusher";
// Name generation data
const adjectives = [
"Swift", "Clever", "Bright", "Quick", "Smart", "Agile", "Rapid", "Sharp",
"Keen", "Deft", "Wise", "Tech", "Cyber", "Digital", "Coding", "Cloud",
"Crypto", "Dev", "Admin", "Hack", "Script", "Pixel", "Binary", "Linux",
];
const nouns = [
"Coder", "Hacker", "Ninja", "Eagle", "Tiger", "Panda", "Lion", "Dragon",
"Wizard", "Guru", "Master", "Knight", "Falcon", "Phoenix", "Wolf", "Fox",
"Bear", "Hawk", "Owl", "Monkey", "Developer", "Engineer", "Architect", "Designer",
];
// Generate random username
const generateRandomName = (): string => {
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const number = Math.floor(Math.random() * 1000);
return `${adjective}${noun}${number}`;
};
// Initialize Pusher
const pusher = new Pusher({
appId: process.env.PUSHER_APP_ID!,
key: process.env.PUSHER_KEY!,
secret: process.env.PUSHER_SECRET!,
cluster: process.env.PUSHER_CLUSTER!,
useTLS: true,
});
// In-memory store for connected users and game state
const users: Record<string, {
name: string;
score: number;
active: boolean;
channelId?: string;
lastActivityTimestamp: number;
heartbeats: number;
}> = {};
// Clean inactive users periodically
const cleanInactiveUsers = async () => {
const now = Date.now();
const inactivityThreshold = 2 * 60 * 1000; // 2 minutes of inactivity = inactive user
const heartbeatMissingThreshold = 3; // Missing 3 consecutive heartbeats = inactive user
Object.entries(users).forEach(async ([id, user]) => {
// Check if user is already marked inactive
if (!user.active) {
// Delete inactive users
delete users[id];
// Broadcast updated user list after cleanup
await pusher.trigger("presence-game", "userLeft", {
userId: id,
users: Object.entries(users)
.filter(([, user]) => user.active)
.map(([id, user]) => ({
id,
name: user.name,
score: user.score,
})),
});
}
// Check for inactivity based on timestamp
else if (now - user.lastActivityTimestamp > inactivityThreshold) {
console.log(`User ${id} (${user.name}) timed out due to inactivity`);
user.active = false;
// Broadcast that user is now inactive
await pusher.trigger("presence-game", "userLeft", {
userId: id,
users: Object.entries(users)
.filter(([, user]) => user.active)
.map(([id, user]) => ({
id,
name: user.name,
score: user.score,
})),
});
}
// Check for missed heartbeats
else if (user.heartbeats >= heartbeatMissingThreshold) {
console.log(`User ${id} (${user.name}) timed out due to missing heartbeats`);
user.active = false;
// Broadcast that user is now inactive
await pusher.trigger("presence-game", "userLeft", {
userId: id,
users: Object.entries(users)
.filter(([, user]) => user.active)
.map(([id, user]) => ({
id,
name: user.name,
score: user.score,
})),
});
}
});
};
// Set up cleanup interval
if (typeof setInterval !== 'undefined') {
// Run cleanup every minute
setInterval(cleanInactiveUsers, 60 * 1000);
// Increment heartbeat counters every 20 seconds
setInterval(() => {
Object.values(users).forEach(user => {
if (user.active) {
user.heartbeats += 1;
}
});
}, 20 * 1000);
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { action, userId, data } = body;
switch (action) {
case "initialize": {
// If user ID and name are provided from session storage, use them
const newUserId = userId || `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const userName = body.userName || generateRandomName();
// Add or update user in the store, but not active yet
users[newUserId] = {
name: userName,
score: users[newUserId]?.score || 0, // Keep existing score if available
active: false,
lastActivityTimestamp: Date.now(),
heartbeats: 0
};
return NextResponse.json({
userId: newUserId,
userName,
users: Object.entries(users)
.filter(([, user]) => user.active)
.map(([id, user]) => ({ id, name: user.name, score: user.score })),
});
}
case "joinMultiplayer": {
if (!userId || !users[userId]) {
return NextResponse.json({ success: false, error: "User not found" }, { status: 400 });
}
// Mark user as active and update activity timestamp
users[userId].active = true;
users[userId].lastActivityTimestamp = Date.now();
users[userId].heartbeats = 0;
// Store channel ID if provided
if (data?.channelId) {
users[userId].channelId = data.channelId;
}
// Get list of active users for the leaderboard
const activeUsers = Object.entries(users)
.filter(([, user]) => user.active)
.map(([id, user]) => ({
id,
name: user.name,
score: user.score,
}));
// Broadcast to everyone that a new user has joined
await pusher.trigger("presence-game", "userJoined", {
userId,
userName: users[userId].name,
users: activeUsers,
});
return NextResponse.json({
success: true,
users: activeUsers,
});
}
case "skillPopped": {
if (!userId || !users[userId]) {
return NextResponse.json({ success: false, error: "User not found" }, { status: 400 });
}
if (!users[userId].active) {
return NextResponse.json({ success: false, error: "User not active" }, { status: 400 });
}
// Update activity timestamp on skill pop
users[userId].lastActivityTimestamp = Date.now();
users[userId].heartbeats = 0;
const { skillId, skillName, x, y, points } = data;
// Update user's score
users[userId].score += points;
// Broadcast the popped skill to all clients
await pusher.trigger("presence-game", "skillPoppedByOther", {
userId,
userName: users[userId].name,
skillId,
skillName,
x,
y,
points,
timestamp: Date.now(),
});
// Broadcast updated leaderboard
await pusher.trigger("presence-game", "leaderboardUpdate", {
users: Object.entries(users)
.filter(([, user]) => user.active)
.map(([id, user]) => ({
id,
name: user.name,
score: user.score,
})),
});
return NextResponse.json({ success: true });
}
// Other actions...
default:
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}
} catch (error) {
console.error("Error in Pusher API route:", error);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
We also need to create an authentication route for Pusher's presence channels. Create a file at app/api/pusher/auth/route.ts
:
import { NextResponse } from "next/server";
import Pusher from "pusher";
// Initialize Pusher
const pusher = new Pusher({
appId: process.env.PUSHER_APP_ID!,
key: process.env.PUSHER_KEY!,
secret: process.env.PUSHER_SECRET!,
cluster: process.env.PUSHER_CLUSTER!,
useTLS: true,
});
export async function POST(request: Request) {
try {
// Parse the request to get socket_id and channel_name
const contentType = request.headers.get('content-type');
let socket_id, channel_name, userId, userName;
// Handle different content types
if (contentType && contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
socket_id = formData.get('socket_id')?.toString();
channel_name = formData.get('channel_name')?.toString();
// Get user data from query params
const url = new URL(request.url);
userId = url.searchParams.get('userId');
userName = url.searchParams.get('userName');
} else {
try {
// Try to parse as JSON
const data = await request.json();
socket_id = data.socket_id;
channel_name = data.channel_name;
userId = data.userId;
userName = data.userName;
} catch (e) {
// Fallback to form data
const formData = await request.formData();
socket_id = formData.get('socket_id')?.toString();
channel_name = formData.get('channel_name')?.toString();
const url = new URL(request.url);
userId = url.searchParams.get('userId');
userName = url.searchParams.get('userName');
}
}
// Validate the request
if (!socket_id || !channel_name) {
return NextResponse.json({ error: "Missing socket_id or channel_name" }, { status: 400 });
}
// For presence channels, we need to provide user data
if (channel_name.startsWith("presence-")) {
// Create user data for presence channel
const presenceData = {
user_id: userId || 'anonymous',
user_info: {
name: userName || 'Anonymous User'
}
};
// Generate auth signature with user data
const auth = pusher.authorizeChannel(socket_id, channel_name, presenceData);
return NextResponse.json(auth);
} else {
// For regular private channels
const auth = pusher.authorizeChannel(socket_id, channel_name);
return NextResponse.json(auth);
}
} catch (error) {
console.error("Error in Pusher auth endpoint:", error);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
2. Client-Side Pusher Provider
Next, we'll create a React Context provider to manage our Pusher connection on the client side. This will abstract away the Pusher implementation details and provide a clean API for our components:
// components/PusherProvider.tsx
"use client";
import React, { createContext, useContext, useEffect, useState, useRef } from 'react';
import Pusher, { Channel } from 'pusher-js';
// Define the context shape
interface PusherContextType {
pusher: Pusher | null;
channel: Channel | null;
userId: string | null;
userName: string | null;
connected: boolean;
multiplayerActive: boolean;
users: Array<{ id: string; name: string; score: number }>;
poppedByOthers: Array<{
userId: string;
userName: string;
skillId: number;
skillName: string;
x: number;
y: number;
points: number;
timestamp: number;
}>;
emitSkillPopped: (data: {
skillId: number;
skillName: string;
x: number;
y: number;
points: number;
}) => void;
joinMultiplayer: () => Promise<boolean>;
leaveMultiplayer: () => void;
connectionError: string | null;
}
// Create context with default values
const PusherContext = createContext<PusherContextType>({
pusher: null,
channel: null,
userId: null,
userName: null,
connected: false,
multiplayerActive: false,
users: [],
poppedByOthers: [],
emitSkillPopped: () => {},
joinMultiplayer: async () => false,
leaveMultiplayer: () => {},
connectionError: null
});
// Custom hook to access the pusher context
export const usePusher = () => useContext(PusherContext);
interface PusherProviderProps {
children: React.ReactNode;
}
export const PusherProvider: React.FC<PusherProviderProps> = ({ children }) => {
const [pusher, setPusher] = useState<Pusher | null>(null);
const [channel, setChannel] = useState<Channel | null>(null);
const [connected, setConnected] = useState(false);
const [multiplayerActive, setMultiplayerActive] = useState(false);
const [userId, setUserId] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const [users, setUsers] = useState<Array<{ id: string; name: string; score: number }>>([]);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [poppedByOthers, setPoppedByOthers] = useState<Array<{
userId: string;
userName: string;
skillId: number;
skillName: string;
x: number;
y: number;
points: number;
timestamp: number;
}>>([]);
// Track connection attempts
const connectionAttemptsRef = useRef(0);
const maxConnectionAttempts = 3;
// Initialize user and get initial data
const initializeUser = async () => {
try {
// Check session storage for existing userId and userName
let existingUserId = null;
let existingUserName = null;
if (typeof window !== 'undefined') {
existingUserId = sessionStorage.getItem('pusherUserId');
existingUserName = sessionStorage.getItem('pusherUserName');
}
// Make API request, passing existing IDs if available
const response = await fetch('/api/pusher', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'initialize',
userId: existingUserId,
userName: existingUserName
}),
});
if (!response.ok) {
throw new Error('Failed to initialize user');
}
const data = await response.json();
setUserId(data.userId);
setUserName(data.userName);
setUsers(data.users);
// Store user information in session storage
if (typeof window !== 'undefined') {
sessionStorage.setItem('pusherUserId', data.userId);
sessionStorage.setItem('pusherUserName', data.userName);
// Make username available globally
(window as Window & { pusherUserName?: string }).pusherUserName = data.userName;
}
return data;
} catch (error) {
console.error('Error initializing user:', error);
setConnectionError('Failed to initialize user. Try refreshing the page.');
return null;
}
};
// Initialize Pusher connection
useEffect(() => {
if (typeof window === 'undefined') return;
// Initialize user first to get userId and userName
initializeUser().then((userData) => {
if (!userData) return;
// Setup Pusher with the received user data
try {
// Enable Pusher logging for debugging
Pusher.logToConsole = true;
const pusherInstance = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY || '', {
cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER || 'us2',
forceTLS: true,
authEndpoint: `/api/pusher/auth?userId={encodeURIComponent(userData.userId)}&userName={encodeURIComponent(userData.userName)}`,
});
// Track connection state
pusherInstance.connection.bind('connected', () => {
console.log('Pusher connected');
setConnected(true);
setConnectionError(null);
connectionAttemptsRef.current = 0;
// Make connection status available globally
if (typeof window !== 'undefined') {
(window as Window & {
pusherConnected?: boolean;
pusherMultiplayerActive?: boolean;
}).pusherConnected = true;
}
});
pusherInstance.connection.bind('error', (err: Error) => {
console.error('Pusher connection error:', err);
connectionAttemptsRef.current += 1;
if (connectionAttemptsRef.current >= maxConnectionAttempts) {
setConnectionError('Connection failed after multiple attempts. Try refreshing the page.');
pusherInstance.disconnect();
}
});
pusherInstance.connection.bind('disconnected', () => {
console.log('Pusher disconnected');
setConnected(false);
setMultiplayerActive(false);
// Update global connection status
if (typeof window !== 'undefined') {
(window as Window & {
pusherConnected?: boolean;
pusherMultiplayerActive?: boolean;
}).pusherConnected = false;
(window as Window & { pusherMultiplayerActive?: boolean }).pusherMultiplayerActive = false;
}
});
setPusher(pusherInstance);
} catch (error) {
console.error('Error creating Pusher connection:', error);
setConnectionError('Failed to connect. Try refreshing the page.');
}
});
// Cleanup Pusher connection
return () => {
if (pusher) {
pusher.disconnect();
}
};
}, []);
// Clean up old popped skills
useEffect(() => {
if (poppedByOthers.length === 0) return;
const interval = setInterval(() => {
const now = Date.now();
setPoppedByOthers((prev) =>
prev.filter((skill) => now - skill.timestamp < 5000)
);
}, 1000);
return () => clearInterval(interval);
}, [poppedByOthers]);
// Send heartbeats to keep connection alive
useEffect(() => {
// Only send heartbeats when connected and in multiplayer mode
if (!connected || !multiplayerActive || !userId) return;
// Send a heartbeat every 15 seconds
const heartbeatInterval = setInterval(() => {
// Send heartbeat to server
fetch('/api/pusher', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'heartbeat',
userId,
}),
}).catch(error => {
console.error('Error sending heartbeat:', error);
});
}, 15000);
return () => clearInterval(heartbeatInterval);
}, [connected, multiplayerActive, userId]);
// Function to join multiplayer
const joinMultiplayer = async (): Promise<boolean> => {
if (!pusher || !connected || !userId) {
return false;
}
try {
// Subscribe to the presence channel
const gameChannel = pusher.subscribe('presence-game');
// Set up event listeners
gameChannel.bind('userJoined', (data: { userId: string; userName: string; users: Array<{ id: string; name: string; score: number }> }) => {
console.log('User joined:', data);
setUsers(data.users);
});
gameChannel.bind('userLeft', (data: { userId: string; users: Array<{ id: string; name: string; score: number }> }) => {
console.log('User left:', data);
setUsers(data.users);
});
gameChannel.bind('leaderboardUpdate', (data: { users: Array<{ id: string; name: string; score: number }> }) => {
setUsers(data.users);
});
gameChannel.bind('skillPoppedByOther', (data: {
userId: string;
userName: string;
skillId: number;
skillName: string;
x: number;
y: number;
points: number;
timestamp: number;
}) => {
console.log('Skill popped by other user:', data);
// Filter out events from ourselves by comparing userId
if (data.userId === userId) {
console.log('Ignoring own skill pop event');
return;
}
// Add the popped skill to the list
setPoppedByOthers((prev) => {
// Only keep the last 10 popped skills to avoid memory issues
const limitedList = [...prev, data];
if (limitedList.length > 10) {
return limitedList.slice(limitedList.length - 10);
}
return limitedList;
});
});
// Wait for the channel to be subscribed successfully
await new Promise<void>((resolve) => {
gameChannel.bind('pusher:subscription_succeeded', () => {
setChannel(gameChannel);
resolve();
});
});
// Tell the server that we've joined multiplayer
const response = await fetch('/api/pusher', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'joinMultiplayer',
userId,
data: {
channelId: 'presence-game',
},
}),
});
if (!response.ok) {
throw new Error('Failed to join multiplayer');
}
const data = await response.json();
if (data.success) {
setMultiplayerActive(true);
setUsers(data.users);
// Make multiplayer status available globally
if (typeof window !== 'undefined') {
(window as Window & { pusherMultiplayerActive?: boolean }).pusherMultiplayerActive = true;
}
return true;
}
return false;
} catch (error) {
console.error('Error joining multiplayer:', error);
return false;
}
};
// Function to leave multiplayer
const leaveMultiplayer = () => {
if (!pusher || !connected || !multiplayerActive || !userId) {
return;
}
try {
// Tell the server we're leaving multiplayer
fetch('/api/pusher', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'leaveMultiplayer',
userId,
}),
}).catch(error => {
console.error('Error leaving multiplayer:', error);
});
// Unsubscribe from the game channel
if (channel) {
pusher.unsubscribe('presence-game');
setChannel(null);
}
setMultiplayerActive(false);
// Update global status
if (typeof window !== 'undefined') {
(window as Window & { pusherMultiplayerActive?: boolean }).pusherMultiplayerActive = false;
}
} catch (error) {
console.error('Error leaving multiplayer:', error);
}
};
// Function to emit when a skill is popped
const emitSkillPopped = (data: {
skillId: number;
skillName: string;
x: number;
y: number;
points: number;
}) => {
if (!connected || !multiplayerActive || !userId) {
return;
}
// Send skill popped event to the server
fetch('/api/pusher', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'skillPopped',
userId,
data,
}),
}).catch(error => {
console.error('Error sending skill popped event:', error);
});
};
return (
<PusherContext.Provider
value={{
pusher,
channel,
userId,
userName,
connected,
multiplayerActive,
users,
poppedByOthers,
emitSkillPopped,
joinMultiplayer,
leaveMultiplayer,
connectionError
}}
>
{children}
</PusherContext.Provider>
);
};
export default PusherProvider;
This provider manages the full lifecycle of our Pusher connection, including user initialization, joining and leaving multiplayer sessions, handling heartbeats to keep connections alive, and processing real-time events. It provides a clean, consistent API that our game components can use without needing to know the details of how Pusher works.
3. Enhancing the FloatingSkills Component
Now let's update our FloatingSkills component to use the socket context and display multiplayer interactions. We'll need to:
- Update the Skill interface to track who popped each skill
- Process skills popped by other users in real-time
- Display visual feedback when skills are popped by others
- Create an improved leaderboard that shows all connected users
First, let's update the Skill interface to include information about who popped each skill:
interface Skill {
id: number;
name: string;
x: number;
y: number;
vx: number;
vy: number;
radius: number;
popped: boolean;
poppedByUserId?: string;
poppedByUserName?: string;
}
Next, let's add a mechanism to process skills popped by other users:
// Track already processed skill pops to avoid duplicates
const processedPopsRef = useRef<Set<string>>(new Set());
// Process skills popped by other users
useEffect(() => {
// Skip if no popped skills or no skills loaded yet
if (!poppedByOthers || poppedByOthers.length === 0 || skills.length === 0) {
return;
}
// Create a separate processing function to avoid race conditions
const processSkillPops = () => {
let updatedSkills = [...skills];
let processedAny = false;
for (const poppedSkill of poppedByOthers) {
// Create a unique ID for this popped skill to avoid processing duplicates
const popId = `${poppedSkill.userId}-${poppedSkill.skillName}-${poppedSkill.timestamp}`;
// Skip if already processed
if (processedPopsRef.current.has(popId)) {
continue;
}
// Find an appropriate skill to mark as popped
// First try to find by position and name
let closestSkill = updatedSkills.find(skill => {
if (skill.popped) return false;
const distance = Math.sqrt(
Math.pow(skill.x - poppedSkill.x, 2) +
Math.pow(skill.y - poppedSkill.y, 2)
);
return distance < 150 && skill.name === poppedSkill.skillName;
});
// Fallback strategies if exact match not found
if (!closestSkill) {
closestSkill = updatedSkills.find(skill =>
!skill.popped && skill.name === poppedSkill.skillName
);
}
if (!closestSkill) {
closestSkill = updatedSkills.find(skill => !skill.popped);
}
if (closestSkill) {
updatedSkills = updatedSkills.map(skill =>
skill.id === closestSkill.id
? {
...skill,
popped: true,
poppedByUserId: poppedSkill.userId,
poppedByUserName: poppedSkill.userName,
// Use deterministic animation based on skill ID
vx: (Math.sin(skill.id * 1.1) * 10000 % 1 * 2 - 1) * EXPLOSION_FORCE,
vy: -Math.abs(Math.sin(skill.id * 2.2) * 10000 % 1 * EXPLOSION_FORCE),
}
: skill
);
// Mark as processed
processedPopsRef.current.add(popId);
processedAny = true;
}
}
// Only update state if we actually processed any pops
if (processedAny) {
setSkills(updatedSkills);
}
};
processSkillPops();
}, [poppedByOthers, skills, EXPLOSION_FORCE]);
When the user pops a skill, we need to emit that event to the server:
// In the handlePop function:
// ...existing code to update score and animations...
// Emit skill popped event to other users via socket
emitSkillPopped({
skillId: id,
skillName: targetSkill.name,
x: targetSkill.x,
y: targetSkill.y,
points: pointsAwarded
});
Finally, we need to update the visual styling to show different colors for skills popped by the current user versus other users:
// In the render function, update the style for each skill bubble:
style={{
color: skill.popped
? skill.poppedByUserId ? "#ef4444" : "#059669" // Red for others, green for you
: isLightMode ? "#0c4a6e" : "var(--n8rs-primary)",
// ... other styles ...
borderColor: skill.popped
? skill.poppedByUserId ? "rgba(239,68,68,0.8)" : "rgba(34,197,94,0.8)"
: isLightMode ? "rgba(8,145,178,0.8)" : "rgba(103,232,249,0.4)",
boxShadow: skill.popped
? skill.poppedByUserId ? "0 4px 6px rgba(239,68,68,0.4)" : "0 4px 6px rgba(34,197,94,0.4)"
: isLightMode ? "0 4px 8px rgba(8,145,178,0.4)" : "0 4px 6px rgba(103,232,249,0.3)",
}}
We should also create an improved leaderboard modal that shows all connected users and their scores:
{/* Combined Players & Leaderboard Modal */}
<AnimatePresence>
{showLeaderboard && (
<motion.div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowLeaderboard(false)}
>
<motion.div
className="rounded-lg p-4 w-96 max-w-[90%] relative z-10"
style={{
backgroundColor: isLightMode
? "rgba(255,255,255,0.9)"
: "var(--n8rs-bg-offset)",
color: "var(--n8rs-text)",
boxShadow: "0 10px 25px rgba(0,0,0,0.2)",
border: isLightMode
? "1px solid rgba(8,145,178,0.3)"
: "1px solid var(--n8rs-border)"
}}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => setShowLeaderboard(false)}
className="absolute top-2 right-2 text-xl hover:opacity-80"
style={{ color: "var(--n8rs-text-offset)" }}
>
×
</button>
<div className="flex items-center justify-center gap-2 mb-3">
<Trophy size={28} style={{ color: isLightMode ? "#eab308" : "#facc15" }} />
<h2 className="text-xl font-bold">
Players ({users.length})
</h2>
</div>
{/* Leaderboard Section */}
<div className="mb-3">
<div className="flex items-center gap-2 px-3 py-2 mb-2 border-b"
style={{ borderColor: "var(--n8rs-border)" }}>
<span className="font-medium">Leaderboard</span>
</div>
<div className="overflow-hidden rounded-md" style={{
border: isLightMode ? "1px solid rgba(0,0,0,0.1)" : "1px solid rgba(255,255,255,0.1)"
}}>
<table className="w-full text-left text-sm">
<thead>
<tr style={{
backgroundColor: isLightMode ? "rgba(0,0,0,0.05)" : "rgba(255,255,255,0.05)",
}}>
<th className="py-2 px-3 font-medium">Player</th>
<th className="py-2 px-3 text-right font-medium">Score</th>
</tr>
</thead>
<tbody>
{/* Current user highlighted */}
<tr className="font-medium" style={{
backgroundColor: isLightMode
? "rgba(8,145,178,0.1)"
: "rgba(103,232,249,0.1)",
}}>
<td className="py-2 px-3">
{userName ? userName : 'You'} <span style={{ color: "var(--n8rs-primary)" }}>(you)</span>
</td>
<td className="py-2 px-3 text-right" style={{
color: isLightMode ? "#0891b2" : "var(--n8rs-primary)",
}}>{score}</td>
</tr>
{/* Other online users, sorted by score */}
{users
.filter(user => !userName || user.name !== userName) // Filter out current user
.sort((a, b) => b.score - a.score) // Sort by score
.slice(0, 6) // Show top 6
.map((user, idx) => (
<tr key={user.id} style={{
backgroundColor: idx % 2 === 0
? (isLightMode ? "rgba(0,0,0,0.02)" : "rgba(255,255,255,0.02)")
: "transparent"
}}>
<td className="py-1.5 px-3">{user.name}</td>
<td className="py-1.5 px-3 text-right">{user.score}</td>
</tr>
))
}
{users.length <= 1 && (
<tr>
<td colSpan={2} className="py-2 px-3 text-sm text-center"
style={{ color: "var(--n8rs-text-offset)" }}>
<p>Waiting for other players to join...</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Online Players Section */}
<div>
<div className="flex items-center gap-2 px-3 py-2 mb-2 border-b"
style={{ borderColor: "var(--n8rs-border)" }}>
<Users size={16} style={{ color: isLightMode ? "#0891b2" : "var(--n8rs-primary)" }} />
<span className="font-medium">All Players</span>
</div>
<div className="max-h-40 overflow-y-auto rounded-md" style={{
backgroundColor: isLightMode ? "rgba(0,0,0,0.03)" : "rgba(255,255,255,0.03)",
border: isLightMode ? "1px solid rgba(0,0,0,0.1)" : "1px solid rgba(255,255,255,0.1)"
}}>
<div className="flex flex-wrap gap-2 p-2">
{/* Current user */}
<div className="px-2.5 py-1 rounded-full font-medium text-sm"
style={{
backgroundColor: isLightMode
? "rgba(8,145,178,0.15)"
: "rgba(103,232,249,0.15)",
color: isLightMode ? "#0891b2" : "var(--n8rs-primary)"
}}>
{userName ? userName : 'You'} <span className="opacity-75">(you)</span>
</div>
{/* Other users */}
{users
.filter(user => !userName || user.name !== userName)
.map(user => (
<div key={user.id} className="px-2.5 py-1 rounded-full text-sm"
style={{
backgroundColor: isLightMode
? "rgba(0,0,0,0.06)"
: "rgba(255,255,255,0.06)",
}}>
{user.name}
</div>
))
}
{users.length <= 1 && (
<div className="w-full text-center py-2 text-sm"
style={{ color: "var(--n8rs-text-offset)" }}>
<p>You're the only player right now.</p>
<p>Invite others to join the fun!</p>
</div>
)}
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
4. Creating a Consistent Multiplayer Experience
One of the challenges with multiplayer games is ensuring a consistent experience across all clients. To achieve this, we need to make sure that all players see the same skills in the same positions. Let's update our skill initialization code to use a seeded random number generator:
// Pseudo-random number generator with seed for consistency across clients
const seed = 12345; // Using fixed seed so all clients show the same skills
let randomState = seed;
const seededRandom = () => {
randomState = (randomState * 9301 + 49297) % 233280;
return randomState / 233280;
};
// Then use seededRandom() instead of Math.random() when creating skills
// ...
// Create skills with consistent layout
const initialSkills: Skill[] = [];
let idCounter = 0;
// Calculate grid dimensions for layout
const gridCols = Math.ceil(Math.sqrt(effectiveMax));
// Create skills for each type
Object.entries(skillCounts).forEach(([name, count]) => {
for (let i = 0; i < count; i++) {
const position = idCounter;
const row = Math.floor(position / gridCols);
const col = position % gridCols;
// Deterministic base position in a grid
const baseX = ((col + 0.5) / gridCols) * containerWidth;
const baseY = ((row + 0.5) / gridCols) * containerHeight;
// Add some randomness, but seed-based so it's the same for all clients
const randX = (seededRandom() * 2 - 1) * (containerWidth / gridCols) * 0.4;
const randY = (seededRandom() * 2 - 1) * (containerHeight / gridCols) * 0.4;
initialSkills.push({
id: idCounter++,
name,
x: baseX + randX,
y: baseY + randY,
vx: (seededRandom() * 2 - 1) * 0.5,
vy: (seededRandom() * 2 - 1) * 0.5,
radius: 50,
popped: false,
});
}
});
We also need to make our animations deterministic, so skills pop in the same way for all players:
// Deterministic animation based on skill ID
const skillId = id;
const deterministicRandom = (seed: number) => {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
};
setSkills((prevSkills) =>
prevSkills.map((skill) =>
skill.id === id
? {
...skill,
popped: true,
// Use deterministic explosion forces based on skill ID
vx: (deterministicRandom(skillId * 1.1) * 2 - 1) * EXPLOSION_FORCE,
vy: -Math.abs(deterministicRandom(skillId * 2.2) * EXPLOSION_FORCE),
}
: skill
)
);
Debugging and Testing Multiplayer
Debugging real-time multiplayer functionality presents unique challenges. Here are some strategies that helped during development:
1. Connection Status Indicators
Add visual indicators to show connection status in the UI. This was implemented in our game through a small status text at the bottom of the screen:
<div className="absolute bottom-0 left-0 right-0 p-4 text-center text-gray-400 text-sm">
Powered by Pusher • <span id="pusher-status">Connecting...</span>
</div>
<script dangerouslySetInnerHTML={{ __html: `
window.addEventListener('load', () => {
const statusEl = document.getElementById('pusher-status');
// Update status every second
setInterval(() => {
if (window.pusherConnected) {
statusEl.textContent = 'Connected as ' + (window.pusherUserName || 'Unknown');
statusEl.style.color = '#10b981';
} else {
statusEl.textContent = 'Disconnected - Refresh page';
statusEl.style.color = '#ef4444';
}
}, 1000);
});
` }} />
2. Broadcast Handling
One common issue was handling broadcasts properly. With Pusher, you need to understand the different broadcast types:
channel.trigger()
to send events on the client sidepusher.trigger()
to broadcast from the server to all clientspresence channels
for automatically tracking user presence/status
3. Tracking Processed Events
To avoid duplicate processing of events (which can happen during reconnections or with slow network conditions), we implemented a tracking system using a Set:
// Create a unique ID for each processed event
const popId = `${poppedSkill.userId}-${poppedSkill.skillName}-${poppedSkill.timestamp}`;
// Check if we've already processed this event
if (processedPopsRef.current.has(popId)) {
continue;
}
// Process the event...
// Mark as processed
processedPopsRef.current.add(popId);
4. Memory Management
To prevent memory leaks, we implemented automatic cleanup of old events and limited the size of our event queues:
// Keep only the last 10 popped skills to avoid memory issues
setPoppedByOthers((prev) => {
const limitedList = [...prev, poppedData];
if (limitedList.length > 10) {
return limitedList.slice(limitedList.length - 10);
}
return limitedList;
});
// Clean up old popped skills
useEffect(() => {
if (poppedByOthers.length === 0) return;
const interval = setInterval(() => {
const now = Date.now();
setPoppedByOthers((prev) =>
prev.filter((skill) => now - skill.timestamp < 5000)
);
}, 1000);
return () => clearInterval(interval);
}, [poppedByOthers]);
5. Testing with Multiple Browsers
For effective testing, use:
- Multiple browser windows (Chrome, Firefox, Safari)
- Normal and incognito mode windows together
- Mobile and desktop browsers simultaneously
- Network throttling in DevTools to test slow connections
Common Challenges and Choosing Real-time Technologies
Building this multiplayer functionality presented several interesting challenges. Let's explore both the technical challenges we overcame and the tradeoffs between different real-time communication approaches.
1. Inconsistent Bubble Positions
Challenge: Each client was generating random skill positions, making it impossible to coordinate which skill was popped.
Solution: We implemented a seeded pseudorandom number generator to ensure all clients generate the same sequence of "random" values, resulting in consistent positioning.
2. User Connection Management
Challenge: Detecting disconnected users reliably and cleaning up inactive sessions.
Solution: We implemented a multi-faceted approach using heartbeats, activity timestamps, and presence channel functionality. This triple redundancy ensures we never have "ghost users" in the system.
3. State Management
Challenge: Tracking and updating user scores and managing the list of connected users.
Solution: We centralized state on the server and broadcast updates to all clients, ensuring consistency. We also handled edge cases like disconnection and reconnection gracefully.
4. UI Responsiveness
Challenge: Processing network events without blocking the UI or causing jank in animations.
Solution: We implemented a carefully crafted state update strategy using React's functional state updates to avoid race conditions, and we batched state updates to minimize rerenders.
5. Choosing the Right Technology: Pusher vs Socket.io
One of the most significant architectural decisions was choosing between Pusher and Socket.io. Let's compare these technologies and their tradeoffs:
Pusher Channels
Pros:
- Hosted solution with no infrastructure to manage
- Automatic scaling without worrying about WebSocket servers
- Built-in presence channels for user tracking
- Easier deployment with serverless frameworks like Next.js
- Reliable fallbacks to HTTP streaming when WebSockets are blocked
Cons:
- Cost increases with connection volume and message count
- Less flexible for custom protocol development
- Higher latency due to external service communication
- Dependency on a third-party service
Socket.io
Pros:
- Complete control over infrastructure and scaling
- No external service costs (just server hosting)
- Lower latency with direct client-server connection
- More flexible for custom real-time protocols
- Room-based messaging out of the box
Cons:
- Requires maintaining WebSocket server infrastructure
- Needs dedicated hosting (not suitable for serverless without adaptations)
- Manual implementation of presence functionality
- Scaling across multiple server instances requires additional setup
Our Choice
For our skills game, we chose Pusher Channels because:
- It integrates naturally with Next.js and serverless architecture
- The presence channels feature provided built-in user tracking
- Quick setup without infrastructure management
- For a personal project, the free tier is sufficient for our expected traffic
However, for projects with very high volume or specific custom needs, Socket.io might be more appropriate. The beauty of our architecture is that we could switch between these technologies by updating our provider implementation while keeping the same interface for the game components.
Redesigning the Player Interface
A key improvement in this version is the enhanced player interface. In Part 1, we had a very basic "Leaderboard" button that displayed only your score. For the multiplayer version, we redesigned this into a comprehensive Players panel with two key sections:
1. Competitive Leaderboard
The top section features a proper leaderboard that shows all players sorted by score. Your score is highlighted at the top, followed by other players in descending order. This creates a competitive element that encourages engagement.
2. Connected Players View
The bottom section displays all connected players using a tag-based interface. This gives players a sense of presence and community, showing them they're not alone in the game space.
The redesigned interface is not just more functional, but also more engaging and social, turning what was a solo experience into a multiplayer game with clear competitive elements.
Future Enhancements
While our multiplayer implementation is fully functional, there are several exciting enhancements we could add in future iterations:
1. Persistent Leaderboard
Store high scores in a database (like Firebase, MongoDB, or DynamoDB) to create an all-time leaderboard that persists across sessions.
2. User Profiles and Authentication
Allow users to create persistent profiles with custom usernames and avatars, tracking their stats across multiple sessions.
3. Power-ups and Special Abilities
Introduce special power-ups that appear randomly, giving players abilities like multi-pop (pop several skills at once), freeze (temporarily prevent others from popping), or point multipliers.
4. Time-limited Competitions
Create timed "seasons" or competitions with special rewards for top scorers during a specific timeframe.
5. Game Modes
Add different game modes like "Timed Challenge" (pop as many skills as possible in 60 seconds) or "Last Bubble Standing" (don't pop any skills and try to be the last player with unpopped skills).
Conclusion
Through this two-part series, we've transformed a simple bubble-popping game into an engaging multiplayer experience. By leveraging Pusher Channels and React, we've created a real-time interactive application that brings players together in a shared virtual space.
The key takeaways from Part 2 include:
- Using Pusher Channels with Next.js Route Handlers for real-time communication
- Designing a robust client-side provider to handle WebSocket connections
- Managing connection states, heartbeats, and activity tracking
- Creating consistent experiences with seeded randomness
- Building intuitive interfaces for multiplayer interactions
- Understanding the tradeoffs between different real-time technologies
This implementation demonstrates how modern web technologies can create engaging multiplayer experiences without the complexity of traditional game server architecture. The abstractions we've built could be applied to many other types of collaborative applications beyond games - from chat systems to collaborative editing tools.
While we chose Pusher for this implementation, our architecture is flexible enough that you could swap it for another real-time solution like Socket.io, Firebase, or even a custom WebSocket server. The key is maintaining a clean separation between your communication layer and your application logic.
I encourage you to experiment with this code, extend it with your own features, and apply these techniques to your own projects. Real-time multiplayer functionality can transform even the simplest web applications into engaging, social experiences.
Further Reading
Additional resources to deepen your understanding:
Key Resources
Official documentation for the real-time channels service that enables scalable WebSocket communication.
Learn how to create API endpoints in Next.js to support your WebSocket server.
Animation library that powers our skill bubbles and visual effects.