Skip to content
Go back

Building a Real-Time Multiplayer Card Game (and Fixing the Weird Bugs)

Updated: (Originally published 10 Nov, 2025 )

So back during Covid - everyone got into online gaming. My friends and I played using Pretend You’re Xyzzy. It’s a very simplistic interface - and I wanted something a little more polished, and (at the time) a little more stable. So - because nothing is original anymore, I sought to see what’s been done! I found this configuration by Yusuf Ameri! The pieces were all there to modernize and tweak issues.

Phucking Cards is a free, no-login-required party game for 3-20 players. I originally picked it up a couple years back, but did a lot of recent work cleaning up bugs and improving mobile UX - which taught me some things about real-time multiplayer development worth documenting (playing with Sockets was my main goal with this project - what a whirlwind that was).

The Stack

Backend:

Frontend:

Infrastructure:

How It Works

Real-Time Architecture

The game state lives in memory on the server. Each game has a Game class instance that manages:

When anything changes, Socket.IO broadcasts updates to all players in that game room:

socket.on("playCard", async (partyCode, cardID) => {
    // Validate and process card submission
    game.playCard(partyCode, cardID, sessionID);

    // Broadcast to all players in this game
    io.to(partyCode).emit("newGameState");
});

Sessions persist in Redis, so players can refresh without losing their spot.

The Question/Answer Flow

  1. One player is randomly selected as judge
  2. Judge sees a question card (e.g., “What helps Obama unwind? _”)
  3. Other players submit answer cards from their hand
  4. When all answers are in (or timer expires), judge picks a winner
  5. Winner gets a point, judge role rotates, new round starts

Simple game loop. Lots of edge cases.

The Bugs That Taught Me Things

Bug 1: Timeout State Never Propagated

Problem: Round timer would expire, but clients never saw the state transition to “judge selection.” Backend logs showed correct state changes, but Socket.IO events weren’t reaching clients.

Root cause:

The game creates a roundFinishedNotifier callback on initialization to emit Socket.IO events when the round ends. But getLobbyState() gets called every time a client connects, and each call created a new callback… which overwrote nothing because the game instance already had its original callback from construction.

// This runs once when game is created
new Game(partyCode, roundLength, (success, message) => {
    io.to(partyCode).emit("newGameState");
});

// This runs on EVERY client connection, but does nothing
// because the game already has its callback set
getLobbyState(partyCode, sessionID, (success, message) => {
    io.to(partyCode).emit("newGameState"); // Never registered!
});

Only the first connected client’s callback worked. Everyone else got silence.

Fix:

Added a setRoundFinishedNotifier() method to dynamically update the callback:

// In Game class
setRoundFinishedNotifier(cb: CallbackType): void {
  this.roundFinishedNotifier = cb;
}

// In getLobbyState
existingGame.setRoundFinishedNotifier(cb);

Now every connection refreshes the callback, ensuring the latest Socket.IO context is always used.

Lesson learned: When dealing with callbacks in long-lived objects, explicit update methods beat “set once at construction.”

Bug 2: Mobile Users Couldn’t Select Cards

Problem: On mobile, users could swipe through their cards (carousel worked fine) but tapping to select did nothing. Desktop drag-and-drop worked. Mobile tap? Dead.

Root cause:

React DnD Touch Backend has a delayTouchStart option. It was set to 200ms:

export const touchBackendOptions = {
    delayTouchStart: 200, // 200ms delay before drag starts
    touchSlop: 5, // 5px movement threshold
};

The delay exists to distinguish between taps and drags. But 200ms is long enough to break tap recognition entirely. Taps were getting swallowed.

Fix:

Removed the delay, increased the movement threshold:

export const touchBackendOptions = {
    delayTouchStart: 0, // No delay - immediate tap recognition
    touchSlop: 10, // Larger threshold still allows drag detection
};

Now taps register instantly. Drags still work if you move >10px. Much better UX.

Lesson learned: Default config values aren’t always right. Mobile gesture recognition requires tuning.

Bug 3: Mobile Players Got Wrong Instructions

After fixing tap recognition, mobile users still didn’t know they could tap. Instructions said “Choose one card” (desktop language) instead of “Tap a card” (mobile language).

Fix:

Added device detection and conditional messaging:

if (isTouchDevice) {
    directions = numCards > 1 ? `Tap ${numCards} Cards` : "Tap a Card";
} else {
    directions = numCards > 1 ? `Pick ${numCards} Cards` : "Choose one Card";
}

Small change, big clarity improvement.

Lesson learned: Instructions matter. Device-appropriate language improves discoverability.

