Skills Popping Game Part 2: Adding Real-time Multiplayer with Pusher Channels

Skills Popping Game Part 2: Adding Real-time Multiplayer with Pusher Channels

Pusher
Multiplayer
WebSockets
Next.js
Real-time
Game Development
2025-03-17

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)" }} > &times; </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 side
  • pusher.trigger() to broadcast from the server to all clients
  • presence 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

Pusher Documentation

Official documentation for the real-time channels service that enables scalable WebSocket communication.

Next.js API Routes

Learn how to create API endpoints in Next.js to support your WebSocket server.

Framer Motion

Animation library that powers our skill bubbles and visual effects.

Academic References