من لعبة بسيطة إلى نظام متعدد اللاعبين كامل رحلة بناء playgrid

١١ مايو ٢٠٢٦

السلام عليكم ورحمة الله وبركاته

بدأت المشروع من أبسط شيء: مجرد UI ولعبة بسيطة وfetch data.

إلى أن مريت على realtime systems، إلى إني بنيت Redux-like system مع Redux Saga بدون ما أحس، إلى أن سويت data pipelines تجمع بيانات من كل مكان.

من بدأت المشروع كنت أحاول أقلد شيء زي neal.fun؛ موقع فيه أكثر من لعبة، وكل لعبة بسيطة وممتعة وتشتغل مباشرة في المتصفح.

لكن الشيء اللي كنت أبغاه مختلف شوي.

كنت أبغى كل لعبة في الموقع تقدر تشتغل بطريقتين:

والمستخدم هو اللي يختار.

وفوق هذا، كنت أبغى يكون عندي عدد كبير من الألعاب، بدون ما أضطر أبني infrastructure جديدة لكل لعبة.

فكان الطموح العالي جدًا:

أبغى أكتب الـ game logic مرة وحدة، ونفس الكود يشتغل في المتصفح في local mode، ويشتغل أيضًا على WebSocket server في multiplayer mode.

والفكرة ذي دمرتني 😁.

لأنها في البداية تبدو بسيطة جدًا.

تقول: “ليش ما نخلي نفس الـ state ونرسل updates عن طريق WebSocket؟”

لكن بمجرد ما تبدأ تسأل أسئلة مثل:

هنا اكتشفت أني ما كنت أبني “موقع ألعاب”.

كنت، بدون ما أقصد، أبني game platform architecture.


البداية: كنت أبني UI فقط

في البداية كان الموضوع عادي جدًا.

React.

UI.

Game state بسيط.

Button.

fetch.

يمكن شوية Zustand.

واللعبة تشتغل.

وهذا النوع من المشاريع يعطيك إحساس خطير جدًا بالسهولة.

لأن الـ UI يخليك تشوف النتيجة بسرعة.

تضغط زر → state يتغير → UI يتحدث.

مثلًا:

User Action

State Update

React Re-render

وخلصنا.

لكن هذا النموذج يشتغل طالما اللعبة محلية.

بمجرد ما قلت:

طيب لو شخصين يلعبون مع بعض؟

الموضوع تغير بالكامل.

لأن الـ state لم يعد مجرد شيء داخل React application.

صار عندك distributed state.

وصارت المشكلة الأساسية ليست:

كيف أغير الـ state؟

بل:

من يملك الحق في تغيير الـ state؟

وهذه نقلة فكرية أكبر بكثير مما توقعت.


الفكرة الأصلية: Neal.fun ولكن Multiplayer

الفكرة الأصلية كانت قريبة من فلسفة neal.fun.

موقع فيه مجموعة ألعاب وتجارب صغيرة.

كل لعبة مستقلة، بسيطة، وممتعة.

لكن كنت أبغى أضيف constraint مهم جدًا:

كل لعبة يجب أن تكون قابلة للتشغيل Local أو Online.

يعني نفس اللعبة ممكن تشتغل كالتالي:

              ┌── Local

Game Definition ──┤

                  └── Multiplayer

وهنا بدأت المشكلة الحقيقية.

لأن الحل السهل جدًا هو أن أكتب اللعبة مرتين:

Five Seconds Local
Five Seconds Multiplayer

لكن هذا بالنسبة لي كان unacceptable.

لأن عندي نفس rules.

نفس actions.

نفس state.

نفس transitions.

فليش أكرر الـ logic؟

كنت أبغى:

                ┌── Local Adapter

Game Logic ─────────┤

                    └── Multiplayer Adapter

الـ game logic ما يعرف أصلًا هل هو يعمل على browser أو server.

وهذا أصبح واحدًا من أهم المبادئ في المشروع.


أول سؤال صعب: أين يجب أن يعيش الـ State؟

لما بدأت أبحث عن architecture مناسبة، كان عندي أكثر من احتمال.

أبسط حل:

خل الـ client هو مصدر الحقيقة، والـ WebSocket مجرد وسيلة synchronization.

يعني عندي Zustand store في المتصفح.

كل action يحدث على الـ client.

وبعدها أرسل التغيير للسيرفر.

السيرفر يرجع updates للعملاء.

في البداية يبدو الحل ممتاز.

لكن فعليًا هذا يخلق مشكلة جوهرية:

Client-authoritative multiplayer.

لو الـ client هو مصدر الحقيقة، فأنت تثق بالـ client.

وهذا في multiplayer games غالبًا abstraction خاطئ.

لأن أي client يمكن أن يقول:

"I won."

بدل ما يقول:

"I want to perform this action."

الفرق بين الجملتين هو الفرق بين:

state synchronization

و

server authority.


الـ Client ليس مصدر الحقيقة

أحد أهم القرارات المعمارية في PlayGrid كان:

في multiplayer mode، الـ client ليس مصدر الحقيقة.

السيرفر هو مصدر الحقيقة.

وهذا قادني إلى architecture مختلفة تمامًا.

بدل:

Client State

WebSocket

Server

صار عندنا:

Client

   │ Action

WebSocket


Server

   ├── Validate
   ├── Reduce
   └── Effects


Authoritative State


Broadcast


Clients

وهنا بدأت الأمور تصبح أكثر منطقية.

الـ client لا يقول:

“هذه هي الحالة الجديدة.”

هو يقول:

“أريد تنفيذ هذا الـ action.”

والسيرفر هو الذي يقرر إذا كان هذا الـ action مسموحًا أصلًا.


طيب XState؟

من الأفكار التي بحثت فيها أيضًا كانت XState.

والسبب منطقي جدًا.

لأن الألعاب بطبيعتها عبارة عن state machines.

عندك مثلًا:

Lobby

Playing

Results

وداخل Playing عندك transitions كثيرة.

فمن الطبيعي جدًا أن تفكر:

لماذا لا أستخدم State Machine library وأبني اللعبة كلها حولها؟

الفكرة جذابة.

لكن مع الوقت بدأت أميز بين شيئين:

State machine كطريقة لوصف transitions

وبين:

State machine كـ runtime architecture للنظام بالكامل.

أنا لم أكن أحتاج فقط إلى تعريف states.

كنت أحتاج architecture تستطيع أن تفصل:

وفي النهاية وصلت إلى نموذج أبسط وأكثر composable بالنسبة لاحتياجي:

Pure reducers + adapters + effects.


الـ Reducer أصبح قلب النظام

أهم قرار architectural في PlayGrid كان أن game logic نفسه يكون مبنيًا على pure reducers.

الفكرة الأساسية:

reducer(currentState, action) → newState

لا HTTP.

لا WebSocket.

لا database.

لا React.

لا Zustand.

لا Cloudflare.

فقط:

State + Action → New State

وهذا القرار أعطاني خاصية مهمة جدًا:

نفس الـ reducer يستطيع العمل في أكثر من environment.

مثلًا، لعبة ما عندها action:

{
  type: "ANSWER",
  answer: "Paris"
}

الـ reducer لا يحتاج يعرف من أين جاء هذا الـ action.

ممكن جاء من:

Browser

أو:

WebSocket

أو حتى test.

وهذا جعل الـ game logic portable بشكل كبير.


لماذا Pure Reducers مهمة؟

الـ pure reducer يعطيك ثلاث خصائص مهمة جدًا.

1. Determinism

إذا أعطيته نفس input:

State A + Action B

لازم يعطيك نفس:

State C

وهذا مهم جدًا في multiplayer systems.

لأنك تريد أن تكون transition نفسها قابلة لإعادة التنفيذ.


2. Testability

بدل ما أحتاج:

عشان أختبر rule بسيطة، أقدر أكتب:

const nextState = reducer(state, action);

expect(nextState.score).toBe(10);

وهذا يقلل بشكل ضخم من تكلفة testing.


3. Portability

وهذه كانت أهم نقطة بالنسبة لي.

نفس reducer يشتغل في:

Local

وفي:

Multiplayer

بدون duplication.


إذًا كيف جعلت الـ Local والـ Multiplayer متشابهين؟

هنا ظهر مفهوم Game Adapter.

بدل ما الـ React components تعرف كيف اللعبة تشتغل، جعلت عندي abstraction واحدة.

الـ UI يتعامل مع:

getState()
dispatch(action)
subscribe(listener)

وهذا كل ما يحتاج يعرفه.

في Local Mode:

UI

Local Adapter

Reducer

Zustand

UI

وفي Multiplayer Mode:

UI

Multiplayer Adapter

WebSocket

Durable Object

Reducer

Broadcast

Client

UI

لاحظ الشيء المهم هنا:

الـ UI لم يتغير.

وهذا بالضبط كان الهدف الأصلي.


Local Mode

في الـ local mode، الـ browser هو صاحب الـ state.

الـ reducer يعمل داخل المتصفح.

والـ adapter يستخدم Zustand لإدارة الـ local state.

الـ flow:

Action

Adapter

Reducer

Zustand

Effects

Follow-up Action

Zustand

UI Re-render

وهذا يعطيني ميزة واضحة جدًا:

Instant feedback.

ما عندك:

Network latency

ولا تحتاج تنتظر server response.

وهذا ممتاز للألعاب التي تريد تشغيلها offline أو local.


Multiplayer Mode

في multiplayer الوضع مختلف.

الـ client يرسل action عبر WebSocket.

السيرفر يستقبل.

ثم يعمل:

Validation

Reducer

Effects

Persist

Broadcast

والـ clients تستقبل الـ authoritative state.

هنا يصبح الـ Durable Object هو مصدر الحقيقة.

وهذا يقود إلى نقطة مهمة جدًا في التصميم:

Multiplayer state management ليس مجرد Zustand لكن مع WebSocket.

هو distributed system صغير.

وهذا الفرق مهم.


لماذا Durable Objects؟

احتجت abstraction يمثل game room.

كل room عنده:

وهذا يناسب جدًا نموذج Cloudflare Durable Objects، خصوصًا أنها مبنية على Actor Model بشكل مباشر، حيث كل object يمثل actor مستقل يملك state خاص به ويتعامل مع messages بشكل متسلسل.

فأصبح عندي concept:

Game Room

Durable Object (Actor Model)

والـ Durable Object مسؤول عن:

وبالتالي:

Durable Object هو الـ single source of truth في multiplayer mode.

وهذا حل جزء كبير من مشكلة الـ distributed state.


لكن ظهرت مشكلة جديدة: الـ Side Effects

هنا وصلت إلى جزء آخر لم أكن مخططًا له أصلًا.

لأن الـ reducer عندي يجب أن يكون pure.

طيب ماذا لو اللعبة تحتاج تعمل:

fetch questions

؟

لا أقدر أضع fetch() داخل reducer.

لأن reducer يجب أن يكون:

pure

فاحتجت أفصل بين:

State transition

و

Side effect.

ومن هنا ظهر عندي مفهوم:

Effects

الـ effect يحدث بعد الـ reducer.

يعني مثلًا:

NEXT_ROUND

Reducer

State Updated

Effect Handler

HTTP Request

LOAD_QUESTIONS

Reducer

State Updated

وهذا قريب جدًا من فلسفة systems مثل Redux middleware / saga من ناحية فصل الـ side effects عن state transitions، لكنني بنيت abstraction مخصصة لاحتياج PlayGrid بدل ما أربط الـ game architecture بالكامل بمكتبة معينة.


وهنا بدأت أبني Redux-like system بدون ما أخطط

المضحك أني بدأت المشروع وأنا أريد:

UI + games.

وبعد فترة أصبحت أملك مفاهيم مثل:

Actions
Reducers
Effects
Adapters
Validation
State transitions
Subscriptions
Reconciliation

يعني بدون ما أحاول، بدأت أبني Redux-like architecture.

والـ effect system بدأ يأخذ شكل قريب من saga-style orchestration:

Action

Reducer

Effect

Async Work

New Action

Reducer

والفكرة الأساسية أصبحت:

الـ reducer يقرر “ماذا أصبحت الحالة؟”، والـ effect يتعامل مع “ماذا يجب أن يحدث خارج state؟”.

وهذا separation مهم جدًا.


مثال: Five Seconds

أحد الأمثلة الموجودة حاليًا هو لعبة Five Seconds.

لنفترض أن اللاعب انتقل إلى الجولة التالية:

NEXT_ROUND

الـ reducer لا يذهب إلى API.

بدل ذلك:

NEXT_ROUND

Reducer

New State

ثم effect handler يشوف أن هناك حاجة لأسئلة جديدة:

Effect

HTTP Client

GET /questions/random

وبعد وصول البيانات:

Questions

LOAD_QUESTIONS

Reducer

Updated State

في multiplayer mode، بعد ذلك state الجديدة يتم broadcast لها لباقي اللاعبين.

وهذا يجعل الـ data flow explicit بدل ما تكون الشبكة والـ state والـ fetching متداخلة داخل بعض.


Dependency Injection حل مشكلة أكبر مما توقعت

لكن هنا ظهرت مشكلة architecture ثانية.

في البداية كان game package نفسه يريد إنشاء HTTP client.

مثلًا:

import hcWithType from '@playgrid/api-client';

من ناحية عملية، هذا سهل.

لكن architecture-wise كان غلط.

لأنك الآن جعلت:

Game

API Client

وهذا يعني أن game package صار يعرف infrastructure.

وهنا تبدأ dependency graph تصبح messy.

لذلك غيرت التصميم إلى:

Game

HttpClient Interface

والـ infrastructure هو الذي يحقن implementation.

مثلًا:

interface HttpClient {
  get(url: string, options?: RequestInit): Promise<Response>;
}

اللعبة تقول:

أنا أحتاج HttpClient.

لكنها لا تقول:

استخدم هذا HTTP library.

وهذا هو جوهر Dependency Injection.


لماذا Dependency Injection مهمة هنا؟

لأن نفس game package يمكن تشغيله في أكثر من environment.

مثلًا:

Browser

Browser HTTP Client

أو:

Cloudflare Worker

Hono HTTP Client

فالـ game لا يحتاج يعرف الفرق.

وهذا يقلل coupling.

والـ architecture تصبح:

Game Logic

Contract

Implementation

بدل:

Game Logic

Infrastructure

ومن هنا ظهرت مشكلة الـ Circular Dependencies

وهنا دخلت في واحدة من أكثر الأشياء التي أخذت مني وقتًا في المشروع:

dependency direction.

في البداية كان عندي imports تتحرك في اتجاهات غير صح.

مثلًا:

API

Game

والـ Game يحتاج:

API Client

ثم الـ API Client يحتاج types من API.

وفجأة تبدأ:

A → B → C → A

وهذه هي الـ circular dependency.

المشكلة في circular dependencies أنها أحيانًا لا تظهر كخطأ واضح مباشرة.

لكن تبدأ تؤثر على:

فقررت أن dependency graph نفسها يجب أن تصبح جزءًا من architecture.


Shared Schemas

واحدة من الحلول كانت نقل الـ schemas المشتركة إلى package مستقل:

@playgrid/shared

مثلًا بدل أن API يستورد schema من game package:

import { baseQuestionSchema } from '@playgrid/five-seconds';

أصبح:

import { baseQuestionSchema } from '@playgrid/shared';

والـ game package يستطيع إعادة تصديره إذا احتاج.

النتيجة:

API ────────┐

         shared


