DEV Community

Cover image for How I Structured a TypeScript Monorepo to Share Types, Board Geometry, and Move Validation Between Angular and Node.js
Learn Openings
Learn Openings

Posted on

How I Structured a TypeScript Monorepo to Share Types, Board Geometry, and Move Validation Between Angular and Node.js

screenshot of the app's home screen

The context: shared state, played in real time

Mercury is a real-time multiplayer implementation of the Tock / Keezen board game: four players, four colors, a cross-shaped board, and cards that drive pawn movement. The whole stack is TypeScript end to end:

  • Backend: Node.js + WebSocket (Express), authoritative server, persistence via Azure Cosmos DB
  • Frontend: Angular + Ionic (web + Android via Capacitor)
  • Shared code: an npm workspaces monorepo with a @mercury/shared package

The problem I wanted to solve from day one: in a board game with rules this precise (start squares, home zones, captures, invincibility, Jack swaps, splitting a 7 between two pawns...), even a small divergence between what the client displays and what the server validates quickly turns into a gameplay bug that's painful to reproduce.

Why a monorepo instead of two separate repos

Angular and Node.js have, on paper, no reason to share code: one renders UI, the other orchestrates game state. But in my case, three things are strictly identical on both sides:

  1. Types: Card, Player, Action, GameState, the WebSocket messages exchanged between client and server.
  2. Board geometry: which squares exist, where each color's start square is, where the home zone begins, what the full path looks like.
  3. Move validation: which cards allow which actions, under what conditions a Jack can swap two pawns, how to split a 7 between two pawns, and so on.

Without a single source of truth, these three blocks eventually drift apart, a field renamed on the backend silently breaking the frontend's typing, a rule fixed in the server validator but forgotten on the client. So I went with a monorepo using npm workspaces, structured like this:

mercury/
├── backend/                 # Node.js + WebSocket + Express
├── frontend/                # Angular + Ionic (+ Capacitor for Android)
└── packages/
    └── shared/               # @mercury/shared
        ├── types.ts
        ├── board-config.ts
        ├── move-validator.ts
        ├── constants.ts
        └── index.ts
Enter fullscreen mode Exit fullscreen mode

What's inside @mercury/shared

types.ts The contract between client and server

This is the most critical file. Every interface that travels over the WebSocket is defined exactly once:

// packages/shared/types.ts
export type Color = 'red' | 'green' | 'blue' | 'orange';

export interface Pawn {
  id: string;
  color: Color;
  position: number | 'reserve' | { home: number };
  isInvincible: boolean;
}

export interface Player {
  userId: string;
  color: Color;
  hand: Card[];
  connected: boolean;
}

export interface GameState {
  players: Player[];
  pawns: Pawn[];
  currentTurn: Color;
  deck: Card[];
  discardPile: Card[];
}

// WebSocket messages
export type ClientMessage =
  | { type: 'joinMatchmaking'; authToken: string }
  | { type: 'playCard'; cardId: string; pawnId: string; steps?: number }
  | { type: 'discardHand' };

export type ServerMessage =
  | { type: 'gameStateUpdate'; state: GameState }
  | { type: 'turnTimeout'; playerId: string }
  | { type: 'gameOver'; winningTeam: Color[] };
Enter fullscreen mode Exit fullscreen mode

The backend emits ServerMessages, the frontend consumes them and since both import from @mercury/shared, a shape change to a message breaks compilation on both sides immediately, long before it would ever reach QA.

board-config.ts board geometry, defined once

Mercury's board is cross-shaped, with numbered positions, per-color start squares, and four-square home zones. This geometry needs to be identical for:

  • the Angular-side rendering (placing marbles on the right squares),
  • the server-side calculation (deciding whether a move is legal, whether a target square is occupied, whether overshooting a home zone is allowed).
// packages/shared/board-config.ts
export const START_POSITIONS: Record<Color, number> = {
  red: 9,
  green: 90,
  blue: 150,
  orange: 76,
};

export const HOME_ENTRY: Record<Color, number> = {
  red: 220,
  green: 141,
  blue: 81,
  orange: 150,
};

export function getStartPosition(color: Color): number {
  return START_POSITIONS[color];
}

export function hasWon(pawns: Pawn[], color: Color): boolean {
  return pawns
    .filter(p => p.color === color)
    .every(p => typeof p.position === 'object' && 'home' in p.position);
}
Enter fullscreen mode Exit fullscreen mode

The entire board numbering (visible in my debug mockups, a plain grid of numbered cells before the final marble-based visual styling) comes from this single file. The frontend just iterates over it to position its SVG/DOM elements.

Debug grid of the board with numbered cells before graphic styling

move-validator.ts the same logic, two different uses

This is the heart of the shared package. The function that decides whether a move is legal runs twice, for two different purposes:

  • Client side: for instant feedback (graying out an unplayable card, showing "No legal moves Discard" without waiting for a network round trip).
  • Server side: as the final authority the only version that actually counts for game state.
// packages/shared/move-validator.ts
import { GameState, Card, Pawn } from './types';
import { getStartPosition, hasWon } from './board-config';

export function getLegalMoves(state: GameState, playerColor: Color, card: Card): LegalMove[] {
  // Identical logic on both sides:
  // - entering play (Ace / King / Joker)
  // - captures, invincibility on the start square
  // - Jack swaps (never between two opposing pawns)
  // - splitting a 7 between one or two pawns
  // - requiring an exact stop to enter the home zone
  // ...
}

export function isMoveLegal(state: GameState, move: ClientMessage): boolean {
  return getLegalMoves(state, /* ... */).some(m => movesEqual(m, move));
}
Enter fullscreen mode Exit fullscreen mode

The server, as the authority, rejects any action that doesn't match isMoveLegal even if a compromised or buggy client sends a hand-crafted WebSocket message. The frontend uses the exact same function only to anticipate the UX, never to decide on its own.

constants.ts magic numbers, centralized

Turn duration, hand size per round, which cards allow entering a pawn: everything that would otherwise get copy-pasted (and eventually drift out of sync) across two different files.

// packages/shared/constants.ts
export const TURN_DURATION_SECONDS = 60;
export const RECONNECT_WINDOW_SECONDS = 180;
export const ENTER_CARDS = ['A', 'K', 'JOKER'] as const;
export const CARDS_PER_HAND = [5, 4, 4] as const; // 3-round cycle
Enter fullscreen mode Exit fullscreen mode

index.ts a single import point

// packages/shared/index.ts
export * from './types';
export * from './board-config';
export * from './move-validator';
export * from './constants';
Enter fullscreen mode Exit fullscreen mode

Simple rule, but it saves a lot of headaches: always import from @mercury/shared, never directly from a sub-file. That keeps the freedom to reorganize the package internals without breaking imports on either the backend or the frontend.

The build workflow

With npm workspaces, packages/shared needs to be built before the frontend or backend start, since it's consumed like a regular dependency:

# one-time install at the root
npm install

# build the shared package (required before frontend/backend)
npm run build:shared

# development, with automatic rebuild of shared
npm run build --workspace=packages/shared -- --watch

# start both apps, in two terminals
npm run dev:backend
npm run dev:frontend
Enter fullscreen mode Exit fullscreen mode

That build-order detail caused some confusion early on a backend starting before shared gets rebuilt ends up pointing at a stale version of the compiled types. Documenting it clearly in the project README settled the issue for good.

What this actually changes

Since this package has existed, I haven't seen a single bug of the "the client thinks this move is legal, the server rejects it" kind caused by a plain logic mismatch. The only remaining client/server discrepancies are intentional and documented (for instance, the server stays the sole authority on late reconnects or turn timeouts). Everything else, geometry, capture rules, start-square invincibility, Jack swaps, splitting a 7,lives in exactly one place, tested once, and enforced at compile time on both sides of the WebSocket by TypeScript itself.

Mercury game board mid-match with colored marbles and cards in hand

Going further

If this is interesting to you, you can try the game itself at mercury-game.online public 4-player matches.

Top comments (0)