Fast, Redis-powered multiplayer backend for a dots-and-boxes style game with matchmaking, friend requests, and live score tracking.
- Overview
- Features
- Architecture
- Project Structure
- Tech Stack
- Prerequisites
- Setup
- HTTP API Reference
- Real-Time Socket API
- Game Flow
- Redis Data Model
- Development Notes
- Contributing
- Roadmap Ideas
- License
This repository provides the backend for a real-time, turn-based grid game inspired by classic dots-and-boxes. Players can discover each other, exchange friend requests, negotiate grid sizes, and battle for territory. The service exposes a lightweight HTTP API for basic user utilities and leverages Socket.io for high-frequency, low-latency gameplay updates. Redis is used as the primary data store for player presence, match state, and transient social interactions.
- Zero-latency gameplay powered by Socket.io with per-room broadcasts.
- Presence tracking so players can see who is online in real time.
- Friend request workflow with automatic expiration handling.
- Match orchestration including grid size voting, turn management, and scoring.
- Graceful reconnects by persisting session-bound room assignments in Redis.
- Simple HTTP utilities for availability checks and dashboards.
- Docker-ready deployment for consistent production rollouts.
- Express (HTTP) handles REST endpoints, JSON parsing, and middleware.
- Socket.io (WebSockets) coordinates the interactive gameplay loop.
- Redis stores user sessions, online presence rosters, friend requests, game rooms, and serialized game state objects.
- UUID supplies unique room identifiers for each head-to-head match.
At runtime the backend starts an http.Server, binds Socket.io to it, and reuses Redis for both stateless lookup and small, fast game-state snapshots.
.
├── Dockerfile
├── package.json
├── package-lock.json
├── index.js # Alternate entry point for container builds
└── src
├── app.js # Express app + Socket.io game logic
├── index.js # Default entry point (loads env, boots Redis + HTTP server)
├── controllers
│ └── user.controller.js
├── routes
│ └── user.route.js
└── utils
├── ApiError.js
├── ApiResponse.js
└── asyncHandler.js
Heads up: The Dockerfile copies the top-level
index.jsintosrc/index.jsinside the image, so containerized deployments use the production connection settings defined in that file. Local development typically runs fromsrc/index.js.
- Node.js 20+
- Express 5
- Socket.io 4
- Redis 5+
- UUID 11
- Docker (optional)
- Node.js v20 or later (matches the
node:20-alpinebase image) - npm v10 or later
- Redis server reachable from the backend (local instance or managed service)
Clone the repository and install dependencies:
git clone https://github.com/<your-org>/real-time-grid-clash.git
cd real-time-grid-clash
npm installCreate a .env file in the project root and provide the values below:
PORT=9000
REDIS_HOST=127.0.0.1
REDIS_PORT=6379| Variable | Default | Description |
|---|---|---|
PORT |
9000 |
HTTP and Socket.io port for local development |
REDIS_HOST |
127.0.0.1 |
Redis host name or IP address |
REDIS_PORT |
6379 |
Redis TCP port |
When using Docker you can pass overrides via
docker run -e, or rely on the defaults baked into the image.
Start Redis (if not already running):
redis-serverBoot the backend:
node src/index.jsExpected logs:
Connected to Redis successfully
Server is running on port 9000
You can enable file watching with native Node.js tooling:
node --watch src/index.jsBuild the image:
docker build -t grid-clash-backend .Run the container, pointing it at your Redis host:
docker run \
-e PORT=9000 \
-e REDIS_HOST=host.docker.internal \
-e REDIS_PORT=6379 \
-p 9000:9000 \
grid-clash-backendBase URL: http://localhost:{PORT} (defaults to http://localhost:9000). All responses are JSON.
Health-check endpoint that returns the string "hello world" to confirm the server is alive.
Returns the set of users currently online (players who have emitted the join Socket event within the last 30 minutes).
- Response
200
[
{
"socketId": "vYKk3...",
"username": "alex",
"sessionId": "81f2d5eb-..."
}
]Checks whether a username is already in use by any active session stored in Redis.
- Request Body
{
"username": "alex"
}- Response
200
{
"message": "Username is available"
}- Response
400– missingusernamefield. - Response
404– username is already claimed by an active session.
Fetches pending friend requests (sent and received) tied to the caller’s session.
- Request Body
{
"sessionId": "81f2d5eb-..."
}- Response
200
[
{ "from": "me", "to": "ec92462f-..." },
{ "from": "d9a3f9f0-...", "to": "me" }
]Requests expire automatically after five minutes thanks to Redis
EXrules.
The backend uses Socket.io. Clients must provide a sessionId during the handshake:
const socket = io("http://localhost:9000", {
auth: { sessionId }
});If the sessionId is missing or empty, the connection is rejected.
| Event | Payload | Purpose |
|---|---|---|
join |
username: string |
Registers the player, refreshes their session TTL (30 minutes), and broadcasts an updated online roster. |
sendFriendRequest |
toSessionId: string |
Queues a friend request for another online player (expires after five minutes). |
friendRequestAccepted |
toSessionId: string |
Creates a room, seeds game state, and notifies both players that the match has begun. |
checkActiveRoom |
none | Rejoins the caller to any in-progress match and replays state snapshots. |
joinGameRoom |
roomId: string |
Manually join a room and receive the latest game state. |
selectGridSize |
{ roomId, gridSize, player } |
One-time selection of the board dimension (allowed values: 4, 5, 6, 7, 8). |
makeMove |
{ roomId, from, to, player } |
Draws an edge between adjacent dots. Validates duplicates, updates scores, flips turns, and announces completions. |
leaveGame |
none | Leaves the active match, clears matchmaking keys, and notifies the opponent. |
leave |
none | Clears presence and request keys for the caller (use on manual logout). |
The from and to coordinates in makeMove are objects of the form { row: number, col: number } and must be adjacent.
| Event | Payload | Trigger |
|---|---|---|
onlineUsers |
Array<PlayerSummary> |
Broadcast whenever the online roster changes. |
receiveFriendRequest |
{ from: sessionId, to: "me" } |
Fired to the targeted player when someone requests a match. |
gameStart |
GameState |
Emitted when a friend request is accepted and a room initializes. |
activeRoom |
roomId or null |
Response to checkActiveRoom showing current assignment. |
playerRoleAssigned |
"player1" or "player2" |
Sent after room join so the client knows its turn order. |
gameStateUpdate |
GameState |
Emitted after checkActiveRoom or joinGameRoom to sync the latest state. |
gridSizeSelected |
GameState |
Broadcast after the board size is locked in. |
connectionMade |
GameState |
Emitted after a valid move when the game continues. |
gameFinished |
GameState |
Emitted when all squares are claimed, including winner information. |
userLeft |
sessionId |
Broadcast when a player leaves the room mid-match. |
error |
message: string |
Sent when validation fails (invalid move, bad grid size, etc.). |
The serialized GameState object stored in Redis and emitted via Socket.io looks like:
{
"roomId": "f0db0c1a-...",
"connections": [
{
"from": { "row": 0, "col": 0 },
"to": { "row": 0, "col": 1 },
"player": "player1",
"timestamp": 1735579200000
}
],
"completedSquares": [
{
"topLeft": { "row": 1, "col": 1 },
"completedAt": 1735579212345,
"player": "player2"
}
],
"currentPlayer": "player2",
"scores": { "player1": 1, "player2": 2 },
"gameStatus": "playing",
"players": {
"player1": { "id": "81f2d5eb-...", "name": "alex", "connected": true },
"player2": { "id": "662c4b13-...", "name": "sam", "connected": true }
},
"gridSize": 6,
"gridSelectedBy": "player1",
"createdAt": 1735579187654,
"lastMove": 1735579212345
}- Players connect with a
sessionIdand emitjoinwith their display name. - The lobby UI listens to
onlineUsersto show available opponents. - A challenger emits
sendFriendRequest; the recipient receivesreceiveFriendRequestand answers withfriendRequestAccepted. - The server provisions a room, creates and stores the initial
GameState, and emitsgameStart. - One player selects a grid size via
selectGridSize, switching the match intoplayingmode. - Players alternate calling
makeMove; when a square is completed the mover scores and keeps the turn. - When all squares are claimed the server emits
gameFinishedwith the winner; leaving early triggersuserLeft.
| Key Pattern | Type | TTL | Contents |
|---|---|---|---|
user:<sessionId> |
string | 30 minutes | JSON blob containing { socketId, username, sessionId }. |
onlineUser:<sessionId> |
string | 30 minutes | Mirror of the user object used for lobby listings. |
friendRequests:sent:<sessionId> |
set | 5 minutes | Serialized objects tracking outgoing friend requests. |
friendRequests:received:<sessionId> |
set | 5 minutes | Serialized objects tracking incoming friend requests. |
activeUser:<sessionId> |
string | 60 minutes | Room ID for the player’s ongoing match. |
gameState:<roomId> |
string | 60 minutes | Serialized GameState JSON for the active match. |
room:<roomId> |
hash | 60 minutes | Hash fields: players (JSON array) and status. |
asyncHandlerwraps controllers so uncaught async errors propagate to Express error middleware.ApiErrorandApiResponseutilities exist insrc/utils/and can be adopted for richer HTTP responses.- The project currently lacks a logging abstraction; consider integrating
pinoorwinstonfor production readiness. - Rate limiting and authentication are intentionally minimal to keep onboarding simple; add guards before going to production.
We welcome contributions of all sizes! To get started:
- Fork the repository and create a feature branch (
git checkout -b feat/<short-name>). - Make your changes with clear, small commits.
- Add or update documentation and tests when applicable.
- Ensure the server boots locally (
node src/index.js) and that linting (if added) passes. - Submit a pull request describing the problem, solution, and any follow-up ideas.
Please follow conventional commit messages where possible (e.g., feat: add rematch endpoint).
- Add a REST endpoint for retrieving historical match results.
- Implement authentication and persistent user profiles.
- Support spectating and broadcasting finished match summaries.
- Add unit and integration tests (Jest + supertest + socket.io testing library).
- Expose Prometheus-ready metrics for observability.
This project is open-source under the ISC License. See Open Source Initiative for license text, or add a dedicated LICENSE file when publishing.