Logo

Efficient Game State Encoding with Bitboards for Multiplayer

How bitboards and card bitsets compress game state to a fraction of JSON size for faster multiplayer sync and cloud saves.

Rob Helmer

Rob Helmer

3/9/2026 Β· 7 min read

Tags:


Illustration of an astronaut cat compressing game pieces in space.

Stellar Whiskersβ€”my casual gaming site with no ads or trackingβ€”has been my testing ground for exploring game development approachesβ€”from C and SDL to Godot to pure web implementations. I’ve written about building the Whiskers C++ game engine from scratch and how I’m using formal specifications and agentic playtesting to validate game logic.

But adding multiplayer and cloud saves introduces a new challenge: how do you transmit game state quickly while minimizing bandwidth?

The answer is bitboards for board games and card bitsets for card games. Here’s how they work.

The Problem with JSON

Traditional web-based multiplayer games send game state as JSON objects. For a Reversi board, that might look like:

{
  "board": [
    [0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 1, 2, 0, 0, 0],
    [0, 0, 0, 2, 1, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0]
  ],
  "currentPlayer": 1,
  "validMoves": [[2, 3], [3, 2], [4, 5], [5, 4]]
}

This is verbose (~250 bytes), slow to parse, and expensive to transmit at scale.

Bitboards: 64 Bits for an 8Γ—8 Board

Board games like Reversi have a natural representation as bitboardsβ€”64-bit integers where each bit represents a square on the board.

For an 8Γ—8 board like Reversi:

Reversi board game

Here’s the comparison:

graph LR
    subgraph "Traditional JSON State"
        A["{ board: [[0,0,0...], ...] }"]
    end

    subgraph "Bitboard State"
        B["black: 0x0000100000000000<br/>white: 0x0000080000000000"]
    end

    A -->|~250 bytes| C["JSON over wire"]
    B -->|~85 bytes| D["Base64 over wire"]

That’s 3x smaller for the same information.

Visualizing Bit Positions

The bitboard is stored as a 64-bit integer, where each bit maps to a square on the board. Here’s the standard Reversi starting position (matching the image above):

0   1   2   3   4   5   6   7     ← bit positions (row Γ— 8 + col)
  β”Œβ”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”
0 β”‚ 0 β”‚ 1 β”‚ 2 β”‚ 3 β”‚ 4 β”‚ 5 β”‚ 6 β”‚ 7 β”‚ 0
  β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
1 β”‚ 8 β”‚ 9 β”‚10 β”‚11 β”‚12 β”‚13 β”‚14 β”‚15 β”‚ 1
  β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
2 β”‚16 β”‚17 β”‚18 β”‚β¦Ώ19β”‚20 β”‚21 β”‚22 β”‚23 β”‚ 2  ← valid move
  β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
3 β”‚24 β”‚25 β”‚β¦Ώ26β”‚β—‹27│●28β”‚29 β”‚30 β”‚31 β”‚ 3  ← valid, white, black
  β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
4 β”‚32 β”‚33 β”‚34 │●35β”‚β—‹36β”‚β¦Ώ37β”‚38 β”‚39 β”‚ 4  ← black, white, valid
  β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
5 β”‚40 β”‚41 β”‚42 β”‚β¦Ώ43β”‚44 β”‚45 β”‚46 β”‚47 β”‚ 5  ← valid move
  β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
6 β”‚48 β”‚49 β”‚50 β”‚51 β”‚52 β”‚53 β”‚54 β”‚55 β”‚ 6
  β”œβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”Όβ”€β”€β”€β”€
7 β”‚56 β”‚57 β”‚58 β”‚59 β”‚60 β”‚61 β”‚62 β”‚63 β”‚ 7
  β””β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”˜

Legend:
β—‹ White pieces (bits 27, 36)
● Black pieces (bits 28, 35)
β¦Ώ Valid moves (bits 19, 26, 37, 43)

Two 64-bit integers encode the entire board stateβ€”one for black pieces, one for white. Game state updates become simple bitwise operations:

// Bitboard approach for Reversi
type BoardState = bigint;  // 64-bit unsigned integer

// Convert row/col to bit position
function toBitPosition(row: number, col: number): bigint {
    return BigInt(1) << BigInt(row * 8 + col);
}

// Check if a square is occupied using bitwise AND
function isOccupied(board: BoardState, row: number, col: number): boolean {
    const position = toBitPosition(row, col);
    return (board & position) !== BigInt(0);
}

// Set a piece using bitwise OR
function setPiece(board: BoardState, row: number, col: number): BoardState {
    const position = toBitPosition(row, col);
    return board | position;
}

// Clear a square using bitwise AND with NOT
function clearSquare(board: BoardState, row: number, col: number): BoardState {
    const position = toBitPosition(row, col);
    return board & ~position;
}

All operations are O(1) and work on the entire board at onceβ€”no loops, no 2D array indexing. For example, to check if a player has a winning line of three pieces, you can precompute bitmasks for all possible winning lines. Then, a simple bitwise AND operation between a player’s board state and each winning line mask will tell you if that player has won, instead of iterating through the board with nested loops.

Card Location Encoding for Card Games

For card games like Solitaire, I use a location-based encoding. Each of the 52 cards is assigned a unique ID (0-51), and we store its location and visibility state:

Solitaire card game

// Card location encoding (5 bits per card)
// 4 bits for location (0-12): stock, waste, tableau 0-6, foundations 0-3
// 1 bit for faceUp status

const LOCATION_STOCK = 0;
const LOCATION_WASTE = 1;
const LOCATION_TABLEAU_OFFSET = 2;    // Tableau 0-6 β†’ values 2-8
const LOCATION_FOUNDATION_OFFSET = 9; // Foundations 0-3 β†’ values 9-12

function encodeCardLocation(location: number, faceUp: boolean): number {
    return (location << 1) | (faceUp ? 1 : 0);  // 5 bits
}

// Full Solitaire state: 52 cards Γ— 5 bits = 260 bits + 4-bit version = 264 bits
// Encoded as ~300 bytes Base64 (vs ~2,500 bytes JSON)

This approach trades the elegance of bitboards for practical flexibilityβ€”cards can move between piles, change visibility, and the encoding remains compact.

Network Efficiency Gains

The difference is substantial for full state encoding:

GameJSON SizeOur EncodingReduction
Rocket Reversi~250 bytes~85 bytes3x
Crystalign~600 bytes~60 bytes10x
Solitaire~2,500 bytes~300 bytes8x
Meteor Bounce~50 bytes~8 bytes6x

For cloud saves with debounced sync (5 second delay), this means:

  • Lower bandwidth costs: 3-10x less data per save
  • Faster load times: Less data to fetch and parse
  • Better mobile experience: Critical for users on cellular networks
  • Reduced D1 operations: Smaller payloads = faster writes
sequenceDiagram
    participant Client as Game Client
    participant LS as LocalStorage
    participant Cloud as Cloud Save Worker
    participant DB as D1 Database

    Client->>LS: Save encoded state (immediate)
    Client->>Cloud: Debounced sync (5s delay)
    Cloud->>DB: Store Base64 string
    DB-->>Cloud: Confirmation
    Cloud-->>Client: Save confirmed

The client saves to localStorage immediately for instant persistence, then syncs to the cloud with a 5-second debounce to reduce request frequency.

What’s Next

Efficient encoding solves the performance problemβ€”but what about privacy? When users log in, their email addresses and personal data need protection too.

In Part 2, I’ll cover how HttpOnly cookies with CHIPS prevent XSS attacks and cross-site tracking. Then in Part 3 (coming March 24), we’ll dive into HPKE encryption for protecting PII so the server can’t read user data even if compromised.

Continue the series: