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:

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:

// 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:
| Game | JSON Size | Our Encoding | Reduction |
|---|---|---|---|
| Rocket Reversi | ~250 bytes | ~85 bytes | 3x |
| Crystalign | ~600 bytes | ~60 bytes | 10x |
| Solitaire | ~2,500 bytes | ~300 bytes | 8x |
| Meteor Bounce | ~50 bytes | ~8 bytes | 6x |
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, we’ll dive into HPKE encryption for protecting PII so the server can’t read user data even if compromised.
Continue the series: