Back to home

Building a Web3 Slither Game

How I built a real-time wagered snake game with a Go game server, binary WebSocket protocol, Solana cash-ins and cash-outs, region-aware matchmaking, and server-side safety rails.

golang
websocket
nextjs
solana
games

Overview

This project looks like a web3 Slither.io-style arcade game, but the hard part was not drawing a snake on a canvas.

The hard part was making a real-time game where the server owns the outcome, the client only sends intent, players can enter with different wager tiers, value can move from one snake to another during play, and cashing out is a gameplay decision instead of a button that lets someone disappear from danger.

That shaped the whole system.

The frontend is a Next.js app with the lobby, wallet flow, server picker, wardrobe, friends, leaderboards, and the canvas game client. The API is a Bun/Elysia backend for users, deposits, cosmetics, friends, affiliates, stats, and matchmaking data. The actual simulation runs in a separate Go game server, and Solana payouts are handled by another Go worker so the game loop is not responsible for signing transactions.

The interesting part is the split:

Use TypeScript for product surfaces. Use Go for the 60 tick game loop. Use Redis as the shared nervous system between API nodes, game nodes, and the transaction worker. Keep the client visually smooth, but make the server authoritative about movement, collisions, value, death, and cashout state.

Gameplay and lobby UI for the web3 slither game.

Game Server

The simulation moved out of the web API and into an authoritative Go loop.

What I Built

The project is a multiplayer snake arena with entry-fee rooms, live spectators, cosmetics, friends, leaderboards, chat, affiliates, cash-ins, and cash-outs.

Players deposit SOL through the web app. The API verifies the on-chain transfer and records an accepted cash-in. When the player opens the game socket, the Go server consumes that accepted deposit, chooses the matching entry-fee room, creates a game session, and spawns the snake at a safe edge position.

From that point forward, the browser is not trusted with game state. It can send:

  • a target direction
  • boost start and stop
  • cashout start and stop
  • window size
  • chat
  • spectator controls

It cannot send its position, score, length, balance, kill count, collision result, or cashout result.

That boundary is the main safety feature. The canvas is allowed to be pretty and predictive. The server is the source of truth.

The 60 Tick Loop

The first version of the game server lived too close to the TypeScript backend. That was convenient until it had to keep a stable 60 ticks per second while handling sockets, movement, food, collisions, cashout timers, database updates, and room state.

The Go server made the game loop explicit.

Each room owns a GameEngine. The WebSocket server runs a frame loop at time.Second / 60, advances every active room, and sends each session a view clipped to that player’s viewport. Sessions have buffered outbound channels, ping/pong deadlines, and a separate write loop so one slow socket does not directly own the whole frame.

The game engine itself is guarded by a mutex because every tick touches shared state: snakes, food, world radius, boost drops, minimap cache, and collision results.

The movement model is deliberately small:

func (e *GameEngine) ChangeVelocity(id string, velocity protocol.Coordinate) {
  e.Mu.Lock()
  defer e.Mu.Unlock()

  if snake, ok := e.Snakes[id]; ok {
    now := time.Now().UnixNano() / 1e6
    lastUpdate := e.LastVelocityUpdate[id]

    if now-lastUpdate < MinUpdateInterval {
      return
    }

    e.LastVelocityUpdate[id] = now

    angle := math.Atan2(velocity.Y, velocity.X)
    snake.TargetAngle = &angle
  }
}
func (e *GameEngine) ChangeVelocity(id string, velocity protocol.Coordinate) {
  e.Mu.Lock()
  defer e.Mu.Unlock()

  if snake, ok := e.Snakes[id]; ok {
    now := time.Now().UnixNano() / 1e6
    lastUpdate := e.LastVelocityUpdate[id]

    if now-lastUpdate < MinUpdateInterval {
      return
    }

    e.LastVelocityUpdate[id] = now

    angle := math.Atan2(velocity.Y, velocity.X)
    snake.TargetAngle = &angle
  }
}

The client asks to turn. The server rate-limits that input and stores a target angle. On each tick, the snake turns toward that angle by a capped amount, with longer snakes turning more slowly.

