Hello everyone
I started the project with the simplest things: just a UI, a simple game, and some fetch calls.
Then I moved into realtime systems, built a Redux-like system with Redux Saga-style effects without even realizing it, and eventually ended up building data pipelines that collect data from multiple places.
When I started the project, I was trying to build something similar to neal.fun: a website with multiple games, where each game is simple and fun.
But I wanted something slightly different.
I wanted every game on the site to support two modes:
LocalOnline Multiplayer
And the user would be able to choose.
On top of that, I wanted to have many different games without having to build a completely new infrastructure for every game.
So the ambition became:
I wanted to write the game logic once and have the exact same logic run locally in the browser and online through a WebSocket server.
And that idea destroyed me π.
Because at first, it sounds very simple.
You think: βWhy not just keep the same state and send updates through WebSocket?β
But once you start asking questions like:
- Who is the source of truth?
- Where should the state live?
- Who decides whether an action is valid?
- What happens if two players act at the same time?
- How do I handle latency?
- How do I implement optimistic updates?
- How do I reconcile client state with server state?
- What happens when the connection drops?
- How do I restore a playerβs session?
- How can the same reducer run locally and on the server?
- How should side effects work?
- Who is responsible for fetching data?
- Should the game logic even know that HTTP or WebSockets exist?
I realized that I wasnβt really building βa game website.β
Without intending to, I was building a game platform architecture.
The Beginning: I Was Just Building UI
At the beginning, everything was normal.
React.
A simple game state.
A button.
fetch.
Maybe some Zustand.
And the game worked.
This kind of project gives you a dangerous feeling that everything is easy.
Because the UI gives you immediate feedback.
You click a button β state changes β UI updates.
User Action
β
State Update
β
React Re-render
Done.
But this model works as long as the game is local.
The moment I asked:
What if two people want to play together?
The entire problem changed.
The state was no longer just something inside a React application.
It became distributed state.
And the fundamental problem was no longer:
How do I change the state?
It became:
Who has the authority to change the state?
That was a much bigger conceptual shift than I expected.
The Original Idea: Neal.fun, But Multiplayer
The original idea was inspired by the philosophy of neal.fun.
A website containing a collection of small games and experiments.
Each game is independent, simple, and fun.
But I wanted to add one important constraint:
Every game should be able to run locally or online.
The same game could therefore run as:
βββ Local
β
Game Definition βββ€
β
βββ Multiplayer
And this is where the real problem began.
The easy solution would have been to write the game twice:
Five Seconds Local
Five Seconds Multiplayer
But that was unacceptable to me.
Because the rules are the same.
The actions are the same.
The state is the same.
The transitions are the same.
So why duplicate the logic?
I wanted:
βββ Local Adapter
β
Game Logic ββββββββββ€
β
βββ Multiplayer Adapter
The game logic should not know whether it is running in the browser or on the server.
That became one of the most important principles in the project.
The First Hard Question: Where Should the State Live?
When I started researching the right architecture, I had several options.
The simplest solution was:
Let the client be the source of truth, and use WebSocket only as a synchronization mechanism.
So I would have a Zustand store in the browser.
Every action would happen on the client.
Then I would send the change to the server.
The server would broadcast updates to the other clients.
At first, this looks great.
But architecturally, it creates a fundamental problem:
Client-authoritative multiplayer.
If the client is the source of truth, you are trusting the client.
And for multiplayer games, that is usually the wrong abstraction.
Any client could effectively say:
"I won."
Instead of:
"I want to perform this action."
The difference between those two sentences is the difference between:
state synchronization
and
server authority.
The Client Is Not the Source of Truth
One of the most important architectural decisions in PlayGrid was:
In multiplayer mode, the client is not the source of truth.
The server is the source of truth.
That led to a completely different architecture.
Instead of:
Client State
β
WebSocket
β
Server
I moved toward:
Client
β
β Action
βΌ
WebSocket
β
βΌ
Server
β
ββ Validate
ββ Reduce
ββ Effects
β
βΌ
Authoritative State
β
βΌ
Broadcast
β
βΌ
Clients
The client does not say:
βThis is the new state.β
It says:
βI want to perform this action.β
The server decides whether that action is actually allowed.
What About XState?
One of the ideas I researched was XState.
And the reason makes perfect sense.
Games are naturally state machines.
For example:
Lobby
β
Playing
β
Results
And inside Playing, you have many transitions.
So naturally you ask:
Why not use a state-machine library and build the entire game around it?
The idea is attractive.
But over time, I started distinguishing between two things:
A state machine as a way to describe transitions
and
A state machine as the runtime architecture for the entire system.
I didnβt just need a way to define states.
I needed an architecture that could separate:
- state
- actions
- reducers
- networking
- persistence
- effects
- validation
- reconciliation
- UI
In the end, I arrived at a simpler and more composable model for my needs:
Pure reducers + adapters + effects.
The Reducer Became the Heart of the System
The most important architectural decision in PlayGrid was making the game logic revolve around pure reducers.
The basic idea is:
reducer(currentState, action) β newState
No HTTP.
No WebSocket.
No database.
No React.
No Zustand.
No Cloudflare.
Just:
State + Action β New State
That decision gave me an important property:
The same reducer can run in multiple environments.
For example, a game might have an action:
{
type: "ANSWER",
answer: "Paris"
}
The reducer does not need to know where that action came from.
It could have come from:
Browser
or:
WebSocket
or even a test.
That made the game logic highly portable.
Why Pure Reducers Matter
Pure reducers give you three important properties.
1. Determinism
If you give the reducer the same input:
State A + Action B
it should produce the same:
State C
This is especially important in multiplayer systems.
You want the same transition to be reproducible.
2. Testability
Instead of needing:
- a browser
- a WebSocket server
- a database
- an API
just to test a game rule, I can write:
const nextState = reducer(state, action);
expect(nextState.score).toBe(10);
This dramatically reduces the cost of testing.
3. Portability
And this was the most important point for me.
The exact same reducer can run in:
Local
and:
Multiplayer
without duplication.
So How Did I Make Local and Multiplayer Behave the Same?
This is where the Game Adapter concept appeared.
Instead of letting React components know how the game works, I created a single abstraction.
The UI interacts with:
getState()
dispatch(action)
subscribe(listener)
And that is all it needs to know.
In Local Mode:
UI
β
Local Adapter
β
Reducer
β
Zustand
β
UI
In Multiplayer Mode:
UI
β
Multiplayer Adapter
β
WebSocket
β
Durable Object
β
Reducer
β
Broadcast
β
Client
β
UI
Notice the important part:
The UI does not change.
That was exactly the original goal.
Local Mode
In local mode, the browser owns the state.
The reducer runs entirely in the browser.
The adapter uses Zustand to manage local state.
The flow is:
Action
β
Adapter
β
Reducer
β
Zustand
β
Effects
β
Follow-up Action
β
Zustand
β
UI Re-render
This gives me an obvious advantage:
Instant feedback.
There is no:
Network latency
and you donβt need to wait for a server response.
That is perfect for games that should work offline or locally.
Multiplayer Mode
Multiplayer is different.
The client sends an action through WebSocket.
The server receives it.
Then it performs:
Validation
β
Reducer
β
Effects
β
Persist
β
Broadcast
Clients receive the authoritative state.
This leads to an important architectural point:
Multiplayer state management is not simply Zustand with WebSockets.
It is a small distributed system.
That distinction matters.
Durable Objects: The Actor Model I Needed for Game Rooms
At one point in the multiplayer architecture, I needed an abstraction representing a game room.
Each room has:
- state
- players
- WebSocket connections
- lifecycle
- actions
- persistence
- effects
This is where Cloudflare Durable Objects fit extremely wellβnot just because they provide persistence and WebSocket support, but because the mental model itself is close to the Actor Model.
Instead of thinking about the multiplayer system as a shared database with a bunch of clients, I started thinking about it like this:
Each Game Room is an independent Actor that owns its state, receives messages, and processes them.
That model fits games extremely well.
Game Room = Actor
In PlayGrid, you can think of each room as an Actor:
Game Room Actor
βββββββββββββββββββββββ
β β
β Game State β
β Players β
β Connections β
β Lifecycle β
β β
ββββββββββββ¬βββββββββββ
β
Messages
β
ββββββββββββββββΌβββββββββββββββ
β β β
Player A Player B Player C
Players do not directly modify the state.
They send messages/actions to the actor.
For example:
Player A
β
β ANSWER
βΌ
Game Room Actor
β
βββ Validate
βββ Reduce
βββ Persist
βββ Broadcast
This is fundamentally different from having every client maintain a copy of the state and trying to synchronize those copies.
Why Is the Actor Model a Good Fit for Games?
Because games already have a natural stateful entity:
The room.
A room has an identity.
For example:
room: abc123
Every player in abc123 needs to interact with the same state.
So instead of building a generic distributed state-management system, I can use a simple mapping:
Game Room ID
β
Durable Object Instance
β
Authoritative Game State
Each room becomes an independent unit in terms of state and lifecycle.
The Actor Owns the State
This is extremely important.
In the multiplayer architecture, the client does not own the state.
The Actor owns the authoritative state.
The client says:
"I want to perform ANSWER."
Not:
"Here is the new state."
The Actor receives the action and sends it through the game pipeline:
Action
β
Validation
β
Reducer
β
New State
β
Persistence
β
Broadcast
This makes state ownership explicit:
Client
β
β command/action
βΌ
Actor
β
β owns
βΌ
Game State
The Actor Is Not Just a WebSocket Server
At first, you might look at a Durable Object and think:
βItβs a WebSocket server for each room.β
But that reduces the value of the abstraction.
WebSocket is simply a communication mechanism.
The more important concept is that you have a stateful actor with identity, state, and lifecycle.
So the Durable Object provides something closer to:
Room Identity
+
State Ownership
+
Message Processing
+
Persistence
+
Connection Management
That is why this architecture became much simpler for me.
Instead of Distributed Shared State
Without this model, I could have designed the system roughly like this:
βββββββββββββββ
β Database β
ββββββββ¬βββββββ
β
ββββββββββββββΌβββββββββββββ
β β β
Client A Client B Client C
β β β
βββββ synchronization βββββ
And then you start dealing with:
- race conditions
- concurrent updates
- synchronization
- locking
- stale state
- conflict resolution
With the Actor Model, the room itself becomes the clear state boundary:
Game Room Actor
ββββββββββββββββββββ
β β
β authoritative β
β state β
β β
ββββββββββββββββββββ
β² β² β²
β β β
Player Player Player
The actors process messages rather than having multiple clients directly mutate shared state.
This Fits the Reducer Architecture
The nice thing is that the Actor Model sits naturally on top of the pure reducer architecture I built.
The Durable Object does not need to know the gameβs rules.
It simply provides the execution environment for the GameDefinition.
Conceptually:
Durable Object
β
βΌ
Game Session
β
βββββββββββ΄ββββββββββ
β β
Validation Reducer
β β
βββββββββββ¬ββββββββββ
βΌ
New State
β
βββββββββββ΄ββββββββββ
β β
Persistence Effects
β β
βββββββββββ¬ββββββββββ
βΌ
Broadcast
This makes the Durable Object infrastructure, not game logic.
The Actor knows how to manage the session.
But it does not know what these actions mean:
ANSWER
NEXT_ROUND
SUBMIT_LOGO
That is the responsibility of the game package.
Every Room Gets Its Own Actor
This also gives PlayGrid a clear scalability model.
Instead of having:
One giant multiplayer server
I have a concept closer to:
Room A β Actor A
Room B β Actor B
Room C β Actor C
Room D β Actor D
Each Actor is responsible for one room.
That makes the state boundary very explicit.
βββββββββββββββββββββββ
β Room A Actor β
β State A β
β Players A β
βββββββββββββββββββββββ
βββββββββββββββββββββββ
β Room B Actor β
β State B β
β Players B β
βββββββββββββββββββββββ
βββββββββββββββββββββββ
β Room C Actor β
β State C β
β Players C β
βββββββββββββββββββββββ
Architecturally, this is much cleaner than trying to build a global multiplayer state manager.
So Durable Objects Were More Than a Cloud Provider Choice
This is an important point.
I did not choose Durable Objects simply because:
βCloudflare supports WebSockets.β
The deeper reason is that the Actor Model fits the problem itself.
The game has natural stateful entities:
Game Room
These entities need:
- identity
- isolated state
- message handling
- persistence
- connections
- lifecycle
And all of that maps naturally to the Actor Model.
So the mental model became:
Game
β
Game Session
β
Actor
β
Authoritative State
This was one of the decisions that made the multiplayer architecture feel coherent instead of becoming a collection of interconnected WebSocket handlers.
From Client-Server to Message-Driven Architecture
This changed the way I thought about the system again.
Initially, I was thinking:
Client β Server β State Update
But the Actor Model pushed me toward thinking more in terms of:
Client
β
β Message / Action
βΌ
Actor
β
β State Transition
βΌ
New State
β
β Event / Broadcast
βΌ
Clients
This is much closer to a message-driven system.
The action itself becomes a message carrying intent:
{
type: "ANSWER",
answer: "Paris"
}
The Actor decides what happens with that message.
And this distinction is extremely important:
The client sends intent; the Actor owns the decision.
That is exactly what I needed to make multiplayer mode server-authoritative.
Side Effects
At this point, another problem appeared that I had not planned for.
Because the reducer must remain pure, what happens when the game needs to:
fetch questions
?
I cannot put fetch() inside the reducer.
The reducer needs to remain:
pure
So I needed to separate:
State transition
from
Side effect.
That led to the concept of Effects.
An effect runs after the reducer.
For example:
NEXT_ROUND
β
Reducer
β
State Updated
β
Effect Handler
β
HTTP Request
β
LOAD_QUESTIONS
β
Reducer
β
State Updated
This is similar in spirit to Redux middleware / saga-style separation of side effects from state transitions, but I built an abstraction specifically around PlayGridβs needs rather than coupling the game architecture to a particular library.
And Somehow I Started Building a Redux-Like System
The funny part is that I started the project wanting:
UI + games.
And after a while I had concepts like:
Actions
Reducers
Effects
Adapters
Validation
State transitions
Subscriptions
Reconciliation
Without really trying, I had started building a Redux-like architecture.
The effect system also started to look somewhat like saga-style orchestration:
Action
β
Reducer
β
Effect
β
Async Work
β
New Action
β
Reducer
The core idea became:
The reducer decides βwhat did the state become?β, while the effect handles βwhat should happen outside the state?β
That separation is extremely valuable.
Example: Five Seconds
One of the current examples is the Five Seconds game.
Suppose the player moves to the next round:
NEXT_ROUND
The reducer does not call the API.
Instead:
NEXT_ROUND
β
Reducer
β
New State
Then the effect handler sees that new questions are needed:
Effect
β
HTTP Client
β
GET /questions/random
Once the data arrives:
Questions
β
LOAD_QUESTIONS
β
Reducer
β
Updated State
In multiplayer mode, the resulting state can then be broadcast to the other players.
This makes the data flow explicit instead of mixing networking, state management, and fetching together.
Dependency Injection Solved a Bigger Problem Than I Expected
But another architecture problem appeared.
Initially, the game package wanted to create the HTTP client itself.
For example:
import httpClient from '@playgrid/api-client';
Practically, this is easy.
Architecturally, it is wrong.
Now the game package knows about infrastructure.
You end up with:
Game
β
API Client
The game should instead say:
βI need an HTTP client.β
It should not say:
βUse this specific HTTP library.β
So I moved toward:
Game
β
HttpClient Interface
and the infrastructure provides the implementation.
For example:
interface HttpClient {
get(url: string, options?: RequestInit): Promise<Response>;
}
The game depends on the contract, not the infrastructure.
That is the core idea behind Dependency Injection.
Why Is Dependency Injection Important Here?
Because the same game package can run in different environments.
For example:
Browser
β
Browser HTTP Client
or:
Cloudflare Worker
β
Worker HTTP Client
The game does not need to know the difference.
This reduces coupling.
The architecture becomes:
Game Logic
β
Contract
β
Implementation
instead of:
Game Logic
β
Infrastructure
Then Circular Dependencies Appeared
This led me to one of the things that took the most time in the project:
dependency direction.
Initially, some imports were moving in the wrong direction.
For example:
API
β
Game
while the Game needed:
API Client
and then the API Client needed types from the API.
Suddenly you have:
A β B β C β A
That is a circular dependency.
Circular dependencies are dangerous because they do not always fail immediately.
Instead, they can start affecting:
- build order
- module initialization
- type generation
- package boundaries
- testing
- deployment
So I decided that the dependency graph itself needed to become part of the architecture.
Shared Schemas
One solution was to move shared schemas into an independent package.
For example:
@playgrid/shared
Instead of having the API import a schema from a game package:
import { baseQuestionSchema } from '@playgrid/five-seconds';
it could import it from:
import { baseQuestionSchema } from '@playgrid/shared';
The game package can re-export it if needed.
The result is:
API βββββββββ
β
shared
β
β
Game ββββββββ
instead of:
API β Game
That is a small change on the surface, but an important architectural improvement.
API Contracts
I applied the same philosophy to the API client.
Instead of having the API client depend directly on the APIβs source code:
api-client β api source
I moved toward:
api
β
api-contracts
β
api-client
The api-contracts package contains the shared contract types.
This means the client does not need to reach into the API implementation to understand its interface.
That becomes increasingly important as a monorepo grows.
At This Point I Realized the Monorepo Was Part of the System
Initially, I thought a monorepo was simply:
A place to put packages.
But over time I started seeing it as an architectural boundary.
The layers became clearer:
Applications
β
Packages
β
Shared
And the dependency direction should generally move downward.
For example:
Frontend
β
Game Packages
β
Game Core
β
Shared
And for the API:
API
β
API Contracts
β
Shared
Most importantly:
No cycles.
Game Core: The Most Important Boundary
As the abstractions grew, another danger appeared:
game-core could become a package containing everything.
This happens often in large projects.
You start with:
Letβs put this in core because it is reusable.
Then:
Another game might need this.
Then:
This helper is small, letβs put it there too.
And suddenly core knows about every game.
So I ended up with a very explicit constraint:
game-coremust be game-agnostic.
It should not know about:
Five Seconds
Guess Logo
Questions
Logos
Sports
Instead, it knows about generic concepts such as:
Player
Turn
Phase
Session
Action
GameState
GameDefinition
The Difference Between Platform Logic and Game Logic
This became one of the most useful ideas in the project.
The question I started asking was:
Does this information describe βhow the platform worksβ, or βwhat the game doesβ?
For example:
Player roster
This is a platform concept.
Every multiplayer game needs players.
So it belongs in:
game-core
But:
Five Seconds timer
That is game-specific.
The platform itself does not need to know what Five Seconds is.
So it belongs in:
five-seconds package
The same applies to:
Question fetching
That is not the responsibility of game-core.
Another game might not use questions at all.
This Rule Changed How I Think
Instead of asking:
Is this code reusable?
I started asking:
Reusable for whom?
That is an important distinction in architecture.
An abstraction is not automatically good just because it is reusable.
Sometimes premature abstraction is worse than duplication.
A healthy abstraction should represent a real boundary.
That is why PlayGrid has a clear rule:
game-core
β
Platform-level concerns
while:
games/*
β
Game-specific concerns
GameDefinition
To add new games without modifying the core, each game registers itself through a GameDefinition.
The definition contains things such as:
Game ID
Version
Name
Description
Player limits
State Schema
Action Schema
Reducer
Initial State
Validator
Effect Handlers
This makes each game almost a plugin for the platform.
The platform knows:
I have a GameDefinition.
It does not need to know the internal details of the game.
Adding a New Game Becomes Straightforward
For example:
packages/games/
five-seconds/
guess-logo/
new-game/
Each game package is responsible for:
State
Actions
Reducer
Validation
Effects
Definition
while game-core provides the infrastructure.
This makes adding games much easier.
Data Pipelines
At some point, the project stopped being only about game state.
I started seeing many different data flows.
For example:
User Action
β
Reducer
β
Effect
β
HTTP
β
API
β
Database
β
Transform
β
Action
β
Reducer
This is effectively a data pipeline.
The nice thing is that the pipeline became traceable.
Instead of having:
fetch()
setState()
doSomething()
all mixed together in one place, the flow became explicit:
Action
β Transition
β Effect
β External Data
β Follow-up Action
β Transition
That made the system easier to understand and test.
Data Fetching Is Not Game State
There was also an important separation on the frontend.
Game state is one thing.
Server data is another.
So the frontend also uses TanStack Query for data fetching and caching.
This matters because:
Game State
is not necessarily:
Server Cache
A game needs state transitions and game rules.
API data needs caching, refetching, loading states, invalidation, and other server-state concerns.
There is no reason to force everything into the same abstraction.
The UI Should Not Know Any of This
I think this is one of the most successful parts of the architecture.
A React component should not need to know:
- whether the game is local
- whether it is multiplayer
- whether there is a WebSocket
- whether state is stored in Zustand
- whether the server is authoritative
- how reconciliation works
- where the Durable Object lives
The component should simply know:
const state = game.getState();
await game.dispatch({
type: "ANSWER",
answer,
});
And that is it.
That is the purpose of an abstraction.
Not to hide complexity for the sake of hiding it.
But to make each layer deal only with the complexity that belongs to it.
Optimistic Updates and Reconciliation
In multiplayer, things become more complicated.
The user wants fast feedback.
But the server remains authoritative.
So the client can perform an optimistic update, and when the authoritative state arrives from the server, the adapter performs reconciliation.
Conceptually:
User Action
β
Optimistic Client Update
β
WebSocket
β
Server Validation
β
Server Reducer
β
Authoritative State
β
Client Reconciliation
The adapter is the right place for these details.
Not the React component.
Not the game reducer.
That separation is important.
Reconnection Is Not Game Logic
For example, if the WebSocket connection drops, the game itself should not contain:
if (socket.closed) {
reconnect();
}
Reconnection is not a game rule.
It is a networking concern.
So the multiplayer adapter handles:
- connection
- reconnection
- session rehydration
- server state
- optimistic updates
- reconciliation
while the game remains focused on its rules.
Persistence
The same applies to persistence.
The reducer says:
State A + Action B = State C
It does not know where State C is stored.
The Durable Object handles persistence.
This gives you a clean separation:
Game Logic
β
State Transition
β
Server Infrastructure
β
Persistence
This is particularly useful in serverless environments.
Unified Deployment
Eventually, even the deployment architecture became part of the design.
The frontend and API are deployed together as a single Cloudflare Worker.
So:
Cloudflare Worker
βββ Hono API
βββ Frontend Assets
This gives practical benefits:
- one domain
- relative URLs
- no frontend-to-API CORS issue in production
- simpler deployment
In production, API communication can simply look like:
/api/...
instead of requiring a separate API domain.
Build Order Became Important
Because the monorepo now contains explicit dependencies, build order also became important.
For example:
shared
β
game-core
β
api
β
api-contracts
β
games
β
frontend / api-client / admin
This is not merely an optimization.
It is a direct consequence of the dependency graph.
If the architecture is correct, the build order should often be understandable from the dependency graph itself.
Testing
Pure reducers make testing much easier.
Unit tests can focus on:
Reducers
Validators
Pure utilities
Effect handlers
Effect handlers can be tested using a mocked HttpClient.
Then integration tests can focus on:
API routes
Game state + effects
Frontend components
And E2E tests can focus on:
Play game
Answer questions
Multiplayer scenarios
The idea is that each level tests something different.
You do not need an E2E test to verify that a reducer changes a score from 5 to 10.
That is a unit-test concern.
What Actually Changed From the Beginning?
If I go back to the first day, I was thinking about the project roughly like this:
React
β
Game
Then it became:
React
β
Game Adapter
β
Reducer
β
State
And in multiplayer:
React
β
Multiplayer Adapter
β
WebSocket
β
Durable Object / Actor
β
Validation
β
Reducer
β
Effects
β
Persistence
β
Broadcast
Then around it I added:
Monorepo
Package boundaries
Shared schemas
API contracts
Dependency injection
Game registry
Testing strategy
Deployment architecture
The project started as a game website.
But the problem itself forced me to build a platform.
The Biggest Thing I Learned: Small Requirements Can Hide a Distributed System
The sentence I started with was extremely simple:
βI want the game to work locally and online.β
But that small sentence contains a whole set of problems:
Local execution
+
Remote execution
+
State ownership
+
Consistency
+
Validation
+
Networking
+
Reconnection
+
Persistence
+
Side effects
+
Code reuse
And that made me realize something important about software architecture:
Sometimes complexity does not come from the size of the product. It comes from the guarantees you want.
You can build a simple game in two hours.
But if you say:
The same game must work locally and in multiplayer, using the same game logic, with server authority, reconnection, and persistence.
You are no longer dealing with βa simple game.β
You are dealing with a small distributed system.
Is the Current Architecture Perfect?
No.
And I think it is important to say that.
The current architecture is the result of the projectβs evolution and the problems I encountered. It is not an architecture that descended from the sky on day one.
Some parts are still being refactored.
Especially the circular dependency problem.
And that is normal.
In fact, one thing I consider healthy about the project is that the architecture itself remains open to criticism and change.
The goal is not to say:
βI built a beautiful architecture.β
The goal is:
βDoes this architecture make the next feature easier or harder?β
If adding a new game requires changing core every time, the abstraction is failing.
If game-specific logic leaks into infrastructure, the boundary is failing.
If the UI starts knowing about WebSockets, the adapter abstraction is failing.
If the client can force state onto the server, the authority model is failing.
These are the criteria I now use to evaluate the design.
In the End, I Learned That Architecture Follows the Problems
The funniest part is that I did not sit down on day one and say:
Today I will build a pure reducer architecture with adapters, Durable Objects, dependency injection, and an effect system.
π
I was just trying to make:
one game work locally and online.
Then each decision opened another problem.
The first problem:
How do I run the same logic in two places?
Led to:
Pure Reducer
Then:
How do I switch between local and multiplayer?
Led to:
Game Adapter
Then:
Who owns the state in multiplayer?
Led to:
Server Authority
Then:
How does the server manage a real room?
Led to:
Actor Model
Durable Objects
Then:
How do I handle fetching without breaking the pure reducer?
Led to:
Effects
Then:
How do I keep the game package independent from infrastructure?
Led to:
Dependency Injection
Then:
How do I prevent circular dependencies?
Led to:
Shared Packages
API Contracts
Clear Dependency Directions
Then:
How do I keep core from becoming a garbage drawer for every game?
Led to:
Game-Agnostic Core
Game-Specific Packages
And eventually the architecture looked roughly like this:
ββββββββββββββββββββββββ
β Frontend β
β React β
ββββββββββββ¬ββββββββββββ
β
βΌ
ββββββββββββββββββββββββ
β Game Adapter β
βββββββββ¬ββββββββ¬βββββββ
β β
Local β β Multiplayer
β β
βΌ βΌ
Reducer WebSocket
β β
β βΌ
β Durable Object
β / Actor
β β
β Validation
β β
β Reducer
β β
β Effects
β β
β Persistence
β β
β Broadcast
β β
βββββββββ
And the beautiful part is that the same reducer sits in the middle.
It does not know about React.
It does not know about WebSockets.
It does not know about Cloudflare.
It does not know about Zustand.
It does not know about HTTP.
It only knows:
State + Action β New State
Everything around it is infrastructure that allows that logic to live in different environments.
From UI to Platform
Maybe this is the best description of the PlayGrid journey for me.
It started with:
UI
+
Game
+
fetch()
And ended up with:
Pure Game Logic
+
Adapters
+
Server Authority
+
Actor Model
+
WebSockets
+
Durable Objects
+
Effects
+
Dependency Injection
+
Shared Contracts
+
Monorepo Boundaries
+
Data Pipelines
And the strangest part?
I was never trying to build all of this from the beginning.
The requirements pulled me into it.
And that is one of the most interesting things about building software systems.
Sometimes you start with an idea that sounds extremely small:
βI want a game.β
Then you add one requirement:
βBut I want it online.β
Then another:
βBut I want the same code to run locally.β
Then:
βAnd I want multiple games.β
And suddenly you find yourself discussing:
state ownership, distributed systems, the Actor Model, dependency inversion, deterministic reducers, consistency, persistence, and architectural boundaries.
That is when I realized that the hardest part of the project was not building the game itself.
The hardest part was building a system that lets me build many games without every new game becoming a reason to break the system underneath it.
And ultimately, that is what I am trying to achieve with PlayGrid:
Write the game once. Let the platform decide where and how it runs.
And maybe that was the biggest βI did not plan for thisβ lesson I learned from the entire project.