Game ───────┘

بدل:

API → Game

وهذا فرق مهم جدًا.


الـ API Contracts

نفس الفلسفة طبقتها على API client.

بدل أن الـ API client يعتمد مباشرة على source code الخاص بالـ API:

api-client → api source

صار عندي:

api

api-contracts

api-client

الـ api-contracts package يحتوي على الـ generated/compiled contract types.

وهكذا الـ client لا يحتاج أن يدخل داخل implementation تفاصيل الـ API.

هذه نقطة صغيرة في ظاهرها، لكنها مهمة جدًا عندما يصبح monorepo كبيرًا.


وهنا اكتشفت أن الـ Monorepo نفسه جزء من النظام

في البداية كنت أفكر أن monorepo مجرد:

مكان أحط فيه packages.

لكن مع الوقت بدأت أشوفه كـ architecture boundary.

صار عندي layers واضحة:

Applications

Packages

Shared

والـ dependency direction يجب أن تكون downward.

مثلًا:

Frontend

Game Packages

Game Core

Shared

والـ API:

API

API Contracts

Shared

والأهم:

لا cycles.


Game Core: أهم boundary في المشروع

مع كثرة الـ abstractions، كان هناك خطر آخر:

أن game-core يتحول إلى package يحتوي كل شيء.

وهذا شيء يحدث كثيرًا في المشاريع الكبيرة.

تبدأ بـ:

خلنا نحط هذا في core لأنه reusable.

ثم:

وهذا ممكن لعبة ثانية تحتاجه.

ثم:

وهذا helper بسيط.

ثم فجأة يصبح core يعرف تفاصيل كل الألعاب.

وهنا انتهيت إلى constraint واضح:

game-core يجب أن يكون game-agnostic.

يعني لا يعرف:

Five Seconds
Guess Logo
Questions
Logos
Sports

بل يعرف concepts عامة مثل:

Player
Turn
Phase
Session
Action
GameState
GameDefinition

الفرق بين Platform Logic و Game Logic

هذه من أكثر الأفكار التي أصبحت واضحة لي أثناء المشروع.

السؤال الذي بدأت أستخدمه:

هل هذه المعلومة تصف “كيف تعمل المنصة”، أم “ماذا تفعل اللعبة”؟

مثلًا:

Player roster

هذا platform concept.

أي لعبة multiplayer تحتاج players.

إذن:

game-core

لكن:

Five Seconds timer

هذا game-specific.

لأن المنصة نفسها لا تحتاج تعرف ما هو Five Seconds.

إذن:

five-seconds package

ونفس الشيء:

Question fetching

ليس من مسؤولية game-core.

لأن ممكن اللعبة الثانية لا تستخدم questions أصلًا.


هذه القاعدة غيرت طريقة تفكيري

بدل ما أسأل:

هل هذا الكود reusable؟

صرت أسأل:

Reusable بالنسبة لمن؟

وهذه نقطة مهمة جدًا في architecture.

لأن abstraction ليست جيدة فقط لأنها reusable.

أحيانًا abstraction مبكرة تكون أسوأ من duplication.

الـ abstraction الصحي يجب أن يعكس boundary حقيقي.

وهذا سبب وجود قاعدة واضحة في PlayGrid:

game-core

Platform-level concerns

بينما:

