π Sunday, Mar 22, 2026
β° 00:00:00
π Sunday, Mar 22, 2026 00:00:00
π Reading time: 17 min
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.
Before getting into the technical side, here’s a quick summary of what Muushigdii is:
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.
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.
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:
| Service | Responsibility |
|---|---|
gameService.ts | Orchestrates game flow and phase transitions |
gameEngine.ts | Core card mechanics β dealing, trick resolution |
cardValidation.ts | Rule enforcement β legal move checking |
aiService.ts | AI decision-making across difficulty levels |
scoringService.ts | Score calculation and penalty logic |
roomService.ts | Room creation, join, and player management |
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:
DEALING β Shuffle and deal 5 cards to each player, reveal trump cardDECLARATION β Each player decides to resign or continue (max 3 consecutive resignations allowed)DEALER_TRUMP_SWAP β The dealer may optionally swap one of their cards with the trump cardREPLACEMENT β Players replace 0β5 cards from the stock pilePLAYING β 5 tricks played with follow-suit enforcement and trump override rulesSCORING β Winners subtract tricks won from their score; players who scored nothing gain a +5 penaltycardValidation.ts is the most rule-dense part of the codebase. It handles:
Every card play goes through validation before being accepted. Invalid plays are rejected with an error event sent back only to the offending client.
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.
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:
sessionId stored in localStorage on the clientsessionId in the reconnect_session eventsessionId back to the player’s slot in the active game and re-emits the current game stateRedis is used to cache active game sessions, making reconnection fast even across server restarts.
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.
Three difficulty levels are available when playing without a full table of human players:
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.
The client is built with React 18 + TypeScript, bundled with Vite.
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.
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.
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.
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.”
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:
The lobby room view received its own responsive pass:
The declaration panel (where players decide whether to resign or continue) and phase controls were also reworked:
17rem on mobile for a tighter fit.break-words and relaxed leading, preventing text overflow that was previously cutting off player names in the declaration sequence.The trick area (the central zone where cards are played each round) was made responsive:
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).cardContentSizes mapping that applies size-specific padding, font sizes, and ornament dimensions. The card back-face text is also responsive.A dedicated MobileGameHeader component was introduced:
AnimatePresence for smooth open/close transitions.index.html changes) to prevent pinch-zoom on mobile, which would break the game layout.The Lobby and UserStatsPanel were also tightened up:
MiniLeaderboard are now inline.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.
The settings system covers:
useTranslation hook, replacing many hardcoded English strings in GameControls with translatable keysclassic, modern, and traditional visual styles for cardsAll settings are persisted to localStorage under the key muushig-settings, with save and reset helpers exposed through a useSettings hook.
An audioService was added and integrated into the GameBoard:
The audio service respects the settings context, so muting music or sounds takes effect immediately without a page reload.
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.
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.
GameCard was enhanced to support three visual styles:
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.
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.
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.
PlayerHand are now computed with useMemo, avoiding recalculation on every render when the hand hasn’t changed.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.PlayerHand are now wrapped in useCallback to prevent child components from re-rendering when the parent updates.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:
PhaseBannerGameCardPlayerHandTrickAreaGameBoardGameControlsCardDealingAnimationThe 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.
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).
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.
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.
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.
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.
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.
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.
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:
client/dist directly via Express static middleware, with an SPA fallback to index.html. This simplifies deployment and eliminates CORS complexity./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.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.
Infrastructure is defined as code in the terraform/ directory, covering the Railway service configuration, environment variables, and networking. This makes it reproducible and auditable.
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:
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.
After the March 2026 optimization pass, the production metrics are:
| Metric | Value |
|---|---|
| Memory usage | ~120β150MB stable |
| Game action response time | <50ms |
| Concurrent games supported | 100+ |
| Database query time (indexed) | <5ms |
| React render reduction | 60β80% |
| CSS animation migration | 15+ animations moved off JS thread |
| Code-split lazy components | Settings, GameHistory |
| Debug logging in production | 0 (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.
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.