Published

Resilient Road Placement

How separating semantic game state from display state makes it easy to optimistically place a road and then roll back if the server rejects it.

CatanReactTypeScriptWeb DesignDistributed Systems

This blog post belongs to a three-part series: Responsive and Resilient Catan Board

  1. Responsive Hexagonal Grids
  2. Deriving Display State
  3. Resilient Road Placement Now Reading

In the previous post, we derived the Catan board’s display state from semantic game state and highlighted selectable roads. In this post, we’ll make the selectable roads interactive. A player will click a road, see the board update immediately, and be notified when the frontend reconciles with the server. If the server rejects the placement, the board will revert.

Making Roads Selectable

When a user loads the Interactive Road Placement scenario, a hardcoded set of Edge components is given a color value of SELECTABLE. All Edge components share the same onClick logic, which returns early unless the interactive scenario is selected and the clicked edge is one of the selectable ones. Every other road on the board is already owned by a player, so clicking it does nothing.

const handleGridComponentClick = (id: string) => {
  console.log(`Clicked ${id}`);
  updateActivityLog(`Clicked ${id}`);

  // handle demo scenario
  if (id.startsWith("E") && selectedScenarioId === "road-placement") {
    handlePlaceRoad(id);
  }
};

Interactive road placement scenario loaded
The interactive road placement scenario, with eligible edges highlighted by a pulsing glow

Clicking a selectable road has to update the frontend’s display state and the server’s semantic game state.

Updating Semantic Game State

During interactive updates, it’s important to keep the server’s semantic state separate from “override” semantic state. The override semantic state holds moves the user has made that the server has not yet received or accepted. The server and override states briefly diverge during every in-flight optimistic update.

All road placement logic lives in a dedicated React hook, useRoadPlacement. The hook holds three pieces of state: the pending road ID, the color to render it in, and a gameStateOverride object that temporarily replaces the server’s view of the game. When the server responds, the override is either committed as the new game state or discarded.

The hook’s state flows back into the renderer through a shallow merge in PageGrid before buildHexagonGrid is ever called. PageGrid is just the top-level React component that includes the Catan board, the Event Log, and the game status.

const boardWithOverrides = {
  ...selectedScenario.board,
  roads: createModifiedEdges(), // removes hidden edges, patches in pending color
};

const gameStateWithOverrides = {
  ...selectedScenario,
  board: boardWithOverrides,
  currentAction:
    gameStateOverride.currentAction || selectedScenario.currentAction,
  currentPlayer:
    gameStateOverride.currentPlayer || selectedScenario.currentPlayer,
};

After the merge, buildHexagonGrid receives a fully resolved Board and never has to know that some of its entries are temporary. The override just looks like real semantic state, and the pure derivation runs exactly as it always does.

Placing a Road Optimistically

Placing a road looks instantaneous to the player. Every click actually runs an optimistic update against a mocked backend.

First, the clicked road is set to a pending color and all other selectable roads are removed. React batches these updates and flushes them as soon as the handler yields, so the pending road is on screen well before the mocked call resolves 500ms later.

// optimistic update via React state
setPendingRoadId(edgeId);
setPendingRoadColor(BoardColor.ROAD_PENDING);
setGameStateOverride({
  currentAction: GameAction.DEMO,
  currentPlayer: PlayerColor.BLUE,
  removedEdgeIds,
});
updateActivityLog(`Placing road optimistically on ${edgeId}...`);

Immediately after the synchronous optimistic update, a request to the server is made:

if (await mockPlaceRoad()) {
  // change to final road color and keep other roads removed
  setPendingRoadColor(BoardColor.PLAYER_RED);
  setGameStateOverride({
    currentAction: GameAction.DEMO,
    currentPlayer: PlayerColor.BLUE,
    removedEdgeIds,
  });
  updateActivityLog(
    `API call to place road on ${edgeId} succeeded. No rollback needed. Game state advanced.`,
  );
} else {
  // revert everything to original state
  setPendingRoadId(null);
  setPendingRoadColor(null);
  setGameStateOverride({});
  updateActivityLog(
    `API call to place road on ${edgeId} failed (50% chance). Rolling back to original state.`,
  );
}

The mocked server returns true half the time, so both branches show up frequently in the demo.

On success, the pending road is committed in its final color, and the demo logs that the game state has advanced. On failure, all of the pending state is cleared and the board returns to its pre-click state. Every outcome is written to the Event Log for the user to see.

Road placement confirmed in Event Log
The Event Log showing a successful API call and the road committed in its final color

Road placement rolled back in Event Log
The Event Log showing a failed API call and the board rolled back to its original state

Synchronizing client and backend state is rarely this clean in practice. The demo only supports one hardcoded scenario, so it can skip the harder problems around retries and conflicts that a real implementation would need to address.

Conclusion

Throughout this series, we’ve built a Catan board that:

  • Responsively scales with device size.
  • Draws itself from semantic game state.
  • Resiliently handles player interactions.

The semantic state describes the game the way a player would, and the display state tracks the exact pixels on the screen. Temporary overrides let the two states diverge just long enough to make the UI feel instant.