The first version of Dealopoly sounded simple.
A player gets some cards, plays them, collects rent, steals a property or two, and eventually someone wins.
Then I tried to make it work for multiple people playing from different browsers.
That changed the problem completely.
What looked like a card-game UI became a distributed-systems problem with a UI attached to it.
Dealopoly is a real-time multiplayer card gaming platform built around games such as Monodeal and Lowdeck. The current architecture separates the web application, an authoritative Fastify WebSocket game server, a deterministic game engine, persistent storage, and Redis-based realtime infrastructure.
The most useful things I learned weren't really about cards.
They were about state, authority, failure, and boundaries.
1. A Multiplayer Game Is Mostly a State Problem
For a single-player game, the browser can get away with being the center of the universe.
The UI knows the current state.
The player clicks a button.
The state changes.
The screen updates.
Multiplayer breaks that assumption immediately.
Imagine this:
Player A Browser
│
│ "play this card"
▼
Game Server
│
▼
Game Engine
│
▼
New Game State
│
┌───┴────┐
▼ ▼
Player A Player B
Browser Browser
Now there is one important question:
Who gets to decide whether the move is valid?
The answer in Dealopoly is the server.
The browser requests an action.
The server decides whether that action is legal.
The game engine applies the rules.
The resulting state is then delivered back to the players.
That distinction turns out to be one of the most important architectural decisions in the whole project.
2. The Browser Should Ask, Not Decide
Consider a button like:
playCard(cardId)
It would be tempting to let the client perform some validation first and then update its local state optimistically.
For a normal application, that can be a perfectly reasonable pattern.
For a competitive multiplayer game, it creates a dangerous boundary.
A modified browser could simply send:
{
"type": "playCard",
"cardId": "deal-breaker"
}
even when:
- it isn't that player's turn,
- the card isn't in their hand,
- they have already used their available actions,
- the target isn't valid,
- or the current game phase doesn't allow the move.
So the server needs to treat every command as untrusted input.
Client
│
│ command
▼
Server
│
├── Is player in this room?
├── Is it their turn?
├── Is this command legal?
├── Is the target valid?
└── Can the current game state accept it?
│
▼
Game Engine
│
┌─────┴─────┐
│ │
rejected accepted
│ │
▼ ▼
error next state
The useful general lesson is bigger than games:
The client can request an operation, but the system that owns the state should be responsible for deciding whether it happens.
You can apply the same principle to payments, inventory, permissions, workflow systems, and collaborative applications.
3. This Led Me to Separate the Game Engine From the WebSocket Server
One of the decisions I value most in the codebase is keeping the game engine independent from the interface.
The engine doesn't need to know whether the request came from:
- a browser,
- a WebSocket,
- a bot,
- a test,
- or something else.
It receives state and a command.
Then it produces either a rejection or the next state and associated events.
Conceptually:
type CommandResult =
| {
accepted: false;
error: GameError;
}
| {
accepted: true;
state: GameState;
events: GameEvent[];
};
That means the rules can be tested without running a web server.
For example, a rule test can exercise a payment scenario without needing:
Browser → WebSocket → Server → Redis → Database
It can simply test:
Game State
+
Command
↓
Game Engine
↓
Next State
That makes the most important part of the system much easier to reason about.
4. Deterministic Rules Are More Valuable Than Clever UI
A card game has a surprisingly large number of interacting rules.
A player might:
- play a property,
- add money to a bank,
- charge rent,
- trigger a reaction,
- respond with "Just Say No",
- choose a target,
- make a payment,
- finish a property set,
- and potentially win.
The UI can make all of this look simple.
The engine cannot.
The Dealopoly game engine therefore has explicit rule modules for areas such as:
setup
draw
property
rent
payment
reactions
win condition
That separation matters because rules tend to grow faster than the UI suggests.
A seemingly harmless change such as:
"Allow another card type to affect rent."
can ripple through payment resolution, reactions, turn state, and win conditions.
Putting those rules into explicit domain code gives each change somewhere obvious to live.
5. Reactions Are Where a "Simple Turn" Stops Being Simple
One of the more interesting parts of the game is reaction handling.
Suppose Player A charges rent.
Player B can respond.
Then Player A may have another response.
Suddenly a single action isn't really:
play card
↓
done
It is closer to:
Player A action
↓
pending resolution
↓
Player B response window
↓
response accepted?
┌──┴──┐
│ │
yes no
│ │
▼ ▼
counter resolve
│
▼
new response window
│
▼
final resolution
This is effectively a small state machine.
And that is a useful lesson beyond games:
Whenever an operation can pause, wait for another actor, be rejected, or be countered, model that as state instead of trying to hide everything inside one function.
It makes the system much easier to reason about.
6. Hidden Information Changes How You Broadcast State
Another problem appears once the server knows more than a player should know.
The server might know every player's hand.
Player A shouldn't.
That means broadcasting the complete game state to every client would be a security problem.
Dealopoly therefore creates a player-specific masked view of the game state.
Conceptually:
Full Game State
│
┌──────────┼──────────┐
▼ ▼ ▼
Player A Player B Player C
View View View
│ │ │
▼ ▼ ▼
own hand own hand own hand
visible visible visible
info info info
The interesting part is that masking isn't only about hiding cards.
Certain cards can contain information that should be represented differently depending on who is looking at them.
That is a good reminder that authorization isn't always:
allowed / forbidden
Sometimes it is:
The same underlying state must produce different representations for different viewers.
That pattern appears in dashboards, admin systems, collaborative tools, financial applications, and many other products.
7. Realtime Doesn't Mean "Just Use WebSockets"
Adding WebSockets makes messages realtime.
It does not automatically make the system reliable.
A player can:
- refresh the browser,
- lose Wi-Fi,
- switch networks,
- close a laptop,
- background a mobile browser,
- or disappear completely.
The game still exists.
So reconnect behavior becomes part of the product.
Dealopoly tracks room membership and disconnect state, including a grace period before a disconnected player is replaced by a bot.
The important mental model is:
CONNECTED
│
│ disconnect
▼
DISCONNECTED
│
│ reconnect in time
├──────────────────► CONNECTED
│
│ timeout
▼
BOT / LEAVE FLOW
That small state transition prevents a temporary network problem from instantly becoming a game-ending event.
8. Redis Became Useful for More Than Caching
When I initially thought about Redis, it was easy to put it into the "cache" bucket.
The actual architecture made it more interesting.
Dealopoly uses Redis for things such as:
- room state,
- active-room indexing,
- disconnect timers,
- and Pub/Sub for propagating room events between server instances.
For example:
Game Server A
│
│ publish
▼
Redis Pub/Sub
│
├───────────────┐
▼ ▼
Game Server B Game Server C
This matters when the game server isn't a single process anymore.
A room doesn't necessarily belong to one permanently fixed machine.
The application needs a way for instances to communicate about the same room.
The broader lesson:
Once an application becomes distributed, in-memory state starts becoming a coordination problem.
That is when systems such as Redis become much more than a simple key-value cache.
9. Bots Should Use the Same Rules as Humans
One architectural choice that pays off quickly is making bots interact with the same game engine.
A bot should not have a secret shortcut like:
bot.setWinner();
It should have to make legal moves.
Game State
│
┌──────┴──────┐
▼ ▼
Human Bot
│ │
│ command │ decision
└──────┬──────┘
▼
Game Engine
│
▼
Legal / Invalid
This gives the bot a useful constraint:
the game engine remains the authority for everyone.
The bot can become smarter without changing the rules.
That separation also makes bot simulations possible.
The repository contains tests around bot behavior and game simulations, which is useful because multiplayer bugs aren't always reproducible by clicking around manually.
10. Simulation Is One of the Best Tools for Game Logic
A UI test might tell me:
"I clicked this and the card moved."
A simulation can ask a much more interesting question:
"Can thousands of legal games complete without producing an impossible state?"
That is especially useful for systems with many interacting rules.
Seed
↓
Create Game
↓
Choose Legal Move
↓
Apply Move
↓
Validate State
↓
Repeat
↓
Game Over
Deterministic seeds make the process reproducible.
When a simulation fails, the useful thing isn't simply that something broke.
You can potentially replay the same state and investigate the exact sequence that led there.
That is a powerful debugging pattern for any state-heavy system.
11. Persistence and Realtime State Have Different Jobs
Another lesson was not to treat every piece of state as having the same lifecycle.
Some state needs to be extremely fast:
current room
current turn
pending reaction
active connection
Some state needs to survive much longer:
players
completed games
history
statistics
leaderboards
That naturally leads to different responsibilities:
Application
│
┌──────────┴──────────┐
▼ ▼
Redis PostgreSQL
fast / ephemeral durable / persistent
The exact split will vary by product, but the underlying question is useful:
How long does this state need to live, and who needs it?
That question often tells you where it belongs.
12. A Good Monorepo Is About Boundaries, Not Folders
Dealopoly is organized as a monorepo with separate applications and shared packages.
At a high level:
dealopoly/
├── apps/
│ ├── web/
│ └── game-server/
│
└── packages/
├── game-engine/
├── db/
├── redis/
├── shared/
└── ui/
The useful part isn't the directory tree by itself.
It's the ownership behind it.
For example:
| Responsibility | Package |
|---|---|
| UI | apps/web |
| Realtime server | apps/game-server |
| Game rules | packages/game-engine |
| Database | packages/db |
| Redis infrastructure | packages/redis |
| Shared domain types | packages/shared |
| Shared UI primitives | packages/ui |
This prevents a common problem in growing projects:
Everything imports everything.
Instead, each layer gets a reason to exist.
13. The Most Difficult Bugs Aren't Always the Most Complicated Code
Some of the hardest problems came from interactions between otherwise reasonable systems.
For example:
UI state
+
server state
+
reconnect
+
timers
+
pending reaction
Each individual piece can look fine.
The difficult part is asking:
What happens when all five occur at the same time?
A player can disconnect during a pending reaction.
A timer can expire while the browser is reconnecting.
A stale client can send a command after another player has already changed the state.
This is why state transitions and server authority matter so much.
The happy path is easy.
The edges are the real product.
14. What I Would Keep in Mind When Building Another Realtime Product
After working through Dealopoly, these are the principles I would carry into the next realtime application.
Keep the domain logic pure
The game engine shouldn't care about React, WebSockets, Redis, or PostgreSQL.
Domain
↓
Infrastructure
↓
Interface
not:
UI
↕
Game Rules
↕
Redis
↕
Database
Treat clients as untrusted
A browser is a user interface, not an authority.
Model intermediate states explicitly
Especially when operations can be paused, rejected, countered, or resumed.
Design for disconnects early
Realtime systems aren't really realtime if they only work on perfect networks.
Test state transitions, not only screens
A beautiful game can still have a broken engine.
Make infrastructure replaceable
The less your domain model knows about Redis, WebSockets, or a specific database client, the easier it is to evolve the system later.
15. The Biggest Thing I Learned
I started thinking about Dealopoly as a game.
At some point, I realized I was actually building a small distributed system.
The cards were just the domain.
The difficult parts were:
authority
state transitions
concurrency
reconnection
hidden information
timers
persistence
failure recovery
And that changed how I think about realtime products in general.
The UI is what players see.
The interesting engineering is what keeps the UI honest.
Try It Yourself
The easiest way to understand some of these decisions is to play the game.
Play Dealopoly live:
https://dealopoly.vercel.app/
The complete source code is also available on GitHub:
Repository:
https://github.com/shubhsaur/dealopoly
Building something real has a funny way of exposing the parts of software engineering tutorials tend to skip.













