Building Muushigdii - A Real-Time Multiplayer Mongolian Card Game

πŸ“… Sunday, Mar 22, 2026

⏰ 00:00:00

πŸ”„ Sunday, Mar 22, 2026 00:00:00

πŸ“– Reading time: 17 min

πŸ“

Building Muushigdii β€” A Real-Time Multiplayer Mongolian Card Game

Muushigdii (ΠœΡƒΡƒΡˆΠΈΠ³) is Mongolia’s most popular trick-taking card game, and I built a full real-time multiplayer implementation of it from scratch. This post covers the architecture, technical decisions, challenges, and lessons learned from building a production-ready web game β€” from the game engine to WebSocket communication to AI opponents.

This is a living document β€” I update it as the project evolves. The latest round of updates (March 2026) brought a complete mobile-responsive overhaul, user-configurable settings with i18n support, audio integration, and a major frontend performance pass.

You can play it at muushigdii.com.


The Game

Before getting into the technical side, here’s a quick summary of what Muushigdii is:

  • Deck: 32 cards β€” 7 through Ace in all four suits
  • Players: 3–5 (optimal at 5)
  • Objective: Start at 15 points and race to reach exactly 0 by winning tricks
  • Trump suit: Revealed at deal, overrides all other suits
  • Card hierarchy: A > K > Q > J > 10 > 9 > 8 > 7

Each round goes through several distinct phases β€” dealing, declaration, dealer trump swap, card replacement, trick-playing, and scoring. There are special rules like the Four Aces auto-win, forced Ace-of-Trump lead, and a “Final Stand” mode when a player hits 1 point. Encoding all of this correctly into a game engine was the core challenge of the project.


Architecture Overview

The project is split into three main areas:

Muushigdii/
β”œβ”€β”€ client/       # React 18 + TypeScript frontend
β”œβ”€β”€ server/       # Node.js + Express + Socket.IO backend
└── shared/       # Shared TypeScript types used by both

Keeping shared types in a dedicated package was one of the best decisions I made early on. Because both the client and server import from shared/types.ts, the game state shape, event names, and card/player types are always in sync. Any type mismatch becomes a compile-time error rather than a runtime bug.


Backend: The Game Engine

The server is built with Node.js + Express + TypeScript, and all real-time communication runs over Socket.IO 4.7. The backend is split into focused service modules:

ServiceResponsibility
gameService.tsOrchestrates game flow and phase transitions
gameEngine.tsCore card mechanics β€” dealing, trick resolution
cardValidation.tsRule enforcement β€” legal move checking
aiService.tsAI decision-making across difficulty levels
scoringService.tsScore calculation and penalty logic
roomService.tsRoom creation, join, and player management

Game State Machine

The game moves through a strict set of phases. I implemented this as an explicit state machine inside gameService.ts β€” each phase transition is a named function that validates the current phase, applies the transition, and emits the new state to all connected clients. This made it much easier to debug invalid state issues and reason about what events are legal at any point in the game.

The phases in order:

  1. DEALING β€” Shuffle and deal 5 cards to each player, reveal trump card
  2. DECLARATION β€” Each player decides to resign or continue (max 3 consecutive resignations allowed)
  3. DEALER_TRUMP_SWAP β€” The dealer may optionally swap one of their cards with the trump card
  4. REPLACEMENT β€” Players replace 0–5 cards from the stock pile
  5. PLAYING β€” 5 tricks played with follow-suit enforcement and trump override rules
  6. SCORING β€” Winners subtract tricks won from their score; players who scored nothing gain a +5 penalty

Card Validation

cardValidation.ts is the most rule-dense part of the codebase. It handles:

  • Follow-suit enforcement: A player must play a card matching the led suit if they have one
  • Trump override: Trump cards beat all non-trump cards regardless of rank
  • Ace of Trump rule: A player holding the Ace of the trump suit must lead with it when they start a trick
  • Four Aces auto-win: Detected immediately after dealing β€” if any player holds all four Aces, they win the round instantly

Every card play goes through validation before being accepted. Invalid plays are rejected with an error event sent back only to the offending client.

Real-Time Communication with Socket.IO

All game events flow through Socket.IO. The server maintains one Socket.IO room per game room (identified by a 6-character room code). When a game state changes, the server emits the new state to everyone in that room.

Key events (both directions):

Client β†’ Server:
  join_room, create_room, play_card, replace_cards,
  declare_resign, dealer_swap, reconnect_session

Server β†’ Client:
  game_state_update, trick_complete, round_over,
  game_over, player_joined, player_disconnected, error

State is never reconstructed on the client from delta events β€” the server always sends the full (but view-filtered) game state. Each player only receives their own cards; other players’ hands are omitted from the payload. This keeps the anti-cheat model simple: the server is the single source of truth.

Persistent Sessions and Reconnection

One of the more technically interesting features is session persistence. If a player refreshes the page or loses connection, they can rejoin and pick up exactly where they left off.

This is implemented with a lightweight identity system:

  • On first connection, the server assigns a sessionId stored in localStorage on the client
  • On reconnect, the client sends the sessionId in the reconnect_session event
  • The server maps the sessionId back to the player’s slot in the active game and re-emits the current game state

Redis is used to cache active game sessions, making reconnection fast even across server restarts.

Database

Prisma ORM sits on top of PostgreSQL 15. The schema tracks rooms, players, and game history. Database interactions are minimal during active gameplay β€” the game state lives in memory (and Redis) for speed, and is persisted to Postgres at round boundaries. This keeps in-game response times well under 50ms.

Database indexes were added for the most common query patterns (room lookup by code, player lookup by session), resulting in a 50–80% reduction in query times.


AI Opponents

Three difficulty levels are available when playing without a full table of human players:

Beginner

  • 40% resignation rate
  • Plays random legal moves
  • No strategic evaluation β€” useful for testing rule enforcement

Intermediate

  • 25% resignation rate
  • Card value awareness (prefers playing low cards when not winning a trick)
  • Basic trump conservation strategy
  • Follows simple heuristics for replacement decisions

Advanced

  • 15% resignation rate
  • Card counting: tracks which cards have been played
  • Opponent modeling: infers probable hand contents from observed play
  • Strategic trump usage: holds trump for critical tricks rather than wasting early
  • Optimal replacement decisions: evaluates expected trick wins before deciding how many cards to replace

The AI runs entirely on the server. When it is an AI player’s turn, aiService.ts is called synchronously within the game loop, evaluates the current state, and returns a move. A managed timeout system wraps AI calls to prevent stale state from race conditions that can occur when multiple AI players act in quick succession.


Frontend

The client is built with React 18 + TypeScript, bundled with Vite.

UI Stack

  • Tailwind CSS for utility-first styling
  • shadcn/ui for accessible, composable UI primitives (dialogs, buttons, dropdowns)
  • Framer Motion for card animations β€” dealing, playing a card, trick collection

Framer Motion was particularly valuable for making the game feel alive. Card dealing is animated with staggered delays, and played cards animate to the center of the table before the trick is collected. These animations aren’t just cosmetic β€” they give players time to register what happened before the next state loads.

Responsive Design

The game layout adapts from desktop to mobile. On desktop, the five player seats are arranged around a virtual table. On mobile, the layout collapses to a vertically scrollable view with the player’s hand pinned to the bottom.


What’s New β€” March 2026 Update

The most recent development cycle (March 7–20, 2026) focused on three areas: a complete mobile-first responsive overhaul, user-configurable settings with i18n support and audio, and a deep frontend performance optimization pass. These changes transformed Muushigdii from a “works on mobile” game into a genuinely mobile-native experience.

Mobile-First Responsive Overhaul

This was the biggest effort. Over a series of commits, every component in the game was audited and reworked for small screens. The goal was not just “fit on a phone” but “feel natural on a phone.”

Game Board Responsiveness

The game board β€” the central play area where tricks happen β€” required the most work. On desktop, opponents are arranged in a circle around the table. On mobile, this same layout needs to fit in a much tighter space without overlapping or obscuring important game information.

