Translating a physical board game into a digital format seems straightforward until you realize that humans are remarkably good at managing complex, multi-step rules that computers find incredibly tedious.

Inspiration

Acquire is a masterpiece of board game design. Released in 1964 by Sid Sackson, it’s a game of placing tiles to form hotel chains, buying stock, and ruthlessly orchestrating mergers to cash out and dominate the board. It requires strategic foresight, probability management, and a healthy dose of capitalistic opportunism.

I set out to build a fully playable, networked version of Acquire. The goal was twofold: to create a sleek, “fintech-inspired” multiplayer environment where friends could compete remotely, and to build an arena for AI bots to battle it out in a game with imperfect information (hidden hands) and compounding numerical strategies.

What started as a simple grid and a few buttons quickly ballooned into an exercise in advanced state-machine architecture, WebSocket resilience, and zero-sum game theory algorithms.

Architecture of a Hostile Takeover

The core of Acquire is highly stateful. Players don’t just take a turn; they initiate chain reactions. Placing a single tile might trigger a 3-way corporate merger, requiring the game to pause, ask the active player to choose a surviving entity, then sequentially ask every shareholder of the defunct corporations if they want to sell, trade, or hold their stock, before finally letting the active player buy new stock.

To manage this without tangling the UI in knots, I decoupled the game engine completely from the React frontend. The engine runs entirely on a single, pure reducer function:

  • 1) THE ENGINE: PURE, IMMUTABLE STATE TRANSITIONS

Every action in the game—from playing a tile to passing a turn—flows through applyAction. It takes the current GameState and a GameAction, and returns a completely new GameState.

export function applyAction(state: GameState, action: GameAction): GameState {
  const s = structuredClone(state) as GameState;
  
  if (action.type === 'PLACE_TILE') {
    // ... evaluates board placement via flood-fill
    // ... detects if a merger is triggered
    if (adjacentCorps.length > 1) {
      return {
        ...s,
        phase: 'ChooseSurvivor', // Shifts the state machine
        mergeState: {
          survivingCorp: null,
          defunctCorps: adjacentCorps,
          currentDefunct: null,
          resolvingPlayerIndex: state.currentPlayerIndex,
        }
      };
    }
  }
  // ...
  return s;
}
Because the engine is perfectly deterministic, it allows the server to effortlessly validate moves, and it allows the AI bots to simulate thousands of future board states without corrupting the live game.
<ul class="feature-list">
<li><strong>2) THE MULTIPLAYER SERVER:</strong> HANDLING HUMAN ERROR (AND LATENCY)</li>
</ul>
Board games take time, and internet connections drop. If someone closes their laptop on a train, the game shouldn't explode. I built a custom Node.js WebSocket server (server/index.ts) that manages connection lifecycles, reconnect grace periods, and offline auto-skips.
When a player disconnects, they enter a "reconnecting" state. If they don't return within 60 seconds, the server marks them "offline". If it happens to be their turn, the server automatically tags in a bot to take their turn so the game doesn't stall, logging an alert to the other players.
Crucially, the server also prevents cheating. In a physical game, you can't see my tiles. In a poorly written web game, you could just open the Chrome DevTools and inspect the WebSocket payload to see my tiles.
code
TypeScript
// server/index.ts
export function scrubStateForPlayer(state: GameState, playerId: string): GameState {
  return {
    ...state,
    unplayedTiles: [], // Clients never need the draw pile
    players: state.players.map(p => {
      if (p.id === playerId) return p; // Full info for requesting player
      if (p.isBot) return { ...p, tiles: [] }; // Bots hide tiles
      
      // Other humans: Replace real tiles with placeholders 
      // so the UI knows the *count* but not the *value*
      return { ...p, tiles: p.tiles.map(() => 'hidden') };
    }),
  };
}
By scrubbing the state before broadcasting, the server guarantees cryptographic fairness.
The Digital Boardroom: Meet the Bots
Because the game engine is decoupled, dropping AI opponents into the mix is seamless. I designed several tiers of AI to test different strategic philosophies.
<ul class="feature-list">
<li><strong>Standard & Allen:</strong> The foundational bots. They understand basic heuristics—preferring high-tier corporations and grabbing majority bonuses. They are competent, but predictable.</li>
<li><strong>Expert:</strong> Plays purely to end the game. It mathematically calculates the exact liquidation net worth of every player on every turn. If it is winning, and the board state allows the game to be ended, it pulls the trigger instantly.</li>
<li><strong>Claude:</strong> The opportunity-cost bot. Instead of just asking "What makes me money?", Claude asks "What denies the leader money?". It actively scans its hand for "dangerous tiles"—tiles that, if played, would permanently lock in a safe corporation where an opponent holds the majority.</li>
</ul>
But the crown jewel of the roster is Emma.
<ul class="feature-list">
<li><strong>Emma:</strong> The ultimate probabilistic oracle.</li>
</ul>
Emma doesn't rely on simple heuristics. She exhaustively simulates the exact dollar swing in "Net Worth Lead" (My Net Worth minus the Highest Opponent's Net Worth) for every possible move.
Furthermore, she derives probabilities strictly from counting unseen tiles versus opponent hand sizes.
<details class="collapsible-prompt">
<summary>Deep Dive: Emma's Probabilistic Danger Assessment</summary>
<div class="code-block">
code
TypeScript
function scoreTileOracle(state: GameState, tile: string, me: Player): number {
  // 1. Calculate the immediate monetary swing of playing this tile
  const immediateDelta = simulatePlacementAndGetLeadDelta(state.board, state, tile, me.id, me.id);

  // 2. Identify all unseen tiles that legally exist 
  // (excluding board and our own hand)
  const unseen = new Set<string>();
  // ... mapping logic ...

  // 3. Find unseen tiles adjacent to the new cluster we just created
  const adjacentUnseen = new Set<string>();
  for (const t of playedCluster) {
    for (const n of getNeighbors(t)) {
      if (unseen.has(n)) adjacentUnseen.add(n);
    }
  }

  // 4. Calculate the probability an opponent holds one of these dangerous tiles
  let dangerPenalty = 0;
  const oppTilesCount = state.players.reduce((sum, p) => sum + (p.id !== me.id ? p.tiles.length : 0), 0);
  const probOppHasTile = Math.min(1, oppTilesCount / Math.max(1, unseen.size));

  // 5. Simulate the opponent playing that tile. If it hurts us, apply a probabilistic penalty.
  for (const u of adjacentUnseen) {
    const oppDelta = simulatePlacementAndGetLeadDelta(simBoard, state, u, me.id, opp.id);
    if (oppDelta < 0) {
      dangerPenalty += oppDelta * probOppHasTile; // EV Calculation
    }
  }

  return immediateDelta + dangerPenalty;
}
</div>
</details>
Emma evaluates tile placements not just by their immediate payout, but by the mathematical risk of opening up devastating counter-plays for her opponents. She plays a terrifyingly tight, risk-averse game that frequently suffocates human players.
Lessons Learned
Building a multiplayer strategy game from scratch is an exercise in managing edge cases.
<ul class="feature-list">
<li><strong>State Machines are Lifesavers:</strong> When a merger occurs between three corporations of the exact same size, the game must pause for a tie-breaker, then sequence through every shareholder, handling scenarios where players might hold zero stock, all while keeping the UI responsive. Modeling `GameState.phase` strictly (e.g., `PlaceTile` -> `ChooseSurvivor` -> `MergeResolution` -> `BuyStock`) was the only way to keep this sane.</li>
<li><strong>UI for Complex Data:</strong> Displaying 7 corporations, live stock prices, remaining bank stocks, player cash, and tile racks on a mobile screen is incredibly difficult. I spent significant time building a "Market" tab and a compact `ActionPanel` that collapses and expands contextually based on whose turn it is and what phase the state machine is in.</li>
<li><strong>Deterministic Bots:</strong> Giving bots the ability to hook into the exact same `applyAction` pipeline that the server uses meant I didn't have to write "bot logic" and "game logic" separately. The bots just play the game in memory to see the future.</li>
</ul>
Acquire is fully playable, highly competitive, and serves as a testament to the fact that sometimes the most ruthless corporate raiders are the ones written in TypeScript.
Expanded view