Chess AI Scripting Example

Table of Contents

In this tutorial we will take a standalone javascript chess engine and adapt it so that we can play chess against it right in Tabletop Playground!

You can find and download all code from the accompanying github project here or just download the package directly on mod.io.

Setup

First we create a new project and copy the default chess pieces and board models into our new project. We also save an initial state where we set up the chess board the correct way.

The initial chess board setup for our project

The player is going to play white, the chess AI is going to take over black.

We also create new script files, one for chess pieces – one for the chess board and one for the global script – and assign them to the pieces.

Concept

The chess AI is located in the chessengine folder and has been prepared by us so that all you have to do is require it as an AI object. You don’t need to worry about all the chess AI code in there, it’s enough to know that you can call a few methods to inform it about any moves made, calculate the next move and print the current state. See this example that we run in our chessboard script file:

const { refObject, world } = require('@tabletop-playground/api');

const { Chess } = require("./chessengine/chess");
const { ChessAI } = require("./chessengine/chessai");

// initate the chess engine
refObject.chessAI = ChessAI(new Chess());

// print the current chess board state
refObject.chessAI.print();

// move the white pawn from e2 to e4
refObject.chessAI.move("e2", "e4");

// let the AI calculate the next move
var move = refObject.chessAI.get_best_move();
console.log("Response", move.from, "->", move.to);

// print the current chess board state again
refObject.chessAI.print();

After the chessboard is spawned, the AI will be initiated and print the current state to the console. Then we tell the AI that a move has been made (white pawn moves forward) and ask the AI to respond with a move. Finally we print the new state again. This is (nearly) all we need to build a chess AI!

The only thing left do is connect it to our actual chessboard. So whenever the player moves a white chess piece, we tell the AI about it and then execute the resulting move for the black side.

Translating between chess positions and world positions

Since the chess AI only understands and talks in chess moves (e.g. A1 → A2), we need to figure out how to convert the positions of our chess pieces in the game world to chess positions and vice versa before we can continue. To figure it out we add the following function to our chessboard file:

// Returns a chess position for the given world coordinates
// e.g. Vector(23.2, 64.6, -5.0) → A4
refObject.printLocalPosition = function(pos)
{
    console.log(this.worldPositionToLocal(pos));
}

Notice how we translate the given position from world position into local position first. If we don’t do this, our function wouldn’t work as soon as the chess board is moved from its current position.

We add an onSnap callback to our chessPiece code and call the above chessboard function:

refObject.onSnapped.add(
	function(obj, player, snapPoint, grabPosition)
	{
		var parent = snapPoint.getParentObject();
		if (parent.getId() == "chessboard") {
			parent.printLocalPosition(obj.getPosition());
		}
	}
);

Trying it out in-game by moving any piece around the chessboard gives us the following output:

Output from moving a rook around the board

Using good old pen and paper, we derive the following functions for translating between world coordinates and chess board coordinates. Also creating the reverse function, we receive the following:

/**
 * Returns a chess position for the given world coordinates
 * e.g. Vector(23.2, 64.6, -5.0) → A4
 */
refObject.worldPosToChessPos = function(pos)
{
    var localPos = this.worldPositionToLocal(pos);
    var col = String.fromCharCode(96 + Math.floor(4.587 - localPos.y / 5.572 + 0.5));
    var row = Math.floor(4.547 - localPos.x / 5.572 + 0.5);
    return col + row;
}


/**
 * Returns a world position vector for the given chess position
 * e.g. A4 → Vector(23.2, 64.6, -5.0)
 */
refObject.chessPosToWorldPos = function(chessPos)
{
    var x = (96 + 4.578 - chessPos.charCodeAt(0)) * 5.572;
    var y = (chessPos[1] - 4.547) * 5.572;
    return new Vector(x, y, this.getPosition().z + 1);
}

Another way to do this would have been starting from the snap point positions on the chess board. Armed with this functionality, we can tackle the next problem.

Finding chess pieces at position

When executing AI moves or when we need to to check if the player is capturing an AI piece, we need to find the chess piece(s) at a given position. In TableTop Playground we do this using line traces. A line trace just gives us all the GameObjects located on a given line. To streamline this, we create a function that does a vertical line trace at a given position and also filters out the chessboard from the result.

/**
 * Look for chess pieces at the given position and return it if existing
 */
refObject.findChessPieceAtPosition = function(pos)
{
    var result = [];
    var from = pos.add(new Vector(0, 0, -10));
    var to = pos.add(new Vector(0, 0, 10));
    var matches = world.lineTrace(from, to);
    if (matches)
    {
        // filter out chessboard from results
        for (var i = 0; i < matches.length; i++)
        {
            if (matches[i].object.getId() != this.getId())
            {
                result.push(matches[i].object);
            }
        }
    }
    return result;
}

Hooking up the AI

Now we can get to the interesting part – actually having the AI respond to player moves.

First, we create a function on piece moved that works similar to our debug method above, but also actually executes moves.

/**
 * Custom function to be called by (white) chess pieces after they have moved
 */
refObject.onPieceMoved = function(piece, grabPosition)
{
    // first translate from vector positions into chess language e.g. a2
    var from = this.worldPosToChessPos(grabPosition);
    var to = this.worldPosToChessPos(piece.getPosition());

    // make sure the the piece was actually moved and not just snapped in place
    if (from !== to)
    {

        // tell AI about the move and print the chess state to console
        console.log("Player move", from, "->", to);
        this.chessAI.move(from, to);

        // let the AI decide on a good move to make next
        var move = refObject.chessAI.get_best_move();
        console.log("Response", move.from, "->", move.to);

        // translate AI move back into actual positions
        var ifrom = refObject.chessPosToWorldPos(move.from);
        var ito = refObject.chessPosToWorldPos(move.to);

        // try to execute the ai move
        var matches = refObject.findChessPieceAtPosition(ifrom);
        if (matches.length == 1)
        {
            // then execute the move
            matches[0].setPosition(ito);
            matches[0].snap();

            // tell the ai that the move has been executed and print the current state
            refObject.chessAI.move(move.from, move.to);
            refObject.chessAI.print();
        }
        else
        {
            console.log("Found", matches.length, "number of chess pieces at", ifrom, move.from, "but expected 1");
        }

    }
}

Then we call this chessboard function whenever our chess pieces are moved:

// hook our move function up to be called by Tabletop Playground whenever something snaps to something.
// Since we know how we set up the game table, all snap events are always chess pieces that have been
// moved on the board
refObject.onSnapped.add(
	function(obj, player, snapPoint, grabPosition)
	{
		// just inform the chess board
		var parent = snapPoint.getParentObject();
		if (parent.getId() == "chessboard") {
			parent.onPieceMoved(obj, grabPosition);
		}
	}
);

Now we already have a basic proof of concept, even though there are still some required features missing and some polishing to be done.

Capturing pieces

After a player move, we have to check if there is another chess piece below it. If yes, it has been captured and we have to remove it from the board. Similarly, we need to check if the AI move will lead to a capture and handle it.

/**
 * Moves a chesspiece to a discard pile
 */
refObject.discardChessPiece = function(piece)
{
    piece.destroy();
}

/**
 * Custom function to be called by (white) chess pieces after they have moved
 */
refObject.onPieceMoved = function(piece, grabPosition)
{
    
    ... // snip

    // make sure the the piece was actually moved and not just snapped in place
    if (from !== to)
    {
        // first see if there is already a piece at this position
        matches = this.findChessPieceAtPosition(piece.getPosition());
        if (matches.length == 2)
        {
            // remove the lower chess piece and put it on the discard pile
            this.discardChessPiece(matches[0]);
        }
        
        ... // snip
        
        // see if there is already a piece at the position and remove it
        var existingObjects = refObject.findChessPieceAtPosition(ito);
        if (existingObjects.length == 1)
        {
            refObject.discardChessPiece(existingObjects[0]);
        }
        
        // tell the ai that the move has been executed and print state to console
        refObject.chessAI.move(move.from, move.to);
        
    }

Check and checkmate

It’s easy for the human player to miss a check, so it’s a good idea to display a message for that. In addition, we write a gameover function that we can call when the AI has detected that there is either a checkmate or draw situation.

/**
 * Send a message to all players
 */
refObject.playerMessage = function(message)
{
    // Send check to all players
    world.getAllPlayers().forEach(
        function(player)
        {
            player.sendChatMessage(message, new Color(150, 150, 10, 255));
            player.showMessage(message);
        }
    );
}


/**
 * Sets the game to end state with the given reason
 */
refObject.endGame = function(reason)
{

    this.playerMessage(reason);

    // set all chess pieces to ground type -> no more moves
    world.getAllObjects().forEach(
        function(obj)
        {
			obj.setObjectType(1);
        }
    );
}

We can simply check these states by asking the chess AI and handle it accordingly.

        ... // snip
        
        // tell AI about the move and print the chess state to console
        console.log("Player move", from, "->", to);
        this.chessAI.move(from, to);
        if (this.chessAI.in_checkmate()) { this.endGame("Checkmate!"); return; }
        if (this.chessAI.in_stalemate()) { this.endGame("Draw!"); return; }
        
        ... // snip
        
        // then execute the move
        matches[0].setPosition(ito, 2);
        matches[0].snap();

        // tell the ai that the move has been executed and print state to console
        this.chessAI.move(move.from, move.to);
        this.chessAI.print();

        if (this.chessAI.in_checkmate()) { this.endGame("Checkmate!"); return; }
        if (this.chessAI.in_stalemate()) { this.endGame("Draw!"); return; }

        if (this.chessAI.in_check()) {
            this.player.sendChatMessage("Check", new Color(150, 150, 10, 255));
            this.player.showMessage("Check");
        }

Polishing responsiveness

Right now, the AI reaction to a player move is fully done in the onSnap callback. Since it can take a second to calculate the response, this is immediately noticeable because the white piece is only snapped to the board once this computation is completed. We can however wrap the AI response code into a process.nextTick() function.

        ... // snip
        
        this.chessAI.move(from, to);
        if (this.chessAI.in_checkmate()) { this.endGame("Checkmate!"); return; }
        if (this.chessAI.in_stalemate()) { this.endGame("Draw!"); return; }
        
        // handle AI response in next tick.
        // it can take a second or two to calculate the response, which leads to the
        // game freezing for a bit. If the player move is finished first, it's not notieceable
        var refObject = this; // set explicitly so we can use it in the nexttick callback
        process.nextTick(function(){
            // let the AI decide on a good move to make next
            var move = refObject.chessAI.get_best_move();
            
            ... // snip
            
        });

Like this, the player move is executed immediately and the chess AI response will follow up in the next javascript tick. The player will probably not even notice that the interface turns unresponsive for a quick moment. Note that this delay is mainly due to the comparatively huge computation time required to calculate a reasonably good chess move. For a more polished AI, the computation could be split up over multiple frames so the slowdown is not noticeable.

Polishing – Capture pile

Instead of simply deleting captured pieces, we can instead move them to capture piles next to the board

/**
 * Moves a chesspiece to a capture pile
 */
refObject.discardChessPiece = function(piece)
{
    var locPos = this.getSnapPoint(0).getLocalPosition();
    // use different discard piles for black and white pieces
    if (piece.getTemplateName().includes("White"))
    {
        // right side of the board (white view)
        locPos = locPos.add(new Vector(20, -20, 20));
    }
    else
    {
        // left side of the board (white view)
        locPos = locPos.add(new Vector(20, 70, 20));
    }
    
    // set to regular object so physics are simulated
    piece.setObjectType(0);
    
    // move pice to discard pile and set to random rotation
    piece.setPosition(this.localPositionToWorld(locPos), 5);
    piece.setRotation(new Rotator(Math.random() * 360, Math.random() * 360, Math.random() * 360), 1);
}

Exercises for the reader

The project is missing a few more things to be called complete. Can you add them yourself?

  • Prevent illegal player moves
  • Support Castling
  • Support En passant
  • Support Promotion