Writing a chess microservice using Node.js and Seneca, Part 2

Chess pieces on a chessboard

Introduction

Welcome to Part 2 of my series on building a chess microservice using Node.js and Seneca. In Part 1, we laid the groundwork by defining a set of microservices to encapsulate the rules for legal chess moves. We ended up with a legalSquares service that could return all the valid moves for a piece on an empty board.

In this post, we‘ll extend our service to handle a more realistic scenario: a board populated with other pieces. We‘ll see how Seneca allows us to iteratively add functionality to our microservice without breaking existing clients. Along the way, we‘ll discuss some important design principles and lessons learned.

But first, let‘s zoom out and revisit some of the core benefits of microservices.

Why microservices?

Microservices have taken the software engineering world by storm in recent years. A 2020 survey by O‘Reilly found that 77% of organizations have adopted microservices, with 92% experiencing success with the architecture.

So what‘s all the fuss about? At a high level, microservices offer a number of compelling benefits:

  • Improved agility and productivity. By decomposing monolithic applications into loosely-coupled services, teams can develop, test and deploy individual components independently. This enables faster iteration and reduces coordination overhead.

  • Better scalability and resilience. Microservices can be scaled up and down independently based on load. Failures in one service can be isolated and gracefully handled without cascading to other parts of the system.

  • Flexibility to use the best tool for the job. Each microservice can be implemented using the language, framework and data store that best fits its particular requirements and performance characteristics.

  • Evolutionary design and reduced technical debt. Microservices support incremental refactoring and replacement of legacy components. Teams can gradually pay down technical debt and evolve the architecture in line with changing business needs.

It‘s this last point I want to focus on. Too often, our carefully-crafted software turns into a ball of mud as requirements evolve and deadlines loom. Microservices offer a way to push back against this entropy. Let‘s see how that looks in practice.

Extending the legalMoves service

Recall that our legalSquares service from Part 1 had a simple responsibility. Given a chess piece and its current position, return the set of squares it can legally move to on an empty board.

Here‘s a refresher on what that implementation looked like:

this.add(‘role:chess,cmd:legalSquares‘, (msg, reply) => {
  const { piece, position } = msg;

  this.act(‘role:chess,cmd:rawMoves‘, { piece, position }, (err, rawMoves) => {
    if (err) return reply(err);

    const legalMoves = rawMoves.filter(move =>
      move.file >= ‘a‘ && move.file <= ‘h‘ &&
      move.rank >= 1 && move.rank <= 8
    );

    reply(null, legalMoves);
  });
});

In English: the service calls out to a rawMoves service to get the unconstrained moves for the given piece. It then filters those moves to only include squares within the boundaries of a standard 8×8 chess board. Finally, it replies with the resulting list of legal moves.

This works great for an empty board. But what about a board with other pieces on it? Clearly, we‘ll need to exclude any squares that are occupied by friendly pieces. And for certain pieces like pawns, we‘ll have to handle more complex rules around capturing opponent pieces.

We could just modify the existing legalSquares implementation to handle these new requirements. But that would quickly lead to a bloated and brittle service doing too many things. Every change would require touching the same core logic, increasing the risk of introducing bugs.

Instead, let‘s apply the microservices philosophy of evolutionary design. We‘ll leave legalSquares untouched and create a new service that extends it for our new use case.

Introducing the populatedBoard parameter

First, let‘s define a new message format for our extended service. We‘ll keep the piece and position parameters from before, but add a new populatedBoard field. This will be an array representing the current state of the chess board, with each element indicating the type and color of the piece occupying that square (or null for empty squares):

