Writing a Chess Microservice using Node.js and Seneca, Part 3: Refactoring and Implementing Advanced Rules

Welcome back to the third and final part of our series on writing a chess microservice using Node.js and Seneca. In the previous two parts, we covered the fundamentals of Seneca microservices, including how to write a service, identify patterns, call services, and enhance existing services. If you haven‘t read Part 1 and Part 2, I highly recommend doing so before proceeding.

In this final installment, we will dive deeper into refactoring our services for improved flexibility and efficiency, as well as implementing the advanced rules of chess for each piece. Let‘s get started!

The Importance of Returning Flexible Data Objects

One of the key lessons learned from the previous parts is the importance of returning a flexible data object, such as JSON, from our services. By doing so, we allow for future enhancements and embellishments to the service output without affecting existing clients.

In our case, the rawMoves service initially returned a list of moves, which was sufficient for the immediate clients. However, when implementing the legalMoves service later on, we realized that having access to the movement vectors would have simplified the calculations and improved efficiency.

To address this, we refactored the rawMoves service to return an object containing both moves and moveVectors. This allows upstream clients to choose what information they need, while ensuring that moves and moveVectors remain in sync.

Refactoring the rawMoves Service

Let‘s take a closer look at how we refactored the rawMoves service. Originally, the service calculated moves along movement vectors and combined them into a 1-dimensional array. However, we discovered that maintaining the movement vectors would be beneficial for the legalMoves service when considering friendly pieces blocking movement.

Here‘s an example of the refactored rawMoves service:

module.exports = function (boardAndPiece, candidateMoves) {
    if (!boardAndPiece.board) return candidateMoves;
    const rangeChecks = {
        B: vectorChecks,
        R: vectorChecks,
        K: vectorChecks,
        Q: vectorChecks,
        P: pawnChecks,
        N: knightChecks
    };
    var rangeCheck = rangeChecks[boardAndPiece.piece.piece];
    return rangeCheck(boardAndPiece, candidateMoves)
}

// ...

function vectorChecks(boardAndPiece, candidateMoves) {
    for (const [j, v] of candidateMoves.moveVectors.entries()) {
        for (const [i, m] of v.entries()) {
            const p = boardAndPiece.board.pieceAt(m);
            if (p) {
                if (p.color === boardAndPiece.piece.color) {
                    candidateMoves.moveVectors[j] = v.slice(0, i);
                    break;
                } else {
                    candidateMoves.moveVectors[j] = v.slice(0, i + 1);
                    Object.assign(candidateMoves.moveVectors[j].slice(-1)[0], {
                        hasCaptured: p
                    })
                    break;
                }
            }
        }
    }
    return {
        moveVectors: candidateMoves.moveVectors,
        moves: Array.prototype.concat(...candidateMoves.moveVectors)
    }
}

By returning both moveVectors and moves, the legalMoves service can now utilize this information to simplify its logic and improve efficiency.

Implementing the legalMoves Service

With the refactored rawMoves service in place, let‘s now focus on implementing the legalMoves service. This service is responsible for handling all movement requests and relies on several other services and helper methods:

  1. Call the rawMoves service to obtain all moves of a lone piece on a virtual 15×15 chessboard (movement mask).
  2. Call the base legalMoves service to clip the movement mask at the edge of the "real" 8×8 board, with proper algebraic coordinates.
  3. Call the overriding legalMoves service if there is a board as part of the incoming message (service pattern). This performs a series of checks to account for the presence of friendly and opposing pieces, which affect movement.

Implementing Rules for Each Chess Piece

Now, let‘s dive into the implementation details for each chess piece, considering the specific rules and interactions with friendly and enemy pieces.

Queen, Rook, and Bishop

For the Queen, Rook, and Bishop, the new rules involving enemy pieces extend or modify the original legalMoves service from Part 2. The service needs to know if the blocking piece is friend or foe. If it‘s a friendly piece, movement is blocked at the square before. If it‘s an enemy piece, movement is blocked by the square of the opposing piece (by capture).

In the list of legal moves returned by a piece, we denote captures by setting a hasCaptured flag, along with the type of enemy piece to be captured. The vectorChecks helper method handles all vector-based movement for these pieces.

The Queen can go to f3, but not g4; it can go to d3 and c4 (by capture)

