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 (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:
- β Part 2: Secure Login System for Multiplayer Games
- β Part 3: Asymmetric Encryption for Player PII (coming March 24)