Key changes:

  • Opponent hand badges were compacted with a maximum width of 160px, truncated player names, and smaller avatars on mobile. The card count badges were also scaled down so they don’t dominate the screen.
  • Opponent positions are now clamped to stay within the viewport on mobile. Previously, opponents on the edges could overflow off-screen, especially on narrower devices.
  • The current-player badge at the bottom of the board was redesigned with a smaller avatar and truncated name for mobile viewports.
  • Bottom padding was reduced to maximize the visible card area β€” on a phone, every pixel matters for seeing your hand clearly.

Lobby and Room View

The lobby room view received its own responsive pass:

  • Room header now stacks the title/room code and Leave/Start buttons vertically on mobile instead of trying to fit them side-by-side.
  • Invite links truncate the long URL and use an icon-only Copy button on mobile β€” the full URL is still copied to clipboard, just not displayed in full.
  • Player cards in the room use smaller avatars and reduced padding on mobile.
  • The Feedback button switches to icon-only on mobile with reduced horizontal padding.
  • Bottom padding was added on mobile to prevent content from hiding behind the floating button.

Game Controls

The declaration panel (where players decide whether to resign or continue) and phase controls were also reworked:

  • Declaration panel width was reduced to 17rem on mobile for a tighter fit.
  • Text sizes and padding were reduced across the phase header, turn indicator, and action buttons.
  • Declaration order text now wraps properly with break-words and relaxed leading, preventing text overflow that was previously cutting off player names in the declaration sequence.

Trick Area and Cards

The trick area (the central zone where cards are played each round) was made responsive:

  • A compact mode is now passed to the TrickArea when on mobile, which reduces the trick radius, shrinks the container, adjusts label positions, and selects a smaller card size (sm instead of md).
  • Card components now have a cardContentSizes mapping that applies size-specific padding, font sizes, and ornament dimensions. The card back-face text is also responsive.
  • The top turn indicator in the player hand is hidden on mobile to avoid redundancy with the mobile header.

Mobile Game Header and Score UI

A dedicated MobileGameHeader component was introduced:

  • It includes a tappable score panel with AnimatePresence for smooth open/close transitions.
  • Sorted player list with visual badges, initials, and a compact layout.
  • Viewport scaling is locked (index.html changes) to prevent pinch-zoom on mobile, which would break the game layout.
  • Safe-area insets are respected throughout β€” the player hand, controls, and header all account for notch/home-indicator areas on modern phones.

Lobby and Stats Compaction

The Lobby and UserStatsPanel were also tightened up:

  • Reduced paddings, border radii, gaps, and typography across the board.
  • Shrunk card/logo sizes, icons, buttons, inputs, and toggle controls.
  • Reflowed some elements β€” “How to Play” and the MiniLeaderboard are now inline.
  • Adjusted animation and transition timings for a tighter, more consistent UI on smaller viewports.

Settings, i18n, and Audio

A new SettingsContext was introduced to manage persistent, user-configurable game settings. This was a significant feature because it touches almost every part of the frontend.

What’s Configurable

The settings system covers:

  • Sound and Music: Toggle sound effects and background music independently
  • Notifications: Enable or disable in-game toast notifications
  • Theme: Light/dark mode with auto-follow system preference
  • Language: i18n scaffolding with a useTranslation hook, replacing many hardcoded English strings in GameControls with translatable keys
  • Card Style: Choose between classic, modern, and traditional visual styles for cards
  • Background Style: Configurable game table background
  • Auto Sort: Automatically sort your hand by suit and rank
  • Quick Play: Reduce animation durations for faster gameplay
  • Confirm Actions: Require confirmation before playing a card (useful for preventing misclicks on mobile)

All settings are persisted to localStorage under the key muushig-settings, with save and reset helpers exposed through a useSettings hook.

Audio Integration

An audioService was added and integrated into the GameBoard:

  • Background music starts and stops based on game state and the user’s music preference.
  • Sound effects fire on key game events β€” card plays, trick wins, round completions.
  • Notification toasts pair with optional sound cues.

The audio service respects the settings context, so muting music or sounds takes effect immediately without a page reload.

i18n Scaffolding

A lib/translations module was created with the structure needed for multi-language support. The useTranslation hook is integrated into GameControls as the first component to use translated strings. This is still early β€” not all components have been migrated β€” but the plumbing is in place for full localization.

Motion and Animation Context