games/*

Game-specific concerns

GameDefinition

عشان أقدر أضيف ألعاب جديدة بدون تعديل الـ core، كل لعبة تسجل نفسها من خلال GameDefinition.

الـ definition يحتوي على أشياء مثل:

Game ID
Version
Name
Description
Player limits
State Schema
Action Schema
Reducer
Initial State
Validator
Effect Handlers

وهذا يجعل اللعبة plugin تقريبًا بالنسبة للمنصة.

الـ platform يعرف:

عندي GameDefinition.

ولا يحتاج يعرف تفاصيل اللعبة نفسها.


وبالتالي إضافة لعبة جديدة تصبح واضحة

مثلًا:

packages/games/
    five-seconds/
    guess-logo/
    new-game/

كل game package مسؤول عن:

State
Actions
Reducer
Validation
Effects
Definition

بينما game-core يعطيها infrastructure.

وهذا separation يجعل إضافة الألعاب أسهل بكثير.


Data Pipelines

في مرحلة معينة، المشروع لم يعد فقط game state.

بدأت تظهر عندي data flows كثيرة.

مثلاً:

User Action

Reducer

Effect

HTTP

API

Database

Transform

Action

Reducer

وهذا فعليًا data pipeline.

والجميل في الموضوع أن الـ pipeline أصبحت قابلة للتتبع.

بدل ما يكون عندك:

fetch()
setState()
doSomething()

في نفس المكان.

صار عندك مراحل واضحة:

Action
→ Transition
→ Effect
→ External Data
→ Follow-up Action
→ Transition

وهذا جعل النظام أسهل للفهم والاختبار.


Data Fetching ليس Game State

أيضًا كان عندي فصل مهم في الـ frontend.

الـ game state شيء.

والـ server data شيء آخر.

لذلك يستخدم frontend أيضًا TanStack Query للـ data fetching والـ caching.

وهذا مهم لأن:

Game State

ليس بالضرورة:

Server Cache

اللعبة تحتاج state machine/state transitions.

لكن API data تحتاج caching، refetching، loading states، invalidation وغيرها.

فليس من المنطقي إجبار كل شيء على نفس abstraction.


UI لا يجب أن يعرف كل هذا

وأعتقد أن هذا من أكثر الأشياء التي نجحت في architecture.

الـ React component المفروض ما يعرف:

الـ component يعرف:

const state = game.getState();

await game.dispatch({
  type: "ANSWER",
  answer,
});

وبس.

هذا هو الهدف من abstraction.

ليس أن نخفي التعقيد لمجرد الإخفاء.

بل أن نخلي كل layer يتعامل مع complexity التي تخصه فقط.


Optimistic Updates و Reconciliation

في multiplayer، الموضوع يصبح أكثر تعقيدًا.

لأن المستخدم يريد feedback سريع.

لكن السيرفر هو authority.

فيمكن للـ client أن يعمل optimistic update، ثم عندما يصل authoritative state من السيرفر يعمل reconciliation.

بمعنى:

User Action

Optimistic Client Update

WebSocket

Server Validation

Server Reducer

Authoritative State

Client Reconciliation

وهنا الـ adapter هو المكان المناسب لهذه التفاصيل.

وليس React component.

وليس game reducer.

وهذا separation مهم جدًا.


Reconnection ليس Game Logic

مثلاً لو انقطع WebSocket.

اللعبة نفسها لا يجب أن تحتوي:

if (socket.closed) {
   reconnect();
}

لأن reconnect ليس rule في اللعبة.

هذا networking concern.

لذلك الـ multiplayer adapter يتعامل مع:

بينما اللعبة تظل مركزة على rules.


Persistence

نفس الشيء بالنسبة للـ persistence.

الـ reducer يقول:

State A + Action B = State C

ولا يعرف أين يتم حفظ State C.

الـ Durable Object هو الذي يتعامل مع persistence.

وهذا يعطيك separation جميل:

Game Logic

State Transition

Server Infrastructure

Persistence

وهذا مهم خصوصًا في serverless environments.


Unified Deployment

في النهاية، حتى deployment architecture أصبحت جزءًا من التصميم.

الـ frontend والـ API يتم نشرهما معًا كـ Cloudflare Worker واحد.

يعني:

Cloudflare Worker
├── Hono API
└── Frontend Assets

وهذا أعطاني فوائد عملية:

في production يصبح API communication مثل:

/api/...

بدل الحاجة إلى domain منفصل للـ API.


الـ Build Order أصبح مهمًا

لأن monorepo أصبح يحتوي dependencies واضحة، حتى build process صار له ترتيب.

مثلًا:

shared

game-core

api

api-contracts

games

frontend / api-client / admin

وهذا ليس مجرد optimization.

هو نتيجة مباشرة للـ dependency graph.

إذا كانت architecture صحيحة، فالـ build order غالبًا يصبح مفهومًا من dependency graph نفسه.


Testing

لما يكون عندك pure reducers، testing يصبح أسهل بكثير.

الـ unit tests تركز على:

Reducers
Validators
Pure utilities
Effect handlers

والـ effect handlers يمكن اختبارها باستخدام mocked HttpClient.

ثم integration tests تختبر:

API routes
Game state + effects
Frontend components

ثم E2E:

Play game
Answer questions
Multiplayer scenarios

والفكرة هنا أن كل مستوى يختبر شيء مختلف.

ما تحتاج E2E test عشان تتأكد أن reducer يزيد score من 5 إلى 10.

هذه مهمة unit test.


ما الذي تغير فعليًا من بداية المشروع؟

لو رجعت لأول يوم، كنت أفكر في المشروع تقريبًا كالتالي:

React

Game

ثم أصبح:

React

Game Adapter

Reducer

State

وفي multiplayer:

React

Multiplayer Adapter

WebSocket

Durable Object

Validation

Reducer

Effects

Persistence

Broadcast

ثم أضفت حوله:

Monorepo
Package boundaries
Shared schemas
API contracts
Dependency injection
Game registry
Testing strategy
Deployment architecture

يعني المشروع بدأ كـ game website.

لكن الـ problem نفسه أجبرني أبني platform.


أكثر شيء تعلمته: الـ requirements الصغيرة ممكن تخفي distributed system

الجملة التي بدأت منها كانت بسيطة جدًا:

“أبغى اللعبة تشتغل local و online.”

لكن هذه الجملة الصغيرة تحمل خلفها مجموعة مشاكل كبيرة:

Local execution
+
Remote execution
+
State ownership
+
Consistency
+
Validation
+
Networking
+
Reconnection
+
Persistence
+
Side effects
+
Code reuse

وهنا فهمت شيء مهم جدًا في software architecture:

أحيانًا الـ complexity لا تأتي من حجم المنتج، بل من الـ guarantees التي تريدها.

ممكن تبني لعبة بسيطة جدًا في ساعتين.

لكن إذا قلت:

نفس اللعبة يجب أن تعمل local و multiplayer، بنفس game logic، مع server authority، وreconnection، وpersistence.

أنت لم تعد تتعامل مع “لعبة بسيطة”.

أنت تتعامل مع distributed system مصغر.


هل architecture الحالية مثالية؟

لا.

وهذا مهم أقوله.

الـ architecture الحالية هي نتيجة تطور المشروع والـ problems التي واجهتها، وليست architecture مثالية نزلت من السماء من أول يوم.

وفيه أجزاء ما زالت تحت refactoring.

خصوصًا موضوع circular dependencies.

وهذا طبيعي.

في الواقع، أحد الأشياء التي أعتبرها علامة صحية في المشروع هو أن architecture نفسها ما زالت قابلة للنقد والتغيير.

الهدف ليس أن تقول:

“أنا بنيت architecture جميلة.”

الهدف:

“هل architecture هذه تجعل الـ next feature أسهل أم أصعب؟”

إذا إضافة لعبة جديدة تتطلب تعديل core في كل مرة، فالـ abstraction فاشلة.

إذا game-specific logic تسرب إلى infrastructure، فالـ boundary فاشل.

إذا UI بدأ يعرف تفاصيل WebSocket، فالـ adapter abstraction فاشلة.

إذا client يستطيع فرض state على السيرفر، فالـ authority model فاشل.

هذه هي المعايير التي أصبحت أراجع بها التصميم.


في النهاية، المشروع علمني أن الـ Architecture تتبع الـ Problems

أكثر شيء funny في الموضوع أني ما جلست من البداية وقلت:

اليوم سأبني pure reducer architecture مع adapters وDurable Objects وdependency injection وeffect system.

😂

أنا فقط كنت أحاول أخلي:

لعبة واحدة تشتغل local وonline.

ثم كل decision فتح مشكلة ثانية.

المشكلة الأولى:

كيف أشغل نفس logic في مكانين؟

أدت إلى:

Pure Reducer

ثم:

كيف أبدل بين local وmultiplayer؟

أدت إلى:

Game Adapter

ثم:

من يملك state في multiplayer؟

أدت إلى:

Server Authority

ثم:

كيف يدير السيرفر room حقيقي؟

أدت إلى:

Durable Objects

ثم:

كيف أتعامل مع fetch بدون ما أخرب pure reducer؟

أدت إلى:

Effects

ثم:

كيف أخلي game package لا يعتمد على infrastructure؟

أدت إلى:

Dependency Injection

ثم:

كيف أمنع circular dependencies؟

أدت إلى:

Shared Packages
API Contracts
Clear Dependency Directions

ثم:

كيف أخلي core لا يتحول إلى garbage drawer لكل الألعاب؟

أدت إلى:

Game-Agnostic Core
Game-Specific Packages

وفي النهاية صار عندي architecture تقريبًا بهذا الشكل:

                ┌──────────────────────┐
                    │       Frontend       │
                    │        React         │
                    └──────────┬───────────┘


                    ┌──────────────────────┐
                    │     Game Adapter     │
                    └───────┬───────┬──────┘
                            │       │
                     Local  │       │ Multiplayer
                            │       │
                            ▼       ▼
                       Reducer   WebSocket
                            │       │
                            │       ▼
                            │   Durable Object
                            │       │
                            │   Validation
                            │       │
                            │    Reducer
                            │       │
                            │     Effects
                            │       │
                            │   Persistence
                            │       │
                            │   Broadcast
                            │       │
                            └───────┘

والجزء الجميل أن الـ reducer نفسه موجود في المنتصف.

لا يعرف عن React.

ولا يعرف عن WebSocket.

ولا يعرف عن Cloudflare.

ولا يعرف عن Zustand.

ولا يعرف عن HTTP.

هو فقط يعرف:

State + Action → New State

وكل التعقيد حوله هو infrastructure تجعل هذا الـ logic يعيش في environments مختلفة.


من UI إلى Platform

يمكن هذا هو أفضل وصف لرحلة PlayGrid بالنسبة لي.

بدأت بـ:

UI
+
Game
+
fetch()

وانتهيت بـ:

Pure Game Logic
+
Adapters
+
Server Authority
+
WebSockets
+
Durable Objects
+
Effects
+
Dependency Injection
+
Shared Contracts
+
Monorepo Boundaries
+
Data Pipelines

والأغرب؟

إني ما كنت أحاول أبني كل هذا من البداية.

الـ requirements هي التي سحبتني إليه.

وهذا بالنسبة لي من أكثر الأشياء الممتعة في بناء software systems.

أحيانًا تبدأ بفكرة تبدو صغيرة جدًا:

“أبغى لعبة.”

ثم تضيف requirement واحدة:

“بس أبغاها online.”

ثم requirement ثانية:

“بس نفس الكود لازم يشتغل local.”

ثم:

“وبس أبغا أكثر من لعبة.”

وفجأة تجد نفسك تناقش:

state ownership, distributed systems, dependency inversion, deterministic reducers, consistency, persistence, and architectural boundaries.

وهنا عرفت أن أصعب جزء في المشروع لم يكن بناء اللعبة نفسها.

أصعب جزء كان أن أبني نظامًا يسمح لي ببناء ألعاب كثيرة، بدون أن تصبح كل لعبة سببًا في تكسير النظام الذي تحتها.

وهذا في النهاية هو الشيء الذي أحاول أوصل له في PlayGrid:

Write the game once. Let the platform decide where and how it runs.

ويمكن هذه كانت أكثر رحلة “ما كنت مخطط لها” تعلمت منها في المشروع كله.