Knight

Knights jump around the board and are only blocked by friendly pieces that are on one of its potential landing squares. An enemy piece does not block, but would be captured if a Knight landed on it. The knightChecks method used by the legalMoves service is straightforward:

function knightChecks(boardAndPiece, candidateMoves) {
    const newMoves = [];
    for (const m of candidateMoves.moves) {
        const p = boardAndPiece.board.pieceAt(m)
        if (!p) {
            newMoves.push(m)
        } else if (p.color !== boardAndPiece.piece.color) {
            m.hasCaptured = p;
            newMoves.push(m)
        }
    }
    return {
        moves: newMoves,
        moveVectors: [newMoves]
    };
}

Pawn

Pawns may seem simple at first, but they have several special rules to consider:

  • The pawn is blocked if any piece, whether friend or enemy, stands in front of it.
  • It can move one square diagonally forward to capture an enemy that sits in that square.
  • The en passant rule allows a pawn to capture an adjacent enemy pawn that just moved two squares on the previous turn.
  • Mandatory promotion must be handled once a pawn reaches the 8th rank (from its perspective).

The pawn at e4 is blocked at e5, but can capture at f5

These considerations result in a more involved set of rules to determine the pawn‘s movement options, which can be found in the accompanying source code.

King

The King is the most complex piece to implement due to various conditions:

  • Is a potential move square controlled by an enemy piece? Eliminate that option.
  • Is the king in check? If so, it must move this turn. If it can‘t move out of check, it‘s checkmate. If it‘s not in check but there are no other legal moves by any friendly piece, it‘s stalemate.
  • Castling conditions must be checked, such as the King and Rook not having previously moved, intervening squares being empty and not controlled by enemy pieces.

Here‘s a simplified version of the legalMovesWithKing method:

module.exports = function (boardAndPiece, candidateMoves, reply) {
    const opposingColor = boardAndPiece.piece.color === ‘W‘ ? ‘black‘ : ‘white‘;
    // temporarily remove the K to avoid cycles
    boardAndPiece.board.removePiece(boardAndPiece.piece);

    function canCastle(king, rook, intervening, opposing) {
        // ...
    }

    this.use(require(‘../SquareControl‘))
    this.act({
        role: "board",
        cmd: "squaresControlledBy",
        board: boardAndPiece.board,
        color: opposingColor,
    }, (err, opposing) => {
        if (err) {
            reply(err);
            return;
        }
        const king = boardAndPiece.piece;
        // add the removed K back in
        boardAndPiece.board.addPiece(king);

        // ...

        reply(null, candidateMoves)
    });
};

Note that we temporarily remove the friendly King from the board to avoid cyclic dependencies when calling the squaresControlledBy service.

Black King can't castle because it's in check; White King can't castle because of intervening friendly Bishop (kingside) and opposing control of d1 square (queenside)

Handling Cyclic Dependencies

One of the challenges when working with microservices is dealing with cyclic dependencies. In our case, the legalMoves service for the King calls the squaresControlledBy service for the opposing side, which in turn calls the legalMoves service for all opposing pieces, potentially leading to an endless cycle.

To avoid this, we temporarily remove the friendly King from the board before calling the squaresControlledBy service and add it back afterwards. While modifying incoming service action data is generally not considered best practice due to potential side-effects, it serves as a practical solution for the scope of this series.

Seneca provides trace options for actions (–seneca.print.tree) and service invocations (–seneca.log.all) that can be helpful in debugging cyclic dependencies and understanding the flow of service calls.

Conclusion

In this final part of our series on writing a chess microservice using Node.js and Seneca, we covered the refactoring process to improve flexibility and efficiency, as well as the implementation of advanced chess rules for each piece.

We discussed the importance of returning flexible data objects from services, refactored the rawMoves service to return both moves and moveVectors, and detailed the implementation of the legalMoves service for each chess piece, considering their specific rules and interactions with friendly and enemy pieces.

We also addressed the challenge of cyclic dependencies in microservices and provided a practical solution for avoiding endless cycles in our specific case.

I hope you found this series informative and engaging. The full source code, including tests, for all three parts can be found on GitHub:

Feel free to explore the code, experiment with it, and provide feedback. Happy coding!