That one decision removes a lot of cheat surface. A modified client can spam inputs, but it still cannot teleport, skip turn radius, invent food, or decide who died.

Making Boost Feel Fast Without Breaking the Snake

Boosting created a surprisingly practical rendering problem.

If the server simply moves the head twice as far and removes one tail segment, the snake can look like it is stretching or skipping because body segments are farther apart than normal. The workaround was to split boosted movement into sub-steps.

steps := int(math.Round(accelerateFactor))
if steps < 1 {
  steps = 1
}

if steps == 1 {
  if shouldGrow {
    snake.Move(newHeadCandidate)
  } else {
    snake.MoveAndMaintainLength(newHeadCandidate)
  }
} else {
  origHead := head
  dx := (newHeadCandidate.X - origHead.X) / float64(steps)
  dy := (newHeadCandidate.Y - origHead.Y) / float64(steps)

  for i := 1; i <= steps; i++ {
    interim := protocol.Coordinate{
      X: origHead.X + dx*float64(i),
      Y: origHead.Y + dy*float64(i),
    }

    if i == steps && shouldGrow {
      snake.Move(interim)
    } else {
      snake.MoveAndMaintainLength(interim)
    }
  }
}
steps := int(math.Round(accelerateFactor))
if steps < 1 {
  steps = 1
}

if steps == 1 {
  if shouldGrow {
    snake.Move(newHeadCandidate)
  } else {
    snake.MoveAndMaintainLength(newHeadCandidate)
  }
} else {
  origHead := head
  dx := (newHeadCandidate.X - origHead.X) / float64(steps)
  dy := (newHeadCandidate.Y - origHead.Y) / float64(steps)

  for i := 1; i <= steps; i++ {
    interim := protocol.Coordinate{
      X: origHead.X + dx*float64(i),
      Y: origHead.Y + dy*float64(i),
    }

    if i == steps && shouldGrow {
      snake.Move(interim)
    } else {
      snake.MoveAndMaintainLength(interim)
    }
  }
}

The player still gets the speed benefit, but the body stays visually dense enough for collision and rendering to feel consistent.

Boost also has a cost. Once the snake is above the minimum length, boosting periodically pops tail segments and drops small food pieces behind the player. Food dropped by your own boost cannot be immediately re-eaten while accelerating, which avoids a trivial boost-loop exploit.

Binary Protocol

WebSockets carried typed binary messages instead of large JSON snapshots.

Binary Messages Instead of JSON

The game state updates are frequent and repetitive. JSON is easy to debug, but it is wasteful when the same fields are sent sixty times per second.

So the Go server and TypeScript client share a compact protocol with numeric message types:

const (
  START            MessageType = 0x01
  VELOCITY         MessageType = 0x03
  WINDOW_SIZE      MessageType = 0x04
  ACCELERATE_BEGIN MessageType = 0x05
  ACCELERATE_END   MessageType = 0x06
  CASHOUT_START    MessageType = 0x07
  CASHOUT_STOP     MessageType = 0x08
  CHAT_MESSAGE     MessageType = 0x09

  VIEW_UPDATE        MessageType = 0x10
  MAP_UPDATE         MessageType = 0x11
  INITIAL_SETUP      MessageType = 0x12
  WORLD_UPDATE       MessageType = 0x13
  CASHOUT_PROGRESS   MessageType = 0x14
  CASHOUT_COMPLETE   MessageType = 0x15
  PLAYER_KILLED      MessageType = 0x17
  LEADERBOARD_UPDATE MessageType = 0x18
)
const (
  START            MessageType = 0x01
  VELOCITY         MessageType = 0x03
  WINDOW_SIZE      MessageType = 0x04
  ACCELERATE_BEGIN MessageType = 0x05
  ACCELERATE_END   MessageType = 0x06
  CASHOUT_START    MessageType = 0x07
  CASHOUT_STOP     MessageType = 0x08
  CHAT_MESSAGE     MessageType = 0x09

  VIEW_UPDATE        MessageType = 0x10
  MAP_UPDATE         MessageType = 0x11
  INITIAL_SETUP      MessageType = 0x12
  WORLD_UPDATE       MessageType = 0x13
  CASHOUT_PROGRESS   MessageType = 0x14
  CASHOUT_COMPLETE   MessageType = 0x15
  PLAYER_KILLED      MessageType = 0x17
  LEADERBOARD_UPDATE MessageType = 0x18
)

The server validates incoming messages before acting on them. Velocity messages need a coordinate. Window-size messages need non-zero dimensions. Entry fee tiers are constrained to the supported room values. Unknown message types are rejected.

For outgoing state, the serializer writes primitive values directly: booleans, floats, uints, RGB colors, optional fields, and length-prefixed strings. Snake bodies are delta encoded. The first body coordinate is absolute inside the viewport, and every segment after that is stored as a delta from the previous segment.

writer.WriteUint16(uint16(bodyCount))

if bodyCount > 0 {
  writer.WriteCoordinate(snake.Bodies[0])

  for i := 1; i < bodyCount; i++ {
    current := snake.Bodies[i]
    previous := snake.Bodies[i-1]
    writer.WriteCoordinate(protocol.Coordinate{
      X: current.X - previous.X,
      Y: current.Y - previous.Y,
    })
  }
}
writer.WriteUint16(uint16(bodyCount))

if bodyCount > 0 {
  writer.WriteCoordinate(snake.Bodies[0])

  for i := 1; i < bodyCount; i++ {
    current := snake.Bodies[i]
    previous := snake.Bodies[i-1]
    writer.WriteCoordinate(protocol.Coordinate{
      X: current.X - previous.X,
      Y: current.Y - previous.Y,
    })
  }
}

The browser reverses that into full coordinates with an auto-advancing DataView reader.

The useful part was not just bandwidth. It forced the game protocol to become an actual contract. There are only a few things the client is allowed to ask for, and each server message has a known shape.

Viewport Clipping

The server does not send the whole world to every player.

Each session sends its window size. The server tracks the player’s center coordinate, computes a viewport, and builds a View containing only snakes and food visible in that rectangle.

Snakes are included only if at least one body segment is visible. Food is included only if its position is inside the viewport. Coordinates are shifted into viewport-local space before they are serialized.

That keeps the update payload tied to what the player can actually see instead of the total size of the arena.

Browser Rendering

The frontend smooths a server-owned world without pretending to own it.

A Fast Canvas Client

The browser client does a lot of work, but it is all presentation work.

The GameClient owns the WebSocket, decodes binary messages, emits typed events, retries a failed connection with exponential backoff, and detects the transition from alive to dead. The renderer owns the canvas loop, camera, zoom, interpolation, cosmetics, chat bubbles, food drawing, minimap display, and adaptive frame rate.

The renderer had its own set of workarounds because cosmetics can become expensive fast.

Pattern fills are cached. Image loads are deduplicated. Large snake skins are rendered at smaller pattern-canvas sizes. Cosmetic animation updates are throttled. Old pattern and gradient caches are periodically trimmed. The renderer starts at 60 FPS, watches frame timing, and drops toward 30 FPS when the browser cannot keep up.

if (this.frameTimeHistory.length >= this.maxFrameHistory) {
  const avgFrameTime =
    this.frameTimeHistory.reduce((a, b) => a + b) /
    this.frameTimeHistory.length;

  if (avgFrameTime > 20 && this.targetFPS > 30) {
    this.targetFPS = Math.max(30, this.targetFPS - 5);
    this.frameInterval = 1000 / this.targetFPS;
  } else if (avgFrameTime < 12 && this.targetFPS < 60) {
    this.targetFPS = Math.min(60, this.targetFPS + 5);
    this.frameInterval = 1000 / this.targetFPS;
  }
}
if (this.frameTimeHistory.length >= this.maxFrameHistory) {
  const avgFrameTime =
    this.frameTimeHistory.reduce((a, b) => a + b) /
    this.frameTimeHistory.length;

  if (avgFrameTime > 20 && this.targetFPS > 30) {
    this.targetFPS = Math.max(30, this.targetFPS - 5);
    this.frameInterval = 1000 / this.targetFPS;
  } else if (avgFrameTime < 12 && this.targetFPS < 60) {
    this.targetFPS = Math.min(60, this.targetFPS + 5);
    this.frameInterval = 1000 / this.targetFPS;
  }
}

That is the browser-side version of the same principle as the backend: keep the real-time path boring. Do the flashy work, but make it easy to degrade when the player has a slow device or when a snake gets huge.

Cosmetics as Data, Not Client Trust

Cosmetics are bought and equipped through the web app, but the game server fetches the equipped set from the database when a player spawns.

That matters because the client cannot claim a paid skin by sending it over the socket. The server attaches the equipped skin, hat, trail, and eyes to the snake data it broadcasts. If a user has no skin, or selected a random skin, the Go server resolves the fallback before the game starts.

The client only renders what the server says the snake has.

Money Flow

Deposits, sessions, game value, and payouts are separate state machines.

Cash-In as a Ticket

The deposit flow is intentionally separate from the game socket.

The web API starts a PENDING cash-in record with the current SOL price. The user sends SOL to the game pool wallet. Then the API fetches the parsed Solana transaction and checks the details:

  • the destination is the game pool wallet
  • the source is the user’s wallet
  • the amount matches the pending cash-in
  • the cash-in is still pending

Only then does the record become ACCEPTED.

The game server later looks for an accepted cash-in for that user. If it finds one, it derives the entry tier from the deposit’s USD value, reserves a room slot, creates a game session, marks the cash-in as used, and spawns the player.

That gives the system a clean handoff:

The API verifies money entered the pool. The game server consumes one verified ticket. The transaction worker handles money leaving the pool.

Value Lives on the Snake

In the arena, the snake’s SolValue is the live value at risk.

When a player dies, the server removes the snake and turns some body segments into food. The dead player’s value is divided across those food pieces. Other snakes can eat that food and increase their own value.

if count > 0 {
  solPerPiece := snake.SolValue / float64(count)
  for _, food := range foodPieces {
    food.SolValue = solPerPiece
  }
}
if count > 0 {
  solPerPiece := snake.SolValue / float64(count)
  for _, food := range foodPieces {
    food.SolValue = solPerPiece
  }
}

That makes the game economy part of the simulation rather than a ledger patched on after the match. Death cause, time alive, kills, entry fee, amount cashed out, and cashout transaction ID are written back to game sessions.

Cashout Safety

Cashing out is asynchronous, delayed, and visible inside the game.

Cashout Is Not Escape

The obvious exploit in a wagered snake game is combat logging.

If a player can instantly leave after gaining value, the safest strategy is to run away the moment they are ahead. The workaround was to make cashout a vulnerable state inside the game.

When the player starts cashout, the server attaches cashout progress to the snake. During the timer, movement slows down near the end. The snake remains visible, collidable, and killable. If the timer completes, the game server removes the snake cleanly, creates a cashout request, and publishes it to Redis.

The transaction service picks it up separately.

That worker calculates the house fee, waives it for solo lobbies, applies affiliate commission when relevant, simulates or falls back to a Solana network fee, writes a processing cashout record, and sends the payment with exponential backoff.

for attempt := 0; attempt < MaxRetryAttempts; attempt++ {
  if attempt > 0 {
    delay := time.Duration(float64(InitialRetryDelay) *
      math.Pow(RetryDelayMultiplier, float64(attempt-1)))
    if delay > MaxRetryDelay {
      delay = MaxRetryDelay
    }
    time.Sleep(delay)
  }

  txID, err := solana.SendPayment(walletAddress, cashoutReq.Amount)
  if err != nil {
    lastError = err
    continue
  }

  cashoutReq.TransactionID = txID
  return nil
}
for attempt := 0; attempt < MaxRetryAttempts; attempt++ {
  if attempt > 0 {
    delay := time.Duration(float64(InitialRetryDelay) *
      math.Pow(RetryDelayMultiplier, float64(attempt-1)))
    if delay > MaxRetryDelay {
      delay = MaxRetryDelay
    }
    time.Sleep(delay)
  }

  txID, err := solana.SendPayment(walletAddress, cashoutReq.Amount)
  if err != nil {
    lastError = err
    continue
  }

  cashoutReq.TransactionID = txID
  return nil
}

The worker publishes status updates back through Redis. The game server listens for those updates and finalizes the session with the payout amount and transaction ID.

This separation is important. A delayed RPC, failed transaction, or retry sleep should not block the game loop. The game server’s job is to decide that a player earned a cashout. The transaction worker’s job is to turn that into an on-chain payment.

Safety Rails Around Realtime State

There are several smaller safety rails throughout the system:

  • accepted cash-ins are marked as used when the game session starts
  • players cannot start a new cash-in while already present in a server player set
  • rooms have fixed $1, $5, and $10 tiers
  • room capacity is reserved before the snake is added
  • disconnects end the active game session with a disconnect death cause
  • cashout failures still end the game session so the player cannot remain half-alive
  • chat messages go through filtering before display
  • the server sends player-killed events from the authoritative collision path

None of those are glamorous, but they matter more than the UI when value is attached to a match.

Regions And Rooms

Redis ties together matchmaking, server discovery, room capacity, and deployment.

Region-Aware Server Discovery

Game servers register themselves in Redis under keys that include region, subregion, and server ID. Each server advertises its host, max players, and available entry-fee tiers, then renews the key every 30 seconds.

The API exposes those registered servers to the web app. The frontend measures round-trip time to each node, stores the user’s selected server, and lets players filter by region and subregion.

That means the lobby is not hardcoded to a single game host. The game servers announce themselves, the API reads the registry, and the browser chooses the best available target.

There is a cleanup path too. When a server starts with a known ID, it removes stale registry entries for that same ID and clears old player sets. When rooms go idle after having players, the room manager recreates them so old in-memory state does not live forever.

Infrastructure Was Part of the Product

The deployment code uses Ansible to set up distributed roles: API nodes, game server nodes, CockroachDB leader/follower setup, Valkey, Nginx, TLS certificates, firewall rules, SSH keys, and Docker Compose templates.

That was not just operations polish. It matched the product architecture.

A real-time wagered game needs region-local game servers for latency, shared state for discovery and cashout messaging, and a database that can survive more than one node. The infrastructure had to support that shape instead of treating the game as one web app with a WebSocket route.

Scope

The game was the center, but the product around it made it usable.

Product Surface

The surrounding app includes a lot of normal product work that still had to connect to the realtime core:

  • Privy wallet auth and embedded wallet management
  • add-funds and cashout modals
  • server picker with latency measurement
  • spectator mode
  • wardrobe and cosmetic shop
  • friends and friend requests
  • leaderboards and user profiles
  • affiliate codes, affiliate balances, and staff review flows
  • PostHog feature flags for operational controls like maintenance mode

The important thing is that these features do not all belong in the game server.

The Go server cares about rooms, sessions, sockets, collisions, game value, and cashout requests. The TypeScript API cares about product state. The transaction worker cares about payments. The frontend pulls those pieces together into one experience.

What Made It Interesting

The most useful engineering work was in the boundaries.

The client has enough information to feel responsive, but not enough authority to cheat movement or value. The game server can run a tight loop without signing Solana transactions. The API can manage users and deposits without simulating snake collisions. Redis lets the pieces discover each other and publish status changes without becoming one giant process.

The weird parts came from that same boundary work: binary protocol compatibility between Go and TypeScript, viewport-local coordinates, sub-stepped boost movement, rate-limited target angles, stale server cleanup, cashout as a vulnerable state, and payout retries outside the frame loop.

That is what made the project more interesting than a canvas clone. It was a small arcade game wrapped around a set of systems that had to agree on one rule:

The server decides what happened.

Let's build better software for the web.

Available for full-stack web application projects.

© 2026 Nolan Martin. Crafted with SvelteKit.