The Features Worth Mentioning

Round Timers with Partial Submissions

When the timer expires, the judge can still pick from whatever cards were submitted. No penalty for slow players — game just moves forward.

Implementation required careful state management:

if (round.otherPlayerCards && round.otherPlayerCards.length > 0) {
    // At least one card submitted - proceed to judge selection
    round.roundState = JUDGE_SELECTING;
    this.roundFinishedNotifier(true, "Judge-selection time!");
} else {
    // Nobody submitted - skip round entirely
    this.endRound(true, "Skipping judge!");
}

This prevents games from stalling when one player goes AFK.

User-Submitted Cards

Players can submit their own cards. They go into a moderation queue. Moderators (authenticated via admin key, currently) can approve/reject with reasons.

Cards are stored in Redis with vote tracking:

// Store card
await redis.hset(`card:${cardId}`, {
    text: sanitizedText,
    type: cardType,
    status: "pending",
    submitter: sessionId,
    timestamp: Date.now(),
});

// Track vote
await redis.hincrby(`card:${cardId}:votes`, "upvotes", 1);

Moderation includes spam detection (checks for similar recent submissions) and rate limiting (10 cards per hour per session).

Special Game Modes

Once I had the core game locked down and most of the issues worked through - I added optional rule variations I found online (h/t to GameRules.com):

These let the user configure the game to their liking. Future enhancements may allow further configuration (like if you don’t want a point deducted).

Technical Decisions That Paid Off

TypeScript Everywhere

Using TypeScript on both frontend and backend meant shared type definitions:

// Shared types
export interface RoundInterface {
    roundNum: number;
    roundState: "player-selecting" | "judge-selecting" | "viewing-winner";
    cards: CardProps[];
    otherPlayerCards: CardProps[];
    timeLeft?: number;
}

Refactoring was safer. IDE autocomplete worked everywhere. Type mismatches got caught at compile time instead of runtime.

Redis for Sessions

Express-session + Redis gave persistent sessions with minimal setup:

app.use(
    session({
        store: new RedisStore({ client: redis }),
        secret: process.env.SESSION_SECRET,
        resave: false,
        saveUninitialized: false,
        cookie: { maxAge: 24 * 60 * 60 * 1000 },
    })
);

Sessions survive server restarts. Players can refresh without losing their game state. This was a HUGE win and lesson for my mental mind.

Socket.IO Rooms

Each game gets its own Socket.IO room. Broadcasting updates is simple:

// Join room when entering game
socket.join(partyCode);

// Broadcast to entire game
io.to(partyCode).emit("newGameState");

// Broadcast to everyone except sender
socket.to(partyCode).emit("playerJoined", { name });

No manual filtering required. Socket.IO handles the room membership.

Performance Observations

With 10-15 concurrent games (30-60 players), server load stays minimal:

Socket.IO scales well for this use case. The in-memory game state is fast enough.

What I’d Do Differently

State Persistence

Games live only in memory. Server restart = all active games lost. This hasn’t been a real problem (games are 15-30 minutes, deployments are rare after the initial launch), but proper state persistence would be better.

Options I considered:

Went with ephemeral for simplicity. Trade-off accepted. It’s a free personal project, I’m not getting paid for it!

Mobile-First Development

Built desktop-first, added mobile support later. This led to the touch event bugs. Starting with mobile constraints would have avoided them.

Better Test Coverage for Real-Time Events

Testing Socket.IO event flows is annoying:

// Mock Socket.IO in tests
const mockSocket = {
    emit: vi.fn(),
    on: vi.fn(),
    to: vi.fn().mockReturnThis(),
};

That works, but feels brittle. End-to-end tests with real Socket.IO connections would catch more issues.

What I Learned

Callbacks in long-lived objects need explicit update mechanisms. Don’t assume “set once at construction” will work when clients connect/disconnect.

Mobile gesture libraries require tuning. Default configs optimize for different use cases than yours.

Real-time debugging is harder than REST debugging. Events fire asynchronously. Timing matters. Logs need structured context.

User-submitted content needs moderation. Even in a silly card game, spam happens. Rate limiting and similarity detection are table stakes (☞゚ヮ゚)☞.

Small UX improvements compound. Device-specific instructions, timeout notifications, visual feedback — each one minor, together they make the experience usable.

Try It

Phucking Cards is live and free. No ads, no login, no tracking beyond basic analytics.

Create a game, share the party code, play with friends.

Source code is public.


Share this post on:

Next Post
Making a Spotify Widget Work on macOS (and Fixing the Bugs)