السلام عليكم ورحمة الله وبركاته
بدأت المشروع من أبسط شيء: مجرد UI ولعبة بسيطة وfetch data.
إلى أن مريت على realtime systems، إلى إني بنيت Redux-like system مع Redux Saga بدون ما أحس، إلى أن سويت data pipelines تجمع بيانات من كل مكان.
من بدأت المشروع كنت أحاول أقلد شيء زي neal.fun؛ موقع فيه أكثر من لعبة، وكل لعبة بسيطة وممتعة وتشتغل مباشرة في المتصفح.
لكن الشيء اللي كنت أبغاه مختلف شوي.
كنت أبغى كل لعبة في الموقع تقدر تشتغل بطريقتين:
LocalOnline Multiplayer
والمستخدم هو اللي يختار.
وفوق هذا، كنت أبغى يكون عندي عدد كبير من الألعاب، بدون ما أضطر أبني infrastructure جديدة لكل لعبة.
فكان الطموح العالي جدًا:
أبغى أكتب الـ game logic مرة وحدة، ونفس الكود يشتغل في المتصفح في local mode، ويشتغل أيضًا على WebSocket server في multiplayer mode.
والفكرة ذي دمرتني 😁.
لأنها في البداية تبدو بسيطة جدًا.
تقول: “ليش ما نخلي نفس الـ state ونرسل updates عن طريق WebSocket؟”
لكن بمجرد ما تبدأ تسأل أسئلة مثل:
- من هو مصدر الحقيقة؟
- وين يعيش الـ state؟
- مين يقرر إذا الـ action صحيح؟
- وش يصير لو لاعبين ضغطوا في نفس اللحظة؟
- كيف أتعامل مع latency؟
- كيف أسوي optimistic updates؟
- كيف أصالح client state مع server state؟
- وش يصير إذا انقطع الاتصال؟
- كيف أرجع اللاعب لنفس الـ session؟
- كيف أخلي نفس الـ reducer يشتغل محليًا وعلى السيرفر؟
- وكيف أتعامل مع الـ side effects؟
- ومن المسؤول عن
fetch؟ - وهل الـ game logic أصلًا المفروض يعرف أن فيه HTTP أو 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 تستطيع أن تفصل:
- state
- actions
- reducers
- networking
- persistence
- effects
- validation
- reconciliation
- UI
وفي النهاية وصلت إلى نموذج أبسط وأكثر 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
بدل ما أحتاج:
- browser
- WebSocket server
- database
- API
عشان أختبر 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 عنده:
- state
- players
- connections
- lifecycle
- actions
- persistence
وهذا يناسب جدًا نموذج Cloudflare Durable Objects، خصوصًا أنها مبنية على Actor Model بشكل مباشر، حيث كل object يمثل actor مستقل يملك state خاص به ويتعامل مع messages بشكل متسلسل.
فأصبح عندي concept:
Game Room
↓
Durable Object (Actor Model)
والـ Durable Object مسؤول عن:
- حفظ game state
- استقبال WebSocket connections
- validation
- تنفيذ reducer
- تشغيل effects
- broadcast
- reconnection
- persistence
وبالتالي:
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 أنها أحيانًا لا تظهر كخطأ واضح مباشرة.
لكن تبدأ تؤثر على:
- build order
- module initialization
- type generation
- package boundaries
- testing
- deployment
فقررت أن 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 المفروض ما يعرف:
- هل اللعبة local؟
- هل هي multiplayer؟
- هل يوجد WebSocket؟
- هل state موجود في Zustand؟
- هل server authoritative؟
- كيف تتم reconciliation؟
- أين يعيش الـ Durable Object؟
الـ 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 يتعامل مع:
- connection
- reconnection
- session rehydration
- server state
- optimistic updates
- reconciliation
بينما اللعبة تظل مركزة على 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
وهذا أعطاني فوائد عملية:
- domain واحد
- relative URLs
- لا تحتاج CORS بين frontend و API في production
- deployment أبسط
في 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.
ويمكن هذه كانت أكثر رحلة “ما كنت مخطط لها” تعلمت منها في المشروع كله.