A multiplayer racing game with real-time physics and WebSocket networking.
# Copy environment file
cp .env.example .env
# Build and start (development)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
# Build and start (production)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --buildThe game will be available at: http://localhost/race/
# Start containers (development)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
# Start containers (production, detached)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
# Stop containers
docker compose down
# Stop and remove volumes
docker compose down -v
# View logs
docker compose logs -f
# Rebuild without cache
docker compose build --no-cache
# Check container status
docker compose ps| Variable | Default | Description |
|---|---|---|
APP_PORT |
80 |
Port to expose on host |
VITE_BASE_PATH |
/race/ |
Base path for client assets |
BASE_PATH |
/race/ |
Base path in nginx config |
IMAGE_NAME |
vector-racer |
Docker image name |
IMAGE_TAG |
latest |
Docker image tag |
To serve the game from a different path (e.g., /game/):
-
Update
.env:VITE_BASE_PATH=/game/ BASE_PATH=/game/ -
Update
nginx/nginx.confto replace/race/with/game/ -
Rebuild:
docker compose up --build
┌─────────────────────────────────────────────────────┐
│ Docker Container │
│ ┌───────────────────────────────────────────────┐ │
│ │ Nginx │ │
│ │ - Serves static files at /race/ │ │
│ │ - Proxies WebSocket /race/ws → gameserver │ │
│ │ - Proxies /race/health, /race/stats │ │
│ └───────────────────┬───────────────────────────┘ │
│ │ │
│ ┌───────────────────▼───────────────────────────┐ │
│ │ Go Game Server │ │
│ │ - WebSocket connections │ │
│ │ - Server-authoritative physics (60 Hz) │ │
│ │ - Room/matchmaking management │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
- Node.js 18+
- Go 1.21+
cd client
npm install
npm run dev # Runs on http://localhost:3000cd server
go run ./cmd/gameserver # Runs on http://localhost:8080| Endpoint | Description |
|---|---|
GET /race/ |
Game client (static) |
WS /race/ws |
WebSocket game connection |
GET /race/health |
Health check |
GET /race/stats |
Server statistics |
- Client: TypeScript, Vite, Canvas 2D
- Server: Go, Gorilla WebSocket
- Deployment: Docker, Nginx, Supervisor
This section explains the game's architecture for developers who want to understand or modify the codebase.
Vector Racer is a server-authoritative multiplayer racing game. The server runs the physics simulation and broadcasts state to all clients. Clients send their inputs to the server and render the game state they receive back.
┌─────────────┐ WebSocket ┌─────────────┐
│ Client │ ◄─────────────────────────►│ Server │
│ │ Binary messages │ │
│ - Renders │ (see Protocol below) │ - Physics │
│ - Input │ │ - Rooms │
│ - Predict │ │ - Validate │
└─────────────┘ └─────────────┘
The server runs two separate loops:
- Physics Loop (60 Hz) - Updates player positions, handles collisions, validates movement
- Broadcast Loop (20 Hz) - Sends game state to all connected clients
// From server/internal/game/room.go
physicsTicker := time.NewTicker(time.Second / 60) // 60 Hz
broadcastTicker := time.NewTicker(time.Second / 20) // 20 HzThe client runs its own render loop at the display's refresh rate (typically 60 Hz) and interpolates between received server states for smooth rendering.
The game uses a custom binary protocol over WebSocket for efficiency. Each message starts with a 1-byte message type:
| Type | Name | Direction | Description |
|---|---|---|---|
0x01 |
JoinRoom | Client -> Server | Request to join a room |
0x02 |
LeaveRoom | Client -> Server | Leave current room |
0x03 |
Input | Client -> Server | Player controls (steering, throttle) |
0x04 |
Ping | Client -> Server | Latency measurement |
0x10 |
RoomInfo | Server -> Client | Room assignment confirmation |
0x11 |
StateUpdate | Server -> Client | All players' positions/states |
0x12 |
PlayerJoin | Server -> Client | New player joined |
0x13 |
PlayerLeave | Server -> Client | Player left |
0x14 |
Pong | Server -> Client | Ping response |
0x15 |
Error | Server -> Client | Error message |
Example: StateUpdate message structure
[0x11][tick:2][player_count:1][player_data:N*16]
Each player_data (16 bytes):
[id:2][x:4][y:4][speed:2][angle:2][rating:1][flags:1]
Players are organized into rooms. Each room:
- Has its own physics simulation
- Supports up to 10 players (configurable)
- Auto-starts when first player joins
- Auto-cleans up when empty (30-second timer)
// From server/internal/matchmaker/matchmaker.go
func (m *Matchmaker) FindRoom() *game.Room {
// 1. Find existing room with space
// 2. If none found, create new room
// 3. Start room's game loop
}The physics engine handles:
- Vehicle Movement - Acceleration, braking, steering
- Road Boundaries - Players are constrained to the curved road
- Collisions - Player-to-player collision detection and response
- Spatial Partitioning - Grid-based optimization for collision checks
// From server/internal/game/physics.go
func (p *Physics) UpdatePlayer(player *Player, dt float64) {
// Apply throttle/brake
// Apply steering (turning)
// Update position
// Clamp to road boundaries
}The server validates all player actions:
- Input Rate Limiting - Max inputs per tick to prevent flooding
- Speed Validation - Detects impossible speeds (speed hacks)
- Position Validation - Detects teleportation hacks
- Correction/Kick - Invalid players are corrected or kicked
// From server/internal/game/anticheat.go
result := r.antiCheat.ValidatePlayerMovement(p, dt)
if result == ValidationKick {
r.kickPlayer(p, "Speed hack detected")
}The server uses Go's sync.RWMutex for thread safety. Key patterns:
// Room struct
type Room struct {
mu sync.RWMutex // Protects players map
players map[uint16]*Player
// ...
}
// Reading players (multiple readers allowed)
r.mu.RLock()
defer r.mu.RUnlock()
// Modifying players (exclusive access)
r.mu.Lock()
defer r.mu.Unlock()CRITICAL: Go's RWMutex is not reentrant. You cannot call RLock() while holding Lock() in the same goroutine. Methods ending in Unlocked expect the caller to already hold the lock.
The client is organized into modules:
client/src/
├── main.ts # Entry point, game loop
├── network.ts # WebSocket connection, protocol
├── game.ts # Game state management
├── render.ts # Canvas 2D rendering
├── input.ts # Keyboard/touch controls
├── physics.ts # Client-side prediction
└── config.ts # Game constants
Client-Side Prediction: The client runs simplified physics locally to make controls feel responsive, then reconciles with authoritative server state.
- Client loads page, establishes WebSocket connection
- User clicks "Join" -> Client sends
JoinRoommessage - Server assigns player to room -> sends
RoomInfowith player ID - Server broadcasts
PlayerJointo other players in room - Game loop: Client sends
Input, Server broadcastsStateUpdate - On disconnect: Server broadcasts
PlayerLeave, cleans up player
server/
├── cmd/gameserver/main.go # Entry point, WebSocket handler
├── config/ # Game constants
└── internal/
├── game/
│ ├── room.go # Room management, game loop
│ ├── player.go # Player state
│ ├── physics.go # Physics simulation
│ ├── anticheat.go # Validation
│ └── spatial.go # Collision optimization
├── matchmaker/ # Room assignment
└── network/ # Binary protocol
client/
├── src/ # TypeScript source
├── public/ # Static assets
└── vite.config.ts # Build configuration
- Fork the repository
- Create a feature branch
- Make your changes
- Test with
docker compose up --build - Submit a pull request
MIT