The existing MotionContext was extended to support quickPlay mode. When enabled, animationDuration is derived from the user’s preference (shorter durations for quick play). This means animations don’t just toggle on/off β€” they scale smoothly, keeping the game feel responsive without losing all visual feedback.

Multiple Card Styles

GameCard was enhanced to support three visual styles:

  • Classic: Clean, minimalist card faces with standard pip layouts
  • Modern: Bolder colors and slightly stylized suit symbols
  • Traditional: A more ornate design with decorative elements

The card style is read from SettingsContext and applied at the component level. Each style has its own set of padding, font size, and ornament dimension rules that work with the responsive card sizing system.

Frontend Performance Optimization

The most recent commits (March 20, 2026) tackled frontend performance with a set of targeted improvements. The goal was to reduce unnecessary JavaScript execution, cut bundle size, and eliminate animation-related jank.

Dependency Upgrades

  • Framer Motion was upgraded from v10 to v11.18.2 β€” this brought tree-shaking improvements and reduced the animation runtime bundle size.
  • lucide-react was upgraded from v0.263 to v0.470 β€” newer versions have better tree-shaking and smaller per-icon bundles.

Code Splitting

React.lazy and Suspense were added for the Settings and GameHistory components. These are infrequently accessed views, so lazy-loading them reduces the initial bundle that every player downloads on first load.

Memoization Improvements

  • Card fan positions in PlayerHand are now computed with useMemo, avoiding recalculation on every render when the hand hasn’t changed.
  • Incomplete React.memo comparisons in GameBoard and GameControls were fixed β€” some props were being compared shallowly when they needed deeper equality checks, causing unnecessary re-renders.
  • Animation handlers in PlayerHand are now wrapped in useCallback to prevent child components from re-rendering when the parent updates.

CSS Animation Migration

This was one of the more impactful changes. Over 15 Framer Motion animate props that ran infinitely (pulsing effects, floating dust motes, phase banner shimmer) were replaced with pure CSS @keyframes animations. Components affected include:

  • PhaseBanner
  • GameCard
  • PlayerHand
  • TrickArea
  • GameBoard
  • GameControls
  • CardDealingAnimation

The reason: Framer Motion’s infinite animations keep the React reconciler active, even when nothing in the component tree is actually changing. CSS @keyframes are handled entirely by the browser’s compositor, which means zero JavaScript overhead for ongoing visual effects. On lower-end mobile devices, this made a noticeable difference in frame rates during gameplay.

Random Value Pre-computation

Two components were using Math.random() directly inside Framer Motion animate props β€” Confetti and the GameBoard dust motes. Because animate re-evaluates on every render, this generated new random values constantly, causing the animation to restart or jitter. The fix was to pre-compute random values with useMemo (dust motes) and move drift values out of the animate prop (Confetti’s driftX).

Layout Animation Cleanup

layoutId was removed from opponent and hidden cards in GameCard. Layout animations are powerful for shared-element transitions, but on cards that aren’t actually transitioning between layout positions, they add unnecessary layout thrashing. Removing them reduced reflow calculations during trick play.

Debug Logging

Approximately 60 console.log calls across GameControls, CardReplacement, GameBoard, and App.tsx were wrapped in import.meta.env.DEV checks. In production builds, Vite strips these entirely during tree-shaking, reducing both bundle size and runtime overhead.

Touch Device Optimization

whileHover animations on GameCard are now disabled on touch devices. On mobile, hover states don’t exist in a meaningful way, and some browsers fire synthetic hover events on tap, causing a flash of the hover animation before the tap action. Disabling hover on touch cleans up the interaction.

Responsive Dealer Selection

The DealerSelection component (the animation that plays when determining who deals) was updated to use viewport-based container sizing and a responsive radius. Previously it used fixed pixel values that could overflow on smaller screens.

TypeScript Cleanup

Five files had pre-existing implicit any TypeScript errors that were previously suppressed. These were cleaned up as part of the performance pass. The entire project now compiles with zero TypeScript errors.


Infrastructure

Docker

A docker-compose.yml spins up the full stack locally:

docker-compose up
# PostgreSQL + Redis + Server (3001) + Client (5173)

A separate docker-compose.prod.yml is used for production build testing.

Production Hosting

