Multiplayer Connect Four in Go
Build a concurrent WebSocket game server with Prometheus observability.
Introduction
30 Second Summary
Think about every online game you have played with a friend. Someone has to track whose turn it is, reject illegal moves, and handle what happens when a player's connection drops mid-game. That invisible referee is a distributed system.
In this project, you will build a multiplayer Connect Four game server in Go that manages real-time gameplay over WebSocket connections, instruments the system with Prometheus metrics, and visualizes everything in Grafana.
What You'll Build
A local multiplayer Connect Four platform where two browser tabs connect to your Go WebSocket server for real-time gameplay, with full observability through a Docker Compose stack.
By the end of this project, you'll have:
- A Go WebSocket game server with matchmaking, concurrent game rooms, and structured logging.
- A browser-based client that renders the Connect Four board and communicates with the server in real time.
- A Prometheus and Grafana observability stack showing live metrics like active games, connected players, and move latency.
- Secret Mission: Add disconnect/reconnect handling with a 30-second grace period so a player who accidentally closes their tab can rejoin.
Are there any prerequisites?
This project assumes intermediate Go experience. You should be comfortable writing HTTP servers and understand goroutines and channels.
You should also have basic Docker Compose familiarity. If you can run docker compose up, you are good to go.
Set Up the Project Scaffold
A multiplayer game server needs more than just game logic. It needs an HTTP layer to accept connections, a well-organized codebase to keep concerns separated, and an observability stack to see what's happening at runtime.
In this step, you'll scaffold the entire project structure, spin up a minimal Go server with a health check endpoint, and launch Prometheus and Grafana locally via Docker Compose. By the end, you'll have a live monitoring stack ready to scrape your server.
In this step, get ready to:
- Initialize a Go module with WebSocket and Prometheus dependencies.
- Create the project directory structure and a minimal HTTP server.
- Launch Prometheus and Grafana via Docker Compose.
Initialize the Go module and install dependencies
Every Go project starts with a module. The module declaration gives your project its import path and tracks all external dependencies.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key, both macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir connectfour
cd connectfour
- Confirm the folder exists by running:
ls
You should see the connectfour folder listed in the output.
- Verify your Go installation by running:
go version
You should see go1.26 (or a patch like go1.26.4) in the output.
Don't see Go 1.26?
If you see command not found, Go is not installed or not in your PATH. Download Go 1.26 from the official site at https://go.dev/dl/ and follow the installer instructions for your OS.
If you see a version lower than 1.26, upgrade by downloading the latest installer from the same page.
help me troubleshoot my Go installation
- Initialize your Go module by running:
go mod init connectfour
What does this command do?
This creates a go.mod file that declares your module name (connectfour) and tracks which Go version and external packages your project uses.
- Install the WebSocket library by running:
go get github.com/coder/websocket@v1.8.14
- Install the Prometheus client library by running:
go get github.com/prometheus/client_golang@v1.23.2
Why these two libraries?
The coder/websocket library handles WebSocket connections with built-in concurrency safety and context.Context support. It replaced the archived gorilla/websocket library.
The prometheus/client_golang library lets you register custom metrics (gauges, histograms) and expose them on an HTTP endpoint for Prometheus to scrape.
- Verify both packages were added by running:
cat go.mod
You should see github.com/coder/websocket and github.com/prometheus/client_golang listed in the require section.
Seeing a network error?
Make sure you have an active internet connection. The go get command downloads packages from the Go module proxy.
If you see a timeout, try again. Temporary network issues can interrupt the download.
help me fix my go get error
Create the directory structure and health check server
Go projects use a conventional layout to separate concerns. You'll put the server entry point in cmd/server/, internal packages in internal/, and static web assets in web/.
- Create all the project directories by running:
mkdir -p cmd/server internal/game internal/hub internal/metrics web
- Confirm the directories were created by running:
ls -R
You should see cmd/server, internal/game, internal/hub, internal/metrics, and web in the output.
- Open the connectfour folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Select the connectfour folder on your Desktop, then click Open.
VS Code reloads with your project as the workspace. You should see the directory tree in the Explorer sidebar on the left.
- Create the main server file by running this in your VS Code terminal:
touch cmd/server/main.go
- Click on cmd/server/main.go in the Explorer sidebar to open it in the editor.
- Paste the following code into main.go:
package main
import (
"net/http"
"os"
)
func main() {
// Create a new HTTP request multiplexer
mux := http.NewServeMux()
// Register a health check endpoint
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
// Start the HTTP server on port 8080
http.ListenAndServe(":8080", mux)
_ = os.Stdout
}
What does this code do?
This creates a minimal HTTP server that listens on port 8080. The /healthz endpoint responds with ok to confirm the server is alive.
Health check endpoints are standard in production services. Monitoring tools (like Prometheus) and orchestrators (like Kubernetes) use them to verify a service is responding.
- Save cmd/server/main.go.
- Test the server by running this in your terminal:
go run cmd/server/main.go
The terminal will appear to hang. That means the server is running and waiting for requests.
- Open a second terminal by clicking the + icon in the terminal panel, then test the health check:
curl http://localhost:8080/healthz
You should see ok printed in the terminal.
- Switch back to your first terminal and press Ctrl+C to stop the server.
Seeing 'port already in use'?
Another process is using port 8080. Find and kill it, or check if you have another copy of this server already running in a different terminal tab.
help me fix a port conflict on 8080
✔️ Awesome, I've got everything!
Great. Make sure you've saved cmd/server/main.go and stopped the server before continuing.
ⓧ I'd like to double check the full code
Here is the complete cmd/server/main.go for this step:
package main
import (
"net/http"
"os"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
http.ListenAndServe(":8080", mux)
_ = os.Stdout
}
Launch the observability stack with Docker Compose
Prometheus scrapes metrics from your server at a regular interval. Grafana connects to Prometheus and lets you build dashboards to visualize those metrics. You'll run both as Docker containers.
- Create the Docker Compose file by running:
touch docker-compose.yml
- Click on docker-compose.yml in the Explorer sidebar to open it.
- Paste the following configuration:
services:
prometheus:
image: prom/prometheus:v3.12.0
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
extra_hosts:
- "host.docker.internal:host-gateway"
grafana:
image: grafana/grafana:13.0.2
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
What does this configuration do?
This defines two services. Prometheus runs on port 9090 and mounts a local config file that tells it where to scrape metrics from.
Grafana runs on port 3000 and provides a web UI for building dashboards. The extra_hosts setting lets the Prometheus container reach your Go server running on the host machine.
- Save docker-compose.yml.
- Create the Prometheus configuration file by running:
touch prometheus.yml
- Click on prometheus.yml in the Explorer sidebar to open it.
- Paste the following configuration:
global:
scrape_interval: 5s
scrape_configs:
- job_name: "connectfour"
static_configs:
- targets: ["host.docker.internal:8080"]
What does this configuration do?
This tells Prometheus to scrape your game server's /metrics endpoint every 5 seconds. The target host.docker.internal:8080 resolves to your host machine's port 8080 from inside the Docker container.
- Save prometheus.yml.
- Start the observability stack by running:
docker compose up -d
What does this command do?
The -d flag runs the containers in the background (detached mode). Docker pulls the Prometheus and Grafana images if they aren't cached locally, then starts both services.
- Verify the containers are running:
docker compose ps
You should see both prometheus and grafana listed with a status of Up.
- Open your browser and navigate to http://localhost:3000.
- Log in with username admin and password admin.
- If prompted to change the password, click Skip for now.
You should see the Grafana home dashboard. This confirms your observability stack is running.
Can't reach localhost:3000?
Make sure Docker Desktop is running. Check docker compose ps to verify both containers are up.
If a container shows as Exited, check its logs with docker compose logs grafana to see what went wrong.
help me troubleshoot my Docker Compose stack
✔️ Awesome, I've got everything!
Great. Make sure both docker-compose.yml and prometheus.yml are saved.
ⓧ I'd like to double check the full code
Here is the complete docker-compose.yml:
services:
prometheus:
image: prom/prometheus:v3.12.0
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
extra_hosts:
- "host.docker.internal:host-gateway"
grafana:
image: grafana/grafana:13.0.2
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
And here is the complete prometheus.yml:
global:
scrape_interval: 5s
scrape_configs:
- job_name: "connectfour"
static_configs:
- targets: ["host.docker.internal:8080"]
Your scaffold is complete. You have a Go module with dependencies, a working HTTP server, and a monitoring stack watching it. Next up, you'll add WebSocket support and build the matchmaking system that pairs two players into a game.
Build the WebSocket Server and Matchmaking
Your server is running and the observability stack is in place. Now you need it to do something interesting.
The core challenge of any multiplayer game server is matchmaking. Two strangers connect independently, and your server must pair them into a shared game room without race conditions or lost connections. In this step, you will build a Hub that manages a matchmaking queue and pairs players into game rooms over WebSocket connections.
In this step, get ready to:
- Create a Hub struct with a channel-based matchmaking queue that pairs players into game rooms.
- Implement WebSocket connection handling with dedicated read and write goroutines per player.
- Verify matchmaking works by connecting two browser tabs and confirming both receive a matched message.
Create the Hub struct and matchmaking loop
The Hub is the central coordinator for your game server. It holds a buffered channel that acts as a waiting queue. When two players land in the queue, the Hub pairs them and notifies both.
- Create a new file at internal/hub/hub.go. This file will hold all the matchmaking and connection logic.
- Add the following code to define the core types and matchmaking loop:
package hub
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// gameCounter generates unique game IDs across all rooms
var gameCounter atomic.Int64
// Player represents a single connected WebSocket client
type Player struct {
conn *websocket.Conn
color int
gameID string
send chan []byte
}
// Hub manages the matchmaking queue and active game rooms
type Hub struct {
mu sync.Mutex
queue chan *Player
games map[string]*gameRoom
}
// gameRoom holds the two players in a single game
type gameRoom struct {
players [2]*Player
}
// clientMsg is the JSON shape of messages FROM the browser
type clientMsg struct {
Type string `json:"type"`
Col int `json:"col,omitempty"`
}
// serverMsg is the JSON shape of messages TO the browser
type serverMsg struct {
Type string `json:"type"`
Color string `json:"color,omitempty"`
}
// New creates a Hub with a buffered matchmaking queue
func New() *Hub {
return &Hub{
queue: make(chan *Player, 100),
games: make(map[string]*gameRoom),
}
}
// Run blocks forever, pairing players from the queue into game rooms
func (h *Hub) Run() {
for {
p1 := <-h.queue
p2 := <-h.queue
id := fmt.Sprintf("game-%d", gameCounter.Add(1))
p1.color = 1
p1.gameID = id
p2.color = 2
p2.gameID = id
room := &gameRoom{players: [2]*Player{p1, p2}}
h.mu.Lock()
h.games[id] = room
h.mu.Unlock()
slog.Info("game created", "game_id", id)
msg1, _ := json.Marshal(serverMsg{Type: "matched", Color: "red"})
msg2, _ := json.Marshal(serverMsg{Type: "matched", Color: "yellow"})
p1.send <- msg1
p2.send <- msg2
}
}
What does this code do?
The Hub uses a channel as a FIFO matchmaking queue. Players enter the channel one at a time, and the Run loop blocks until two are available.
- queue chan *Player is a buffered channel holding up to 100 waiting players. Using a channel means Go handles all the synchronization for free.
- Run() pulls two players off the channel, assigns them colors (1 = red, 2 = yellow), creates a game room, and sends each a JSON matched message.
- The sync.Mutex protects the games map because multiple goroutines will read and write it concurrently.
Why a channel instead of a slice?
A slice-based queue would require manual locking every time you add or remove a player. A channel gives you blocking reads, thread safety, and backpressure (the buffer limit) in one primitive. It is the idiomatic Go pattern for producer-consumer queues.
- Save internal/hub/hub.go.
You won't see a change yet. The next chunk adds the connection handling that lets players actually reach this matchmaking loop.
Handle WebSocket connections and route messages
Each player needs a dedicated read goroutine (to receive messages) and a write goroutine (to send messages). This pump pattern prevents one slow client from blocking another.
- In internal/hub/hub.go, add the following three functions below the Run() function:
// HandleWebSocket upgrades an HTTP connection to WebSocket and starts the player's read/write pumps
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"*"},
})
if err != nil {
slog.Error("websocket accept failed", "err", err)
return
}
slog.Info("player connected", "remote", r.RemoteAddr)
p := &Player{
conn: conn,
send: make(chan []byte, 16),
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go h.writePump(ctx, p)
h.readPump(ctx, p)
slog.Info("player disconnected", "remote", r.RemoteAddr)
}
// writePump sends queued messages to the player's WebSocket connection
func (h *Hub) writePump(ctx context.Context, p *Player) {
for {
select {
case msg, ok := <-p.send:
if !ok {
return
}
writeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
p.conn.Write(writeCtx, websocket.MessageText, msg)
cancel()
case <-ctx.Done():
return
}
}
}
// readPump reads messages from the player's WebSocket and routes them
func (h *Hub) readPump(ctx context.Context, p *Player) {
defer p.conn.CloseNow()
for {
_, data, err := p.conn.Read(ctx)
if err != nil {
return
}
var msg clientMsg
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
switch msg.Type {
case "join":
h.queue <- p
}
}
}
What does this code do?
- HandleWebSocket upgrades the HTTP request to a WebSocket connection using websocket.Accept(). It then spawns a write goroutine and blocks on the read loop.
- writePump sits in a select loop, pulling messages from the player's send channel and writing them to the WebSocket. Each write has a 5-second timeout to prevent a stuck client from blocking forever.
- readPump reads incoming JSON messages and routes them. When a player sends {"type":"join"}, they get pushed into the matchmaking queue.
- When the context is cancelled (connection drops), both pumps exit cleanly. No goroutine leaks.
Why separate read and write goroutines?
A single goroutine handling both reads and writes would block. If the server is waiting for the client to send data, it cannot push a matched notification at the same time. The pump pattern decouples inbound and outbound traffic so neither direction starves the other.
- Save internal/hub/hub.go.
You won't see a change yet. The next chunk wires the Hub into your server's main function.
✔️ Awesome, I've got everything!
Great. Double check you have saved internal/hub/hub.go before continuing.
ⓧ I'd like to double check the full code
package hub
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
var gameCounter atomic.Int64
type Player struct {
conn *websocket.Conn
color int
gameID string
send chan []byte
}
type Hub struct {
mu sync.Mutex
queue chan *Player
games map[string]*gameRoom
}
type gameRoom struct {
players [2]*Player
}
type clientMsg struct {
Type string `json:"type"`
Col int `json:"col,omitempty"`
}
type serverMsg struct {
Type string `json:"type"`
Color string `json:"color,omitempty"`
}
func New() *Hub {
return &Hub{
queue: make(chan *Player, 100),
games: make(map[string]*gameRoom),
}
}
func (h *Hub) Run() {
for {
p1 := <-h.queue
p2 := <-h.queue
id := fmt.Sprintf("game-%d", gameCounter.Add(1))
p1.color = 1
p1.gameID = id
p2.color = 2
p2.gameID = id
room := &gameRoom{players: [2]*Player{p1, p2}}
h.mu.Lock()
h.games[id] = room
h.mu.Unlock()
slog.Info("game created", "game_id", id)
msg1, _ := json.Marshal(serverMsg{Type: "matched", Color: "red"})
msg2, _ := json.Marshal(serverMsg{Type: "matched", Color: "yellow"})
p1.send <- msg1
p2.send <- msg2
}
}
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"*"},
})
if err != nil {
slog.Error("websocket accept failed", "err", err)
return
}
slog.Info("player connected", "remote", r.RemoteAddr)
p := &Player{
conn: conn,
send: make(chan []byte, 16),
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go h.writePump(ctx, p)
h.readPump(ctx, p)
slog.Info("player disconnected", "remote", r.RemoteAddr)
}
func (h *Hub) writePump(ctx context.Context, p *Player) {
for {
select {
case msg, ok := <-p.send:
if !ok {
return
}
writeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
p.conn.Write(writeCtx, websocket.MessageText, msg)
cancel()
case <-ctx.Done():
return
}
}
}
func (h *Hub) readPump(ctx context.Context, p *Player) {
defer p.conn.CloseNow()
for {
_, data, err := p.conn.Read(ctx)
if err != nil {
return
}
var msg clientMsg
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
switch msg.Type {
case "join":
h.queue <- p
}
}
}
Now you need to wire the Hub into your server so incoming connections reach it.
- Replace the entire contents of cmd/server/main.go with the following code:
package main
import (
"log/slog"
"net/http"
"os"
"connectfour/internal/hub"
)
func main() {
// Set up structured JSON logging
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
// Create the Hub and start the matchmaking goroutine
h := hub.New()
go h.Run()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
// Register the WebSocket endpoint
mux.HandleFunc("/ws", h.HandleWebSocket)
// Serve the frontend files from the web/ directory
mux.Handle("/", http.FileServer(http.Dir("web")))
slog.Info("server starting", "addr", ":8080")
http.ListenAndServe(":8080", mux)
}
What changed in main.go?
- Structured logging is configured with slog outputting JSON. Every log line from the Hub will now include structured fields like game_id.
- hub.New() creates the Hub, and go h.Run() starts the matchmaking loop in a background goroutine.
- The /ws route handles WebSocket upgrades. The / route serves static files from web/ so you can load the browser client.
- Save cmd/server/main.go.
- Run the server by executing this command in your terminal:
go run cmd/server/main.go
You should see a JSON log line with "msg":"server starting" printed to your terminal. The server is now listening on port 8080 with the WebSocket handler active.
Seeing a compilation error?
- Check that internal/hub/hub.go starts with package hub (not package main). The import in main.go expects it at connectfour/internal/hub.
- Make sure all unused imports are removed. Go will refuse to compile if any import is unused.
- Verify you ran go get github.com/coder/websocket@v1.8.14 in the previous step. If missing, run it now.
Help me debug my compilation error
✔️ Awesome, I've got everything!
Great. Make sure cmd/server/main.go is saved and the server is running.
ⓧ I'd like to double check the full code
package main
import (
"log/slog"
"net/http"
"os"
"connectfour/internal/hub"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
h := hub.New()
go h.Run()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/ws", h.HandleWebSocket)
mux.Handle("/", http.FileServer(http.Dir("web")))
slog.Info("server starting", "addr", ":8080")
http.ListenAndServe(":8080", mux)
}
Test matchmaking with two browser tabs
You need a minimal browser client so you can actually see the matchmaking in action. This page connects to your WebSocket server and logs messages to the screen.
- Create a new file at web/index.html and add the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Connect Four</title>
</head>
<body>
<h1>Connect Four</h1>
<div id="status">Click Join to find an opponent</div>
<button id="joinBtn">Join Game</button>
<script>
const status = document.getElementById('status');
const joinBtn = document.getElementById('joinBtn');
let ws;
joinBtn.addEventListener('click', () => {
// Connect to the WebSocket server
ws = new WebSocket(`ws://${location.host}/ws`);
ws.onopen = () => {
// Tell the server we want to join the matchmaking queue
ws.send(JSON.stringify({ type: 'join' }));
status.textContent = 'Waiting for opponent...';
};
ws.onmessage = (event) => {
console.log('Message:', event.data);
const msg = JSON.parse(event.data);
if (msg.type === 'matched') {
status.textContent = `Matched! You are ${msg.color}`;
}
};
});
</script>
</body>
</html>
What does this page do?
- Clicking **Join Game** opens a WebSocket to ws://localhost:8080/ws.
- On connection, it immediately sends {"type":"join"} to enter the matchmaking queue.
- When the server sends back a matched message, the status text updates to show the player's assigned color.
- Save web/index.html.
- Stop your server if it is running by pressing Ctrl+C in the terminal.
- Restart the server by running:
go run cmd/server/main.go
- Open your browser and navigate to http://localhost:8080. You should see the Connect Four heading and a **Join Game** button.
- Open a second browser tab at the same URL: http://localhost:8080.
- Click **Join Game** in the first tab. The status should change to "Waiting for opponent...".
- Click **Join Game** in the second tab. Both tabs should immediately update.
The first tab should show "Matched! You are red" and the second tab should show "Matched! You are yellow". In your terminal, you should see JSON logs for both player connections and the game creation.
Not seeing the matched message?
- Make sure you clicked **Join Game** in BOTH tabs. The server needs two players before it matches anyone.
- Open the browser's developer console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows). Check for any connection errors.
- Verify the server is still running in your terminal. If it crashed, check the error output and restart it.
- If you see a WebSocket connection refused error, confirm you are using http://localhost:8080 (not a different port).
Help me debug why my WebSocket matchmaking is not working
✔️ Awesome, I've got everything!
Great. Double check you have saved web/index.html and both browser tabs show matched messages.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Connect Four</title>
</head>
<body>
<h1>Connect Four</h1>
<div id="status">Click Join to find an opponent</div>
<button id="joinBtn">Join Game</button>
<script>
const status = document.getElementById('status');
const joinBtn = document.getElementById('joinBtn');
let ws;
joinBtn.addEventListener('click', () => {
ws = new WebSocket(`ws://${location.host}/ws`);
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'join' }));
status.textContent = 'Waiting for opponent...';
};
ws.onmessage = (event) => {
console.log('Message:', event.data);
const msg = JSON.parse(event.data);
if (msg.type === 'matched') {
status.textContent = `Matched! You are ${msg.color}`;
}
};
});
</script>
</body>
</html>
Your matchmaking system is live. Two players can connect from separate browser tabs and get paired into a game room instantly. Next up, you will build the actual game logic so those matched players can drop pieces and compete.
Implement Game State and Move Broadcasting
Your matchmaking queue pairs players together, but matched players have nowhere to actually play. The server needs a state machine that tracks whose turn it is, validates moves, detects wins, and broadcasts board updates to both players simultaneously.
In this step, you'll build a mutex-protected game engine, wire it to your WebSocket message flow, and update the browser client so two tabs can play a full game of Connect Four against each other.
In this step, get ready to:
- Build a mutex-protected game state machine with move validation and win detection.
- Wire game logic to WebSocket messages for real-time move broadcasting.
- Add structured logging for game events and update the browser client.
Build the game state machine
Connect Four needs a single source of truth for the board. Two goroutines (one per player) will call into this state simultaneously, so you need a mutex to guarantee atomic transitions.
- Create a new file at internal/game/game.go.
- Add the package declaration, constants, error variables, and the Game struct by pasting this code:
package game
import (
"errors"
"sync"
)
const (
Rows = 6
Cols = 7
)
var (
ErrNotYourTurn = errors.New("not your turn")
ErrInvalidCol = errors.New("invalid column")
ErrColumnFull = errors.New("column is full")
ErrGameOver = errors.New("game is over")
)
type Game struct {
mu sync.Mutex
ID string
Board [Rows][Cols]int
Turn int
Winner int
Done bool
}
func New(id string) *Game {
return &Game{
ID: id,
Turn: 1,
}
}
What does this code do?
The Game struct holds the 6x7 board as a 2D int array, a turn tracker (1 = red, 2 = yellow), and a sync.Mutex that protects every state transition.
The four sentinel errors give callers specific failure reasons instead of generic error strings. This lets the WebSocket handler decide what to tell each player.
- Below the constructor, add the core move logic by pasting this code:
func (g *Game) MakeMove(col int, player int) (int, int, error) {
g.mu.Lock()
defer g.mu.Unlock()
if g.Done {
return 0, 0, ErrGameOver
}
if player != g.Turn {
return 0, 0, ErrNotYourTurn
}
if col < 0 || col >= Cols {
return 0, 0, ErrInvalidCol
}
row := -1
for r := Rows - 1; r >= 0; r-- {
if g.Board[r][col] == 0 {
row = r
break
}
}
if row == -1 {
return 0, 0, ErrColumnFull
}
g.Board[row][col] = player
if g.checkWin(row, col, player) {
g.Winner = player
g.Done = true
} else if g.isFull() {
g.Done = true
} else {
if g.Turn == 1 {
g.Turn = 2
} else {
g.Turn = 1
}
}
return row, col, nil
}
What does MakeMove do?
The function validates four conditions in order: game not over, correct player's turn, valid column index, and column not full. If any check fails, it returns early with the specific error.
The gravity loop scans from the bottom of the column upward to find the first empty cell. After placing the piece, it checks for a win or draw and toggles the turn. The mutex ensures only one goroutine can modify the board at a time.
- Complete the file by adding the helper methods below MakeMove:
func (g *Game) GetState() ([Rows][Cols]int, int, int, bool) {
g.mu.Lock()
defer g.mu.Unlock()
return g.Board, g.Turn, g.Winner, g.Done
}
func (g *Game) checkWin(row, col, player int) bool {
directions := [][2]int{{0, 1}, {1, 0}, {1, 1}, {1, -1}}
for _, d := range directions {
count := 1
for i := 1; i < 4; i++ {
r, c := row+d[0]*i, col+d[1]*i
if r < 0 || r >= Rows || c < 0 || c >= Cols || g.Board[r][c] != player {
break
}
count++
}
for i := 1; i < 4; i++ {
r, c := row-d[0]*i, col-d[1]*i
if r < 0 || r >= Rows || c < 0 || c >= Cols || g.Board[r][c] != player {
break
}
count++
}
if count >= 4 {
return true
}
}
return false
}
func (g *Game) isFull() bool {
for c := 0; c < Cols; c++ {
if g.Board[0][c] == 0 {
return false
}
}
return true
}
How does win detection work?
The checkWin function checks four directions from the last-placed piece: horizontal, vertical, and both diagonals. For each direction, it counts consecutive pieces in both the positive and negative direction. If any direction reaches 4 or more, the player wins.
The isFull function only checks the top row. If every column's top cell is occupied, the board is full and the game is a draw.
- Save internal/game/game.go.
- You won't see a visible change yet. The next substep wires this into the WebSocket handler.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
package game
import (
"errors"
"sync"
)
const (
Rows = 6
Cols = 7
)
var (
ErrNotYourTurn = errors.New("not your turn")
ErrInvalidCol = errors.New("invalid column")
ErrColumnFull = errors.New("column is full")
ErrGameOver = errors.New("game is over")
)
type Game struct {
mu sync.Mutex
ID string
Board [Rows][Cols]int
Turn int
Winner int
Done bool
}
func New(id string) *Game {
return &Game{
ID: id,
Turn: 1,
}
}
func (g *Game) MakeMove(col int, player int) (int, int, error) {
g.mu.Lock()
defer g.mu.Unlock()
if g.Done {
return 0, 0, ErrGameOver
}
if player != g.Turn {
return 0, 0, ErrNotYourTurn
}
if col < 0 || col >= Cols {
return 0, 0, ErrInvalidCol
}
row := -1
for r := Rows - 1; r >= 0; r-- {
if g.Board[r][col] == 0 {
row = r
break
}
}
if row == -1 {
return 0, 0, ErrColumnFull
}
g.Board[row][col] = player
if g.checkWin(row, col, player) {
g.Winner = player
g.Done = true
} else if g.isFull() {
g.Done = true
} else {
if g.Turn == 1 {
g.Turn = 2
} else {
g.Turn = 1
}
}
return row, col, nil
}
func (g *Game) GetState() ([Rows][Cols]int, int, int, bool) {
g.mu.Lock()
defer g.mu.Unlock()
return g.Board, g.Turn, g.Winner, g.Done
}
func (g *Game) checkWin(row, col, player int) bool {
directions := [][2]int{{0, 1}, {1, 0}, {1, 1}, {1, -1}}
for _, d := range directions {
count := 1
for i := 1; i < 4; i++ {
r, c := row+d[0]*i, col+d[1]*i
if r < 0 || r >= Rows || c < 0 || c >= Cols || g.Board[r][c] != player {
break
}
count++
}
for i := 1; i < 4; i++ {
r, c := row-d[0]*i, col-d[1]*i
if r < 0 || r >= Rows || c < 0 || c >= Cols || g.Board[r][c] != player {
break
}
count++
}
if count >= 4 {
return true
}
}
return false
}
func (g *Game) isFull() bool {
for c := 0; c < Cols; c++ {
if g.Board[0][c] == 0 {
return false
}
}
return true
}
Wire game logic to WebSocket broadcasting
The game package handles rules, but it needs to be connected to your WebSocket hub. When a player sends a move message, the hub should call MakeMove, then broadcast the updated board state to both players.
- In internal/hub/hub.go, find the import block at the top of the file.
- Find this line:
"github.com/coder/websocket"
- Add the game package import directly below it, so the imports end like this:
"github.com/coder/websocket"
"connectfour/internal/game"
- Next, find the gameRoom struct:
type gameRoom struct {
players [2]*Player
}
- Replace it with this version that includes a reference to the game:
type gameRoom struct {
game *game.Game
players [2]*Player
}
- Find the serverMsg struct:
type serverMsg struct {
Type string `json:"type"`
Color string `json:"color,omitempty"`
}
- Replace it with this expanded version that carries board state:
type serverMsg struct {
Type string `json:"type"`
Color string `json:"color,omitempty"`
Board [6][7]int `json:"board,omitempty"`
Turn string `json:"turn,omitempty"`
Winner string `json:"winner,omitempty"`
Done bool `json:"done,omitempty"`
}
- In the Run() method, find these lines:
room := &gameRoom{players: [2]*Player{p1, p2}}
- Replace with these two lines that create a game instance and store it in the room:
g := game.New(id)
p1.color = 1
p1.gameID = id
p2.color = 2
p2.gameID = id
room := &gameRoom{game: g, players: [2]*Player{p1, p2}}
What changed in Run()?
Previously the room only held two player pointers. Now it also holds a game.Game instance created with the same ID. Both players and the game share the room, so the hub can route moves to the correct state machine.
- In readPump, find the switch statement:
switch msg.Type {
case "join":
h.queue <- p
}
- Replace it with this version that also handles move messages:
switch msg.Type {
case "join":
h.queue <- p
case "move":
h.handleMove(p, msg.Col)
}
- Now add the handleMove method at the bottom of the file, after readPump:
func (h *Hub) handleMove(p *Player, col int) {
h.mu.Lock()
room, ok := h.games[p.gameID]
h.mu.Unlock()
if !ok {
return
}
_, _, err := room.game.MakeMove(col, p.color)
if err != nil {
slog.Warn("invalid move", "game_id", p.gameID, "player", p.color, "col", col, "err", err)
errMsg, _ := json.Marshal(map[string]string{"type": "error", "message": err.Error()})
p.send <- errMsg
return
}
board, turn, winner, done := room.game.GetState()
turnStr := "red"
if turn == 2 {
turnStr = "yellow"
}
winnerStr := ""
if winner == 1 {
winnerStr = "red"
} else if winner == 2 {
winnerStr = "yellow"
}
update, _ := json.Marshal(serverMsg{
Type: "update",
Board: board,
Turn: turnStr,
Winner: winnerStr,
Done: done,
})
for _, player := range room.players {
if player != nil {
player.send <- update
}
}
if done {
slog.Info("game finished", "game_id", p.gameID, "winner", winnerStr)
h.mu.Lock()
delete(h.games, p.gameID)
h.mu.Unlock()
} else {
slog.Info("move made", "game_id", p.gameID, "player", p.color, "col", col)
}
}
What does handleMove do?
It looks up the game room by ID, calls MakeMove on the game engine, and handles two outcomes. If the move is invalid, it logs a warning and sends the error back to that player only.
If the move succeeds, it reads the full board state, translates integer colors to string names, and broadcasts an update message to both players. When a game finishes, it removes the room from the map to free memory.
- Save internal/hub/hub.go.
Seeing a compile error?
Make sure the import path is exactly connectfour/internal/game. It must match your module name in go.mod.
Check that the game field in gameRoom is lowercase (unexported). The struct field must be game *game.Game not Game *game.Game.
help me debug a compile error in my hub.go after adding the game import
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
package hub
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
"connectfour/internal/game"
)
var gameCounter atomic.Int64
type Player struct {
conn *websocket.Conn
color int
gameID string
send chan []byte
}
type Hub struct {
mu sync.Mutex
queue chan *Player
games map[string]*gameRoom
}
type gameRoom struct {
game *game.Game
players [2]*Player
}
type clientMsg struct {
Type string `json:"type"`
Col int `json:"col,omitempty"`
}
type serverMsg struct {
Type string `json:"type"`
Color string `json:"color,omitempty"`
Board [6][7]int `json:"board,omitempty"`
Turn string `json:"turn,omitempty"`
Winner string `json:"winner,omitempty"`
Done bool `json:"done,omitempty"`
}
func New() *Hub {
return &Hub{
queue: make(chan *Player, 100),
games: make(map[string]*gameRoom),
}
}
func (h *Hub) Run() {
for {
p1 := <-h.queue
p2 := <-h.queue
id := fmt.Sprintf("game-%d", gameCounter.Add(1))
g := game.New(id)
p1.color = 1
p1.gameID = id
p2.color = 2
p2.gameID = id
room := &gameRoom{game: g, players: [2]*Player{p1, p2}}
h.mu.Lock()
h.games[id] = room
h.mu.Unlock()
slog.Info("game created", "game_id", id)
msg1, _ := json.Marshal(serverMsg{Type: "matched", Color: "red"})
msg2, _ := json.Marshal(serverMsg{Type: "matched", Color: "yellow"})
p1.send <- msg1
p2.send <- msg2
}
}
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"*"},
})
if err != nil {
slog.Error("websocket accept failed", "err", err)
return
}
slog.Info("player connected", "remote", r.RemoteAddr)
p := &Player{
conn: conn,
send: make(chan []byte, 16),
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go h.writePump(ctx, p)
h.readPump(ctx, p)
slog.Info("player disconnected", "remote", r.RemoteAddr)
}
func (h *Hub) writePump(ctx context.Context, p *Player) {
for {
select {
case msg, ok := <-p.send:
if !ok {
return
}
writeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
p.conn.Write(writeCtx, websocket.MessageText, msg)
cancel()
case <-ctx.Done():
return
}
}
}
func (h *Hub) readPump(ctx context.Context, p *Player) {
defer p.conn.CloseNow()
for {
_, data, err := p.conn.Read(ctx)
if err != nil {
return
}
var msg clientMsg
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
switch msg.Type {
case "join":
h.queue <- p
case "move":
h.handleMove(p, msg.Col)
}
}
}
func (h *Hub) handleMove(p *Player, col int) {
h.mu.Lock()
room, ok := h.games[p.gameID]
h.mu.Unlock()
if !ok {
return
}
_, _, err := room.game.MakeMove(col, p.color)
if err != nil {
slog.Warn("invalid move", "game_id", p.gameID, "player", p.color, "col", col, "err", err)
errMsg, _ := json.Marshal(map[string]string{"type": "error", "message": err.Error()})
p.send <- errMsg
return
}
board, turn, winner, done := room.game.GetState()
turnStr := "red"
if turn == 2 {
turnStr = "yellow"
}
winnerStr := ""
if winner == 1 {
winnerStr = "red"
} else if winner == 2 {
winnerStr = "yellow"
}
update, _ := json.Marshal(serverMsg{
Type: "update",
Board: board,
Turn: turnStr,
Winner: winnerStr,
Done: done,
})
for _, player := range room.players {
if player != nil {
player.send <- update
}
}
if done {
slog.Info("game finished", "game_id", p.gameID, "winner", winnerStr)
h.mu.Lock()
delete(h.games, p.gameID)
h.mu.Unlock()
} else {
slog.Info("move made", "game_id", p.gameID, "player", p.color, "col", col)
}
}
Add structured logging and update the client
Your server now logs every game event with slog structured fields. Every log line includes a game_id for correlation, so you can trace a single game's lifecycle across all log entries.
Why structured logging matters here
With multiple games running simultaneously, unstructured logs become noise. Structured fields like game_id and player let you filter and correlate events for a single game session.
The slog.Warn level for invalid moves separates expected user errors from actual system failures. In production, you would alert on Error level but ignore Warn level.
The browser client needs a visual board, click handlers, and the ability to process update messages from the server. Let's replace the minimal test page with a full game interface.
- Replace the entire contents of web/index.html with this code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connect Four</title>
<style>
body { font-family: sans-serif; display: flex; flex-direction: column; align-items: center; background: #1a1a2e; color: #eee; margin: 0; padding: 20px; }
h1 { margin-bottom: 10px; }
#status { margin: 10px 0; font-size: 1.2em; }
#board { display: grid; grid-template-columns: repeat(7, 60px); grid-template-rows: repeat(6, 60px); gap: 4px; background: #16213e; padding: 10px; border-radius: 8px; }
.cell { width: 60px; height: 60px; border-radius: 50%; background: #0f3460; cursor: pointer; transition: background 0.2s; }
.cell:hover { opacity: 0.8; }
.cell.red { background: #e94560; }
.cell.yellow { background: #f5c518; }
button { margin-top: 15px; padding: 10px 20px; font-size: 1em; border: none; border-radius: 4px; background: #e94560; color: #fff; cursor: pointer; }
button:hover { background: #c73e54; }
</style>
</head>
<body>
<h1>Connect Four</h1>
<div id="status">Click Join to find an opponent</div>
<div id="board"></div>
<button id="joinBtn">Join Game</button>
<script>
const board = document.getElementById('board');
const status = document.getElementById('status');
const joinBtn = document.getElementById('joinBtn');
let ws;
let myColor = '';
function createBoard() {
board.innerHTML = '';
for (let r = 0; r < 6; r++) {
for (let c = 0; c < 7; c++) {
const cell = document.createElement('div');
cell.className = 'cell';
cell.dataset.row = r;
cell.dataset.col = c;
cell.addEventListener('click', () => makeMove(c));
board.appendChild(cell);
}
}
}
function makeMove(col) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'move', col: col }));
}
}
function renderBoard(boardState) {
const cells = document.querySelectorAll('.cell');
for (let r = 0; r < 6; r++) {
for (let c = 0; c < 7; c++) {
const cell = cells[r * 7 + c];
cell.className = 'cell';
if (boardState[r][c] === 1) cell.classList.add('red');
else if (boardState[r][c] === 2) cell.classList.add('yellow');
}
}
}
joinBtn.addEventListener('click', () => {
ws = new WebSocket(`ws://${location.host}/ws`);
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'join' }));
status.textContent = 'Waiting for opponent...';
joinBtn.disabled = true;
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'matched':
myColor = msg.color;
status.textContent = `Matched! You are ${myColor}. Red goes first.`;
createBoard();
break;
case 'update':
renderBoard(msg.board);
if (msg.done) {
status.textContent = msg.winner ? `${msg.winner} wins!` : 'Draw!';
} else {
status.textContent = `Turn: ${msg.turn} | You: ${myColor}`;
}
break;
case 'error':
status.textContent = `Error: ${msg.message}`;
setTimeout(() => { status.textContent = `Your turn (${myColor})`; }, 2000);
break;
}
};
ws.onclose = () => {
status.textContent = 'Disconnected';
joinBtn.disabled = false;
};
});
createBoard();
</script>
</body>
</html>
What does the client do?
The createBoard() function renders 42 circular cells in a 7x6 CSS grid. Each cell has a click handler that sends a move message with the column index.
The renderBoard() function receives the 2D board array from the server and applies red or yellow CSS classes to each cell. The message handler processes matched, update, and error message types.
- Save web/index.html.
- Restart your server by pressing Ctrl+C in the terminal and running:
go run ./cmd/server
- Open two browser tabs to http://localhost:8080.
- Click Join Game in both tabs.
- Click a column in the red player's tab. You should see a red circle appear in both tabs, and the status updates to show it's yellow's turn.
- Check your terminal. You should see structured JSON log lines with fields like game_id and player for each move.
Clicking but nothing happens?
Make sure you clicked Join Game in both tabs first. You cannot make moves until both players are matched.
If you see the board but clicks do nothing, check the browser console for errors. A common cause is the WebSocket connection closing before you click. Refresh both tabs and join again.
help me debug why clicking the board doesn't register moves
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before continuing.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connect Four</title>
<style>
body { font-family: sans-serif; display: flex; flex-direction: column; align-items: center; background: #1a1a2e; color: #eee; margin: 0; padding: 20px; }
h1 { margin-bottom: 10px; }
#status { margin: 10px 0; font-size: 1.2em; }
#board { display: grid; grid-template-columns: repeat(7, 60px); grid-template-rows: repeat(6, 60px); gap: 4px; background: #16213e; padding: 10px; border-radius: 8px; }
.cell { width: 60px; height: 60px; border-radius: 50%; background: #0f3460; cursor: pointer; transition: background 0.2s; }
.cell:hover { opacity: 0.8; }
.cell.red { background: #e94560; }
.cell.yellow { background: #f5c518; }
button { margin-top: 15px; padding: 10px 20px; font-size: 1em; border: none; border-radius: 4px; background: #e94560; color: #fff; cursor: pointer; }
button:hover { background: #c73e54; }
</style>
</head>
<body>
<h1>Connect Four</h1>
<div id="status">Click Join to find an opponent</div>
<div id="board"></div>
<button id="joinBtn">Join Game</button>
<script>
const board = document.getElementById('board');
const status = document.getElementById('status');
const joinBtn = document.getElementById('joinBtn');
let ws;
let myColor = '';
function createBoard() {
board.innerHTML = '';
for (let r = 0; r < 6; r++) {
for (let c = 0; c < 7; c++) {
const cell = document.createElement('div');
cell.className = 'cell';
cell.dataset.row = r;
cell.dataset.col = c;
cell.addEventListener('click', () => makeMove(c));
board.appendChild(cell);
}
}
}
function makeMove(col) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'move', col: col }));
}
}
function renderBoard(boardState) {
const cells = document.querySelectorAll('.cell');
for (let r = 0; r < 6; r++) {
for (let c = 0; c < 7; c++) {
const cell = cells[r * 7 + c];
cell.className = 'cell';
if (boardState[r][c] === 1) cell.classList.add('red');
else if (boardState[r][c] === 2) cell.classList.add('yellow');
}
}
}
joinBtn.addEventListener('click', () => {
ws = new WebSocket(`ws://${location.host}/ws`);
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'join' }));
status.textContent = 'Waiting for opponent...';
joinBtn.disabled = true;
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'matched':
myColor = msg.color;
status.textContent = `Matched! You are ${myColor}. Red goes first.`;
createBoard();
break;
case 'update':
renderBoard(msg.board);
if (msg.done) {
status.textContent = msg.winner ? `${msg.winner} wins!` : 'Draw!';
} else {
status.textContent = `Turn: ${msg.turn} | You: ${myColor}`;
}
break;
case 'error':
status.textContent = `Error: ${msg.message}`;
setTimeout(() => { status.textContent = `Your turn (${myColor})`; }, 2000);
break;
}
};
ws.onclose = () => {
status.textContent = 'Disconnected';
joinBtn.disabled = false;
};
});
createBoard();
</script>
</body>
</html>
You now have a fully playable multiplayer Connect Four game running over WebSockets. Next up, you'll add Prometheus metrics and a Grafana dashboard so you can observe your system's behaviour in real time.
Add Prometheus Metrics and Grafana Dashboard
Your game server works, but you have no visibility into what is happening inside it. How many games are running right now? How long does each move take to process? Without answers, you are flying blind.
In this step, you will add Prometheus instrumentation to your server and visualize the data in Grafana. You will create custom metrics that track active games, connected players, and move processing latency.
In this step, get ready to:
- Register Prometheus metrics and expose a /metrics endpoint.
- Instrument game creation, player connections, and move timing with gauges and histograms.
- Build a Grafana dashboard with three panels visualizing your game server in real time.
Create the metrics package
Before you can observe your server, you need to define what to measure. You will create a dedicated metrics package that registers three Prometheus collectors.
- Create a new file at internal/metrics/metrics.go and paste the following code:
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
)
var (
// ActiveGames tracks how many games are currently in progress
ActiveGames = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "connectfour_active_games",
Help: "Number of currently active games",
})
// ConnectedPlayers tracks how many WebSocket connections are open
ConnectedPlayers = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "connectfour_connected_players",
Help: "Number of currently connected players",
})
// MoveDuration measures how long MakeMove takes to process
MoveDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "connectfour_move_duration_seconds",
Help: "Time taken to process a move",
Buckets: prometheus.DefBuckets,
})
)
func Register() {
prometheus.MustRegister(ActiveGames)
prometheus.MustRegister(ConnectedPlayers)
prometheus.MustRegister(MoveDuration)
}
What does this code do?
You define three collectors that Prometheus will scrape every 5 seconds (matching your prometheus.yml interval).
- ActiveGames is a Gauge (goes up and down) that tracks in-progress games.
- ConnectedPlayers is a Gauge that tracks open WebSocket connections.
- MoveDuration is a Histogram that records how long each move takes, bucketing values for percentile calculations.
- Register() calls prometheus.MustRegister so Prometheus knows about each collector.
- Save internal/metrics/metrics.go.
Now update cmd/server/main.go to register these metrics and expose them to Prometheus. You will also refactor the server to support graceful shutdown, which prevents metrics from being lost mid-scrape.
- Replace the entire contents of cmd/server/main.go with the following:
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
"connectfour/internal/hub"
"connectfour/internal/metrics"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
// Register Prometheus collectors before starting the server
metrics.Register()
h := hub.New()
go h.Run()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/ws", h.HandleWebSocket)
// Expose Prometheus metrics at /metrics
mux.Handle("/metrics", promhttp.Handler())
mux.Handle("/", http.FileServer(http.Dir("web")))
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Graceful shutdown: wait for interrupt signal
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go func() {
slog.Info("server starting", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
slog.Info("shutting down")
// Give in-flight requests 5 seconds to complete
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)
}
What changed in main.go?
- metrics.Register() tells Prometheus about your three collectors at startup.
- promhttp.Handler() serves the /metrics endpoint that Prometheus scrapes.
- signal.NotifyContext listens for Ctrl+C so the server shuts down gracefully instead of dropping connections.
- The 5-second shutdown timeout gives in-flight WebSocket messages and metric scrapes time to finish.
- Save cmd/server/main.go.
- Run go mod tidy in your terminal to resolve the new promhttp import.
Seeing an import error?
If go mod tidy cannot find the promhttp package, confirm you installed the Prometheus client library in Step 1.
Re-run the install command from your connectfour directory:
go get github.com/prometheus/client_golang@v1.23.2
help me fix my Go import errors
Instrument the hub with metrics
The metrics package defines collectors, but nothing increments or decrements them yet. You need to call the metric methods at the exact points where state changes happen.
- In internal/hub/hub.go, find the import block and add the metrics import so it looks like this:
"github.com/coder/websocket"
"connectfour/internal/game"
"connectfour/internal/metrics"
- In the Run() method, find the line h.mu.Unlock() (after storing the room in the map). Add the metrics call directly below it so the section looks like this:
h.mu.Lock()
h.games[id] = room
h.mu.Unlock()
metrics.ActiveGames.Inc()
slog.Info("game created", "game_id", id)
- In HandleWebSocket(), add metrics.ConnectedPlayers.Inc() right after the successful websocket.Accept() call, and metrics.ConnectedPlayers.Dec() just before the disconnect log. The method should look like this:
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"*"},
})
if err != nil {
slog.Error("websocket accept failed", "err", err)
return
}
metrics.ConnectedPlayers.Inc()
slog.Info("player connected", "remote", r.RemoteAddr)
p := &Player{
conn: conn,
send: make(chan []byte, 16),
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go h.writePump(ctx, p)
h.readPump(ctx, p)
metrics.ConnectedPlayers.Dec()
slog.Info("player disconnected", "remote", r.RemoteAddr)
}
- In handleMove(), wrap the MakeMove call with timing and add metrics.ActiveGames.Dec() in the game-finished block. Find the section that calls room.game.MakeMove and update it to:
start := time.Now()
_, _, err := room.game.MakeMove(col, p.color)
metrics.MoveDuration.Observe(time.Since(start).Seconds())
- Further down in the same function, find the if done { block and add the gauge decrement:
if done {
slog.Info("game finished", "game_id", p.gameID, "winner", winnerStr)
metrics.ActiveGames.Dec()
h.mu.Lock()
delete(h.games, p.gameID)
h.mu.Unlock()
} else {
slog.Info("move made", "game_id", p.gameID, "player", p.color, "col", col)
}
Why instrument at these exact points?
Each metric call sits at the precise moment the state transition happens. Incrementing ConnectedPlayers after Accept() succeeds ensures you only count real connections.
Decrementing ActiveGames inside the if done block means the gauge drops only when the game is truly over. If you placed it after delete(h.games, p.gameID), a crash between the delete and the decrement would leave the gauge permanently off by one.
- Save internal/hub/hub.go.
- Restart your server by pressing Ctrl+C in the terminal where it is running, then start it again:
go run cmd/server/main.go
- Open http://localhost:8080/metrics in your browser. You should see Prometheus text output including lines like connectfour_active_games 0 and connectfour_connected_players 0.
Not seeing your custom metrics?
If /metrics returns a 404, confirm you added the mux.Handle("/metrics", promhttp.Handler()) line in main.go and saved the file.
If the page loads but your connectfour_ metrics are missing, make sure metrics.Register() is called before the server starts.
help me debug my missing Prometheus metrics
✔️ Awesome, I've got everything!
Great. Double check you have saved both internal/metrics/metrics.go and internal/hub/hub.go.
ⓧ I'd like to double check the full code
Here is the complete internal/metrics/metrics.go:
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
)
var (
ActiveGames = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "connectfour_active_games",
Help: "Number of currently active games",
})
ConnectedPlayers = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "connectfour_connected_players",
Help: "Number of currently connected players",
})
MoveDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "connectfour_move_duration_seconds",
Help: "Time taken to process a move",
Buckets: prometheus.DefBuckets,
})
)
func Register() {
prometheus.MustRegister(ActiveGames)
prometheus.MustRegister(ConnectedPlayers)
prometheus.MustRegister(MoveDuration)
}
Here is the complete cmd/server/main.go:
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
"connectfour/internal/hub"
"connectfour/internal/metrics"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
metrics.Register()
h := hub.New()
go h.Run()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("/ws", h.HandleWebSocket)
mux.Handle("/metrics", promhttp.Handler())
mux.Handle("/", http.FileServer(http.Dir("web")))
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go func() {
slog.Info("server starting", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
slog.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)
}
Here is the complete internal/hub/hub.go:
package hub
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
"connectfour/internal/game"
"connectfour/internal/metrics"
)
var gameCounter atomic.Int64
type Player struct {
conn *websocket.Conn
color int
gameID string
send chan []byte
}
type Hub struct {
mu sync.Mutex
queue chan *Player
games map[string]*gameRoom
}
type gameRoom struct {
game *game.Game
players [2]*Player
}
type clientMsg struct {
Type string `json:"type"`
Col int `json:"col,omitempty"`
}
type serverMsg struct {
Type string `json:"type"`
Color string `json:"color,omitempty"`
Board [6][7]int `json:"board,omitempty"`
Turn string `json:"turn,omitempty"`
Winner string `json:"winner,omitempty"`
Done bool `json:"done,omitempty"`
}
func New() *Hub {
return &Hub{
queue: make(chan *Player, 100),
games: make(map[string]*gameRoom),
}
}
func (h *Hub) Run() {
for {
p1 := <-h.queue
p2 := <-h.queue
id := fmt.Sprintf("game-%d", gameCounter.Add(1))
g := game.New(id)
p1.color = 1
p1.gameID = id
p2.color = 2
p2.gameID = id
room := &gameRoom{game: g, players: [2]*Player{p1, p2}}
h.mu.Lock()
h.games[id] = room
h.mu.Unlock()
metrics.ActiveGames.Inc()
slog.Info("game created", "game_id", id)
msg1, _ := json.Marshal(serverMsg{Type: "matched", Color: "red"})
msg2, _ := json.Marshal(serverMsg{Type: "matched", Color: "yellow"})
p1.send <- msg1
p2.send <- msg2
}
}
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: []string{"*"},
})
if err != nil {
slog.Error("websocket accept failed", "err", err)
return
}
metrics.ConnectedPlayers.Inc()
slog.Info("player connected", "remote", r.RemoteAddr)
p := &Player{
conn: conn,
send: make(chan []byte, 16),
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go h.writePump(ctx, p)
h.readPump(ctx, p)
metrics.ConnectedPlayers.Dec()
slog.Info("player disconnected", "remote", r.RemoteAddr)
}
func (h *Hub) writePump(ctx context.Context, p *Player) {
for {
select {
case msg, ok := <-p.send:
if !ok {
return
}
writeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
p.conn.Write(writeCtx, websocket.MessageText, msg)
cancel()
case <-ctx.Done():
return
}
}
}
func (h *Hub) readPump(ctx context.Context, p *Player) {
defer p.conn.CloseNow()
for {
_, data, err := p.conn.Read(ctx)
if err != nil {
return
}
var msg clientMsg
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
switch msg.Type {
case "join":
h.queue <- p
case "move":
h.handleMove(p, msg.Col)
}
}
}
func (h *Hub) handleMove(p *Player, col int) {
h.mu.Lock()
room, ok := h.games[p.gameID]
h.mu.Unlock()
if !ok {
return
}
start := time.Now()
_, _, err := room.game.MakeMove(col, p.color)
metrics.MoveDuration.Observe(time.Since(start).Seconds())
if err != nil {
slog.Warn("invalid move", "game_id", p.gameID, "player", p.color, "col", col, "err", err)
errMsg, _ := json.Marshal(map[string]string{"type": "error", "message": err.Error()})
p.send <- errMsg
return
}
board, turn, winner, done := room.game.GetState()
turnStr := "red"
if turn == 2 {
turnStr = "yellow"
}
winnerStr := ""
if winner == 1 {
winnerStr = "red"
} else if winner == 2 {
winnerStr = "yellow"
}
update, _ := json.Marshal(serverMsg{
Type: "update",
Board: board,
Turn: turnStr,
Winner: winnerStr,
Done: done,
})
for _, player := range room.players {
if player != nil {
player.send <- update
}
}
if done {
slog.Info("game finished", "game_id", p.gameID, "winner", winnerStr)
metrics.ActiveGames.Dec()
h.mu.Lock()
delete(h.games, p.gameID)
h.mu.Unlock()
} else {
slog.Info("move made", "game_id", p.gameID, "player", p.color, "col", col)
}
}
Set up the Grafana dashboard
Your metrics are flowing to Prometheus every 5 seconds. Now you will connect Grafana to Prometheus and build a dashboard that visualizes your game server in real time.
- Open http://localhost:3000 in your browser to access Grafana.
- Log in with username admin and password admin. If prompted to change the password, click **Skip**.
- Click **Connections** in the left-side menu.
- Click **Add new connection**.
- Enter Prometheus in the search bar and select **Prometheus data source**.
- Click **Add new data source** in the upper right.
You are now on the Settings tab for the new Prometheus data source.
- In the **Prometheus server URL** field, enter http://prometheus:9090. This works because Grafana and Prometheus share the same Docker network.
- Scroll to the bottom and click **Save & test**.
- You should see a green success message confirming the data source is working.
Why http://prometheus:9090 and not localhost?
Grafana runs inside a Docker container. From inside that container, localhost refers to the container itself, not your host machine.
Docker Compose puts both services on the same network, so Grafana can reach Prometheus by its service name prometheus on port 9090.
Now create a dashboard with three panels.
- Click **Dashboards** in the left-side menu.
- Click **New** and select **New Dashboard**.
- Click the **Add new element** icon (the blue plus sign) and drag a panel onto the dashboard.
- Click **Configure visualization** on the new panel.
- In the query editor, enter connectfour_active_games as the metric.
- Select the **Time series** visualization.
- Enter Active Games as the panel title.
- Click **Back to dashboard** to return to the dashboard view.
- Repeat the process to add a second panel with the metric connectfour_connected_players and title Connected Players.
- Add a third panel. For this one, enter histogram_quantile(0.95, rate(connectfour_move_duration_seconds_bucket[5m])) as the query. Title it Move Latency p95.
- Click **Save** in the top right corner.
- Enter Connect Four as the dashboard title and click **Save**.
- Click **Exit edit** to finish.
What is that p95 query doing?
histogram_quantile(0.95, rate(...)) calculates the 95th percentile latency from the histogram buckets. This means 95% of moves process faster than the value shown on the graph.
This is the standard way to compute latency percentiles in Prometheus. The [5m] window smooths the rate calculation over 5 minutes so the graph is not too spiky.
- To generate some data, open two browser tabs to http://localhost:8080 and play a quick game.
- Return to your Grafana dashboard. Within 5-10 seconds you should see the Active Games and Connected Players panels update.
Dashboard showing No data?
First confirm your game server is still running and serving metrics at http://localhost:8080/metrics.
If metrics are available but Grafana shows nothing, check that your data source URL is http://prometheus:9090 (not http://localhost:9090).
help me fix my Grafana dashboard showing no data
Your game server now has full observability. Every game, every connection, and every move is measured. Next up, you will tackle the hardest distributed systems problem: handling partial failures when a player disconnects mid-game.
Secret mission
Add Graceful Disconnection and Reconnection
In production distributed systems, network connections drop constantly. Tabs crash. WiFi hiccups. A naive server would destroy the game state the instant a WebSocket closes. Your mission: implement a 30-second reconnection grace period so a player who accidentally closes their tab can rejoin their game without losing progress.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker containers: Prometheus (prom/prometheus:v3.12.0) and Grafana (grafana/grafana:13.0.2).
- Local project files: the connectfour folder on your Desktop.
- Go server process running on port 8080.
Keep everything running
No action needed. Choose this if you are still actively experimenting with the game server, adding new features, or testing the observability stack.
- Your Go server continues running and accepting WebSocket connections on port 8080.
- Prometheus and Grafana remain accessible at localhost:9090 and localhost:3000.
- Docker Desktop will use some memory while the containers are running.
Pause - I'll come back to this later
Shut down running processes to free up memory and CPU. Your project files and container data stay intact so you can resume later.
- Stop the Go server by pressing Ctrl+C in the terminal where it is running.
- Stop the Docker Compose stack by running this command from your connectfour folder:
docker compose stop
When you want to resume, run docker compose start from the same folder and restart your Go server.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your containers, volumes, and project files permanently.
- Stop the Go server by pressing Ctrl+C in the terminal where it is running.
- Remove the Docker Compose containers and volumes by running this command from your connectfour folder:
docker compose down -v
- Delete the project folder by running:
cd ~/Desktop
rm -rf connectfour
On Windows, use this command instead:
cd ~\Desktop
rmdir /s /q connectfour
That's a Wrap!
That's a Wrap!
Nice work! You've built a multiplayer game platform that demonstrates real distributed systems engineering in Go.
You've learned how to:
- Build a concurrent WebSocket game server in Go that manages matchmaking queues and multiple simultaneous game rooms.
- Implement a mutex-based state machine with atomic transitions and real-time move broadcasting to connected players.
- Instrument a live system with Prometheus metrics and visualize game behaviour through Grafana dashboards.
- (Secret Mission) Implement disconnect/reconnect handling with timeout-based graceful degradation, proving you can reason about partial failure in distributed systems.
Ready to quiz yourself?