{
  role: ‘chess‘,
  cmd: ‘legalMovesOnPopulatedBoard‘,
  piece: {
    type: ‘queen‘,
    color: ‘white‘
  },
  position: {
    file: ‘d‘,
    rank: 1
  },
  populatedBoard: [
    [‘rook‘,   ‘knight‘, ‘bishop‘, ‘queen‘,  ‘king‘,   ‘bishop‘, ‘knight‘, ‘rook‘],
    [‘pawn‘,   ‘pawn‘,   ‘pawn‘,   ‘pawn‘,   ‘pawn‘,   ‘pawn‘,   ‘pawn‘,   ‘pawn‘],
    [null,     null,     null,     null,     null,     null,     null,     null],
    [null,     null,     null,     null,     null,     null,     null,     null],
    [null,     null,     null,     null,     null,     null,     null,     null],
    [null,     null,     null,     null,     null,     null,     null,     null],
    [‘pawn‘,   ‘pawn‘,   ‘pawn‘,   ‘pawn‘,   ‘pawn‘,   ‘pawn‘,   ‘pawn‘,   ‘pawn‘],
    [‘rook‘,   ‘knight‘, ‘bishop‘, ‘queen‘,  ‘king‘,   ‘bishop‘, ‘knight‘, ‘rook‘]
  ]
}

Great, we have our input format. Now let‘s implement the service.

Scaffolding the legalMovesOnPopulatedBoard service

Here‘s a first pass at the new legalMovesOnPopulatedBoard service:

this.add(‘role:chess,cmd:legalMovesOnPopulatedBoard‘, (msg, reply) => {
  const { piece, position, populatedBoard } = msg;

  this.act(‘role:chess,cmd:legalSquares‘, { piece, position }, (err, legalSquares) => {
    if (err) return reply(err);

    const availableSquares = legalSquares.filter(square => {
      const occupyingPiece = getPieceAt(populatedBoard, square);
      return !occupyingPiece || occupyingPiece.color !== piece.color;
    });

    reply(null, availableSquares);
  });

  function getPieceAt(board, square) {
    const { file, rank } = square;
    const fileIndex = file.charCodeAt(0) - ‘a‘.charCodeAt(0);
    const rankIndex = rank - 1;
    return board[rankIndex][fileIndex];
  }  
});

Let‘s break this down:

  1. We destructure the piece, position, and new populatedBoard parameters from the incoming message.

  2. We call out to the existing legalSquares service to get the baseline set of legal moves, ignoring any other pieces on the board.

  3. We filter the legalSquares based on the occupancy of each square. If the square is unoccupied (!occupyingPiece) or occupied by an opponent piece (occupyingPiece.color !== piece.color), we include it. Otherwise, we exclude it.

  4. We reply with the resulting list of availableSquares.

The getPieceAt helper function translates from chess notation (e.g. ‘e4‘) to array indices to look up the occupying piece for a given square.

With about 20 lines of code, we‘ve successfully extended our microservice to support a new requirement. And crucially, we didn‘t have to touch the existing legalSquares implementation. Consumers of that service can continue using it without any changes or awareness of the new functionality.

Refining the implementation

Our first version of legalMovesOnPopulatedBoard gets the job done, but it has a few rough edges.

Firstly, the getPieceAt function is doing some fiddly index math that obscures the core logic. The chess engine code is also directly coupled to the structure of the populatedBoard array, which makes it harder to change the data model in the future.

Secondly, something feels a bit off about returning a list of all unoccupied and opponent-occupied squares. What the client really wants to know is which squares the piece can legally move to, considering the constraints of other pieces. Returning more than that feels like a leaky abstraction.

Let‘s address both of these issues with an improved implementation:

this.add(‘role:chess,cmd:legalMovesOnPopulatedBoard‘, (msg, reply) => {
  const { piece, position, populatedBoard } = msg;

  this.act(‘role:chess,cmd:legalSquares‘, { piece, position }, (err, legalSquares) => {
    if (err) return reply(err);

    const candidateMoves = legalSquares.map(square => {
      const pieceAt = populatedBoard.pieceAt(square);
      return {
        to: square,
        captures: pieceAt && pieceAt.color !== piece.color ? pieceAt : null  
      };
    });

    const blockedMoves = candidateMoves.filter(({to}) => 
      populatedBoard.pieceAt(to)?.color === piece.color
    );

    const unblockedMoves = candidateMoves.filter(move => 
      !blockedMoves.some(blocked => isSameSquare(move.to, blocked.to))  
    );

    const legalMoves = unblockedMoves.map(({to, captures}) => ({to, captures}));

    reply(null, legalMoves);
  });

  function isSameSquare(a, b) {
    return a.file === b.file && a.rank === b.rank;  
  }
});

The key changes:

  • We‘ve introduced a candidateMoves structure to represent all the possible moves, along with metadata about whether each move captures an opponent piece. This normalizes the data and abstracts away the chess notation.

  • We‘ve moved the board lookup logic into a pieceAt method on the populatedBoard object itself. This encapsulates the indexing math and hides the details of the board representation from the rest of the code.

  • We‘ve split the filtering logic into two phases. First we identify any moves that are blocked by a friendly piece. Then we exclude those blocked moves from the candidateMoves to arrive at the final list of legal moves. This two-step approach is more explicit and easier to reason about.

  • Finally, we‘ve trimmed down the return value to only include the to square and captures flag for each legal move. This focuses the API on the essential information needed by clients.

These changes make the code more readable, maintainable, and self-documenting. They also demonstrate how we can progressively refine the implementation within the microservice boundary, without impacting consumers.

Future enhancements

We‘ve covered a lot of ground, but there‘s always more to improve! Here are some ideas for further extending our chess microservice:

More granular services

As our service grows in complexity, we might want to break it down into even smaller, more focused services. For example, we could have separate services for:

  • Detecting check and checkmate
  • Validating a sequence of moves
  • Pretty-printing the board state
  • Persisting and loading games

Each of these services would encapsulate a specific bit of chess logic or infrastructure, making the overall system more modular and maintainable.

Persistence and state management

So far, our service has been purely stateless. Every request includes the entire board state, and we don‘t persist anything across requests. This keeps things simple, but it puts more burden on the client to manage the game state and pass it in with each move.

To provide a better client experience, we could add persistence and state management to our microservice. This would involve:

  • Generating a unique ID for each game
  • Storing the game state in a database or cache, keyed by the game ID
  • Exposing APIs for clients to create, load, update, and delete games
  • Validating moves against the persisted game state on the server side

With this stateful setup, clients could interact with the chess service using a lightweight request/response protocol, without needing to keep track of the entire board state themselves.

Advanced chess rules

There are many chess rules and edge cases we haven‘t covered yet, such as:

  • Castling
  • En passant captures
  • Pawn promotion
  • Stalemate
  • Threefold repetition

Implementing these rules would require extending our game state model and adding new services to handle the additional logic. We might also want to introduce a domain-specific language (DSL) for expressing chess rules in a more declarative and readable way.

API versioning and backwards compatibility

As we evolve our microservice API over time, we‘ll need to think about versioning and backwards compatibility. We might want to:

  • Introduce version numbers into our service names and message formats
  • Maintain multiple versions of the API side-by-side, with clear deprecation policies
  • Use semantic versioning to signal breaking changes, new features, and bug fixes
  • Provide migration guides and tools to help clients upgrade to new versions

By managing API evolution carefully, we can give clients the stability they need while still being able to innovate and improve the service over time.

Conclusion

In this post, we‘ve seen how to extend a microservice to handle new requirements without breaking existing consumers. By encapsulating chess logic and state within focused services, we were able to add support for populated boards and piece captures in an incremental and backwards-compatible way.

We‘ve also explored some key design principles for microservices, including:

  • Defining clear service boundaries and interfaces
  • Encapsulating domain logic and infrastructure details
  • Refining implementations iteratively within service boundaries
  • Evolving APIs carefully to balance stability and innovation

Microservices are a powerful architectural pattern for building complex, evolving systems. But they also come with their own set of challenges and trade-offs. It‘s important to apply them judiciously and to constantly strive for simplicity, modularity, and loose coupling in our designs.

In future posts, we‘ll dive deeper into topics like testing, deployment, and orchestration for Node.js microservices. Stay tuned!

Similar Posts