The app is deployed on Railway.com as a single-service deployment. The server serves the built client as static files, and the client uses same-origin API calls β€” no separate frontend service needed.

Key infrastructure decisions:

  • Single-service deployment: The server serves client/dist directly via Express static middleware, with an SPA fallback to index.html. This simplifies deployment and eliminates CORS complexity.
  • Health endpoint: The /health endpoint always returns HTTP 200 for Railway liveness checks, even when Redis or the database are degraded. The response body still reports component status, but a non-200 response was causing Railway to restart the container unnecessarily during transient issues.
  • Nixpacks configuration: A nixpacks.toml was added with OpenSSL in the setup phase, required by Prisma’s database driver.

Railway handles automatic deploys from the main branch, managed PostgreSQL and Redis instances, environment variable injection, and zero-downtime deploys.

Terraform

Infrastructure is defined as code in the terraform/ directory, covering the Railway service configuration, environment variables, and networking. This makes it reproducible and auditable.


How to Play Tutorial

An interactive How to Play dialog was added, accessible from the Lobby page. It’s a multi-step, animated tutorial that walks new players through:

  1. The objective β€” reduce your score from 15 to 0
  2. Card rankings and the 32-card deck
  3. Gameplay phases β€” declaration, replacement, trick-playing
  4. Penalties and special rules
  5. Winning conditions

This was important for onboarding β€” Muushigdii’s rules are not immediately intuitive to someone who hasn’t played before, and having an in-app explanation prevents players from bouncing because they don’t understand what’s happening.


Performance Results

After the March 2026 optimization pass, the production metrics are:

MetricValue
Memory usage~120–150MB stable
Game action response time<50ms
Concurrent games supported100+
Database query time (indexed)<5ms
React render reduction60–80%
CSS animation migration15+ animations moved off JS thread
Code-split lazy componentsSettings, GameHistory
Debug logging in production0 (stripped by Vite)

The memory figure was a hard-won result. Early versions had memory leaks from static collections that held references to finished game rooms indefinitely. I implemented a managed cleanup system that tears down room state, clears all associated timeouts, and removes Socket.IO listeners when a game ends or all players disconnect.


What I Learned

Encoding real-world game rules is harder than it looks. Muushigdii has a lot of edge cases β€” what happens when the stock pile is exhausted before all players have replaced their cards? What if all remaining players resign? What if the Ace of Trump holder disconnects on their forced lead? Each edge case requires a deliberate decision and careful implementation.

The server must be the single source of truth. Early on I experimented with letting the client track some state locally for responsiveness. This caused subtle desync bugs that were painful to reproduce and fix. Moving all authoritative state to the server and treating the client as a pure view made everything much more predictable.

AI is most useful as a design tool. Having AI opponents from early in development meant I could playtest the full game loop without needing five humans. The AI surfaced rule ambiguities and edge cases that I wouldn’t have caught otherwise.

Reconnection is not optional for a multiplayer game. Players lose connection. Phones go to sleep. Browsers get refreshed. If a disconnect means losing your game, players stop playing. Implementing persistent sessions early made the game feel polished and was worth every hour of effort.

Mobile is not an afterthought β€” it’s the primary target. The March 2026 responsive overhaul taught me that “it works on mobile” is not the same as “it feels good on mobile.” Every component needed individual attention: padding, font sizes, avatar dimensions, tap targets, safe-area insets. The trick is to design compact-first and expand for desktop, not the other way around.

CSS animations beat JavaScript animations for anything that loops. Moving 15+ infinite Framer Motion animations to CSS @keyframes had a measurable impact on mobile frame rates. The browser’s compositor handles CSS animations without touching the main thread, while Framer Motion keeps React’s reconciler active. For one-shot transitions (card dealing, trick collection), Framer Motion is still the right choice. For continuous ambient effects, CSS wins.

Settings and personalization create attachment. Adding configurable card styles, themes, and audio controls made the game feel more personal. Players who customize their experience tend to come back. The SettingsContext pattern β€” a single context with localStorage persistence and a useSettings hook β€” was clean to implement and easy to extend.


The full source is on GitHub and the game is live at muushigdii.com. If you grew up playing ΠœΡƒΡƒΡˆΠΈΠ³ or want to learn, come play a hand.