From a Simple Game to a Full Multiplayer System: The Journey of Building PlayGrid

May 11, 2026

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:

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:

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:

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:

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:

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:

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:

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:

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-core must 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:

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:

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:

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.