Game Servers at Scale · Lesson 01 · 20 min
The Server Has a Heartbeat
Build a shared world that runs on its own clock in one Go process — then compute the wall that stops it at 550 players.
You know how to build a server where someone chops a tree:
POST /trees/42/chop → UPDATE trees SET chops = chops + 1 WHERE id = 42
→ 200 OK
That works. It is also the wrong animal, and the reason is not performance — it is that nobody else finds out. A second player standing next to that tree learns about the chop only if they ask. In a shared world, not asking is the normal state: a player is looking at a hundred things they did not request and expects all of them to be true.
By the end of this lesson you will have a Go server holding a world in memory, running on its own clock, with two browser tabs watching the same trees fall. Then you will multiply four numbers together and find out exactly how many players it can hold before it dies. Both halves matter — the second one is the habit this whole course is really about.
01 The server you know is asleep
The server you have built before has no opinion about time. It sits in accept() until
someone knocks, does a piece of work proportional to that knock, and goes back to sleep.
Between requests it holds nothing; the database holds everything.
A game server inverts all three of those.
| Request/response server | Shared-world server | |
|---|---|---|
| Runs when | someone asks | always |
| Holds state | in the database | in its own memory |
| Work per second | proportional to requests | proportional to players, whether or not they act |
| Client’s role | asks for data | receives data it did not ask for |
| Wrong answer costs | one user’s page | everyone’s shared reality |
In gpu-prog the hard part
was giving up the loop — you wrote the body and the hardware ran it. Here the move is
the exact opposite, and it is just as uncomfortable: you write the loop.
You own a for that never ends, and everything else in the process exists to
feed it or to carry its output away.
02 Three properties, not one design
Everything in this course follows from three commitments made now.
Authoritative. The client asks; the server decides. Gambetta’s statement of the model is the tightest one written:
your game client sends inputs (key presses, commands) to the server, the server runs the game, and you send the results back to the clients — Client-Server Game Architecture
This is not primarily an anti-cheat measure, though it is that too. It is what makes a shared world possible at all: if two players chop the last swing of the same tree in the same 100 ms, exactly one of them fells it, and only a single decision-maker can say which.
Resident. The world lives in RAM. Not cached in RAM — lives there. The database, when it arrives in lesson 7, is a place you write backups to, not the thing you read the current chop count from. A round trip to Postgres inside a loop running ten times a second, for thousands of players, is not slow. It is arithmetic that does not close.
Continuous. The loop runs whether or not anyone did anything. That fixed heartbeat is the tick, and its frequency is the tick rate. Ten per second is right for a game about felling trees; a shooter needs 60 and pays for it in every number below.
03 One goroutine owns the world
Here is the structural decision, and it is the one that survives to the end of the course.
You have thousands of connections, each with its own goroutine, all wanting to touch the
same trees. The instinct from web work is a sync.RWMutex around the world. Resist it.
Instead: exactly one goroutine may read or write world state, and it is the one running
the tick loop. Connection goroutines never touch a tree. They parse an input, push it down
a channel, and go back to reading their socket.
connection goroutine ─┐
connection goroutine ─┼─→ commands channel ─→ [ TICK LOOP ] ─→ per-player out channels
connection goroutine ─┘ owns the world
Three things fall out of this for free, and they are worth naming because you will lean on all of them later:
- No locks, so no lock contention — the thing that would otherwise decide your ceiling.
- The tick is a natural transaction boundary. Every observer sees the world between ticks, never halfway through one.
- The simulation is deterministic in its inputs. Same commands in the same order gives the same world, which is what makes crash recovery (lesson 7) and debugging possible.
The rule is easy to state and easy to break by accident. The moment a handler
goroutine reads player.X to log it, or a metrics endpoint ranges over the
tree map, you have a data race that will not show up until production. Run with
go run -race . from day one; it catches exactly this.
04 Build it
Three files. Make a directory, then go mod init woodcutters and
go get github.com/coder/websocket.
coder/websocket takes a
context.Context on every call and handles concurrent writes internally,
which removes a class of bug you would otherwise meet in about a week. It is the current
default recommendation for new Go code over gorilla and gobwas.
main.go — the world and its loop:
package main
import (
"encoding/json"
"flag"
"log"
"math/rand"
"net/http"
"sync/atomic"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
const (
tickRate = 10
tickPeriod = time.Second / tickRate
worldW = 900.0
worldH = 600.0
chopsToFell = 5
reach = 40.0
)
type Tree struct {
ID int `json:"id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Chops int `json:"chops"`
}
type Player struct {
ID int64 `json:"id"`
X float64 `json:"x"`
Y float64 `json:"y"`
// Unexported, so the JSON encoder ignores it. Only the tick loop sends here.
out chan []byte
}
// Input is what a client asks for. Note the word: asks.
type Input struct {
Kind string `json:"kind"` // "move" or "chop"
Tree int `json:"tree"`
X float64 `json:"x"`
Y float64 `json:"y"`
}
type command struct {
from *Player
input Input
}
type Hub struct {
join chan *Player
leave chan *Player
commands chan command
// Everything below is owned by run(). No other goroutine may touch it.
players map[*Player]bool
trees map[int]*Tree
}
func newHub() *Hub {
h := &Hub{
join: make(chan *Player),
leave: make(chan *Player),
commands: make(chan command, 256),
players: make(map[*Player]bool),
trees: make(map[int]*Tree),
}
for i := 1; i <= 200; i++ {
h.trees[i] = &Tree{ID: i, X: rand.Float64() * worldW, Y: rand.Float64() * worldH}
}
return h
}
// run is the heartbeat. It is the only goroutine that reads or writes the world.
func (h *Hub) run() {
ticker := time.NewTicker(tickPeriod)
defer ticker.Stop()
for {
select {
case p := <-h.join:
h.players[p] = true
case p := <-h.leave:
if h.players[p] {
delete(h.players, p)
close(p.out)
}
case c := <-h.commands:
h.apply(c)
case <-ticker.C:
h.broadcast()
}
}
}
func (h *Hub) apply(c command) {
switch c.input.Kind {
case "move":
// Deliberately credulous for now: the client says where it is and we believe it.
// Lesson 8 takes movement away from the client; lesson 9 explains what that fixes.
c.from.X = clamp(c.input.X, worldW)
c.from.Y = clamp(c.input.Y, worldH)
case "chop":
tree, ok := h.trees[c.input.Tree]
if !ok {
return // already felled, or never existed
}
dx, dy := c.from.X-tree.X, c.from.Y-tree.Y
if dx*dx+dy*dy > reach*reach {
return // out of reach — the client asked, the server said no
}
tree.Chops++
if tree.Chops >= chopsToFell {
delete(h.trees, tree.ID)
}
}
}
// broadcast encodes the world once, then hands the same bytes to every player.
func (h *Hub) broadcast() {
snapshot := struct {
Trees []*Tree `json:"trees"`
Players []*Player `json:"players"`
}{
Trees: make([]*Tree, 0, len(h.trees)),
Players: make([]*Player, 0, len(h.players)),
}
for _, t := range h.trees {
snapshot.Trees = append(snapshot.Trees, t)
}
for p := range h.players {
snapshot.Players = append(snapshot.Players, p)
}
frame, err := json.Marshal(snapshot)
if err != nil {
log.Println("encode:", err)
return
}
for p := range h.players {
select {
case p.out <- frame:
default:
// This client is not draining fast enough. Drop the frame rather than let
// one slow socket stall the whole world. Lesson 6 does something better.
}
}
}
func clamp(v, max float64) float64 {
if v < 0 {
return 0
}
if v > max {
return max
}
return v
}
The socket half. Note how little it does — it is a translator between a TCP connection and two channels:
var addr = flag.String("addr", ":8080", "listen address")
var nextPlayerID atomic.Int64
func (h *Hub) serveWS(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
defer conn.CloseNow()
ctx := r.Context()
p := &Player{
ID: nextPlayerID.Add(1),
X: rand.Float64() * worldW,
Y: rand.Float64() * worldH,
out: make(chan []byte, 8),
}
h.join <- p
defer func() { h.leave <- p }()
// Writer: drains this player's queue until the hub closes it on leave.
go func() {
for frame := range p.out {
if err := conn.Write(ctx, websocket.MessageText, frame); err != nil {
conn.CloseNow() // unblocks the reader below
return
}
}
}()
// Reader: this goroutine never touches world state. It only forwards.
for {
var in Input
if err := wsjson.Read(ctx, conn, &in); err != nil {
return
}
h.commands <- command{from: p, input: in}
}
}
func main() {
flag.Parse()
hub := newHub()
go hub.run()
http.HandleFunc("/ws", hub.serveWS)
http.Handle("/", http.FileServer(http.Dir(".")))
log.Printf("listening on http://localhost%s", *addr)
log.Fatal(http.ListenAndServe(*addr, nil))
}
index.html, in the same directory — a dumb terminal, which is the whole point:
<!doctype html>
<meta charset="utf-8">
<title>Woodcutters</title>
<style>body { margin: 0; background: #10140f } canvas { display: block; cursor: crosshair }</style>
<canvas id="view" width="900" height="600"></canvas>
<script>
const canvas = document.getElementById("view");
const ctx = canvas.getContext("2d");
const ws = new WebSocket(`ws://${location.host}/ws`);
let world = { trees: [], players: [] };
ws.onmessage = (event) => { world = JSON.parse(event.data); draw(); };
canvas.onclick = (event) => {
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left, y = event.clientY - rect.top;
const tree = world.trees.find((t) => Math.hypot(t.x - x, t.y - y) < 14);
// Ask to walk there. Then ask to chop. The server may refuse either.
ws.send(JSON.stringify({ kind: "move", x: tree ? tree.x : x, y: tree ? tree.y : y }));
if (tree) ws.send(JSON.stringify({ kind: "chop", tree: tree.id }));
};
function draw() {
ctx.fillStyle = "#10140f";
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (const t of world.trees) {
ctx.fillStyle = t.chops === 0 ? "#3f8f52" : "#8a7a2f";
ctx.beginPath();
ctx.arc(t.x, t.y, 10 - t.chops, 0, Math.PI * 2);
ctx.fill();
}
for (const p of world.players) {
ctx.fillStyle = "#e8dcc0";
ctx.fillRect(p.x - 4, p.y - 4, 8, 8);
}
}
</script>
go run -race ., then open http://localhost:8080 in two
windows side by side. Click a tree five times in one window and watch it shrink and
vanish in the other. Nothing in the second window asked for that. That is the whole
inversion, on screen.
Port 8080 is busy on this machine, so you will want
go run -race . -addr :9010 — that is what the addr flag is
there for. If you would rather not retype the listings, the same three files are already
sitting in game-server/code/0001-woodcutters/.
05 Now multiply it out
The server works. The interesting question is what it costs, and that question is a multiplication you can do in your head.
Each tick, every player receives a snapshot containing every player. Call a player’s
encoded state 40 bytes — {"id":1284,"x":412.8,"y":301.5} is about that:
That squared term is the whole story. Your CPU is bored, your memory is fine, and you died
at about 550 players — on a mission that says thousands. No amount of Go tuning moves a
quadratic. The fix is not to send faster; it is to send less, and the lever is the second
P: nobody standing in one clearing needs the position of someone three kilometres away.
Drag the numbers yourself. Watch what changes the shape of the answer and what merely scales it:
Notice what the lab also shows: capping neighbours at 50 is not sufficient on its own. At 10 000 players it still asks for 200 MB/s, because the recipient count keeps growing. That is what lesson 3 is for — sending changes instead of snapshots, since most of those 50 neighbours are standing still on any given tick.
Before scrolling back: why can't a mutex around the world fix the 550-player wall?
Because the wall is not contention, it is bytes. A perfectly parallel, lock-free version of this server sends exactly the same 1.6 GB/s at 2 000 players. Recognising which resource actually ran out — and refusing to optimise the other three — is most of the skill this course is teaching.
Check yourself
Client-Server Game Architecture by Gabriel Gambetta — ten minutes, and the clearest statement of the authoritative model anywhere. Stop after the first article; the three that follow solve a latency problem this game does not have yet.
I am your teacher for this, not just the author of the page. If the channel ownership
rule feels arbitrary, if you want to see what -race catches when you break
it deliberately, or if you want to argue that 10 ticks a second is too slow for felling a
tree — ask. That last one is a genuinely open design question and worth a conversation.
Next: the world stops being 900×600 and becomes endless, the second P becomes a
constant, and the wall at 550 players moves somewhere far more interesting. New vocabulary
lands in the glossary as you earn it.