Saving and loading

There are some things to consider when you want to keep data in your scripts in order to stay compatible with loading save states (which also happens when using undo/redo). Whenever a save state is loaded, the entire scripting environment gets reset and all scripts are run again.

If you have any variables that may change over the course of playing the game, they will disappear when loading, and may be be re-initialized when your scripts run again. If you want to persist your variables, you need to use the setSavedData and getSavedData methods in GameWorld and GameObject.

These methods allow you to save a string for each object and for the whole game. The strings are stored in the save state, and you can retrieve them again after a state was loaded. The usual pattern would be calling setSavedData whenever your state changes to make sure that whenever the game is saved, the string contains the latest information. Then, you call getSavedData when you initialize your variables, check if saved data exists, and update your variables accordingly.

You can find a simple example of using saved data in the hit point counter in the UI tutorial. If you need to store multiple values, a convenient way is using JSON.stringify and JSON.parse to convert objects into strings. The advanced UI tutorial has an example for how this can be done.

The saved data needs to be sent across the network to all players. Even though scripts only run on the host, every player can save the current game state and load it later in a new game as host. In order to keep network traffic low and the gameplay smooth, try to minimize the size of your saved data strings, and only modify them when necessary.

Advanced UI: Character Sheet

This article explains how to build complex user interfaces using the example of a role playing game character sheet. lt assume that you already know your way around Tabletop Playground UI scripting, see Scripting basics and User Interface to learn more. We won’t cover all code from the example here, you can find the full package on mod.io: https://tabletopplayground.mod.io/dnd-character-sheet-example

Base Object

We want the character sheet to be an object in the game, so first we need a base object to attach the UI to. It will just serve as a handle so the character sheet can be grabbed and moved around the table. The easiest way to create such an object is using a Tile/Token template and choosing an appropriate size. We also need to configure the script in the template so every time when a character sheet is spawned, the UI is automatically added. Finally, we disable stacking because we don’t need stacks of character sheets.

Basic considerations

A Dungeons & Dragons character is defined by different types of information: there’s generic info like the character name and background. Then there are numeric values such as their ability scores, proficiency, and hit points. And there are skills and saving throws that characters can be proficient in. The skill and saving throw modifiers depend on ability scores and proficiency, so they can be calculated automatically.

We start by defining the different abilities with their abbreviations and full names, and skills and their associated abilities. Next, we define all widgets that the player can modify, so we can easily access them when saving and loading. We’re also creating a Canvas object where we’ll add all other widgets.

const tp = require('@tabletop-playground/api');

const abilities = {
    "STR": "Strength", 
    "DEX": "Dexterity", 
    "CON": "Constitution", 
    "INT": "Intelligence", 
    "WIS": "Wisdom", 
    "CHA": "Charisma"};
const skills = {
    "Acrobatics": "DEX",
    "Animal Handling": "WIS",
    "Arcana": "INT",
    "Athletics": "STR",
    "Deception": "CHA",
    "History": "INT",
    "Insight": "WIS",
    "Intimidation": "CHA",
    "Investigation": "INT",
    "Medicine": "WIS",
    "Nature": "INT",
    "Perception": "WIS",
    "Performance": "CHA",
    "Persuasion": "CHA",
    "Religion": "INT",
    "Sleight of Hand": "DEX",
    "Stealth": "DEX",
    "Survival": "WIS"
}

let nameTextBox = new tp.TextBox().setMaxLength(100).setFontSize(20).setText("Character Name");
let classTextBox = new tp.TextBox().setMaxLength(30).setText("Class");
let raceTextBox = new tp.TextBox().setMaxLength(30).setText("Race");
let backgroundTextBox = new tp.TextBox().setMaxLength(30).setText("Background");
let alignmentSelection = new tp.SelectionBox();
let xpTextBox = new tp.TextBox().setInputType(4).setMaxLength(7).setText("0");
let abilityTextBoxes = {};
let proficiencyTextBox;
let acTextBox;
let initiativeTextBox;
let inspirationTextBox;
let currentHPTextBox;
let maxHPTextBox;
let deathSuccessCheckBoxes = [];
let deathFailureCheckBoxes = [];
let saveCheckBoxes = [];
let skillCheckBoxes = [];

let canvas = new tp.Canvas();

Next, we define a few small helper functions that will be useful when connecting the ability score widgets with other widgets that show modifiers based on the ability scores:

function scoreToModifier(scoreText) {
    return Math.floor((parseInt(scoreText) - 10) / 2);
}

function modifierToString(modifier) {
    if (modifier < 0) {
        return modifier.toString();
    }
    return "+" + modifier.toString();
}

function scoreToModifierString(scoreText) {
    return modifierToString(scoreToModifier(scoreText));
}

function getAbilityModifier(ability) {
    return scoreToModifier(abilityTextBoxes[ability].getText());
}

Adding widgets

Now it’s time to start adding widgets to the canvas. For the basic information like character name, class, or background, we simply add a Text and corresponding TextBox. The ability scores are more interesting: For each of the six abilities, we’ll add a text with the abbreviation, a text box for the score with two buttons to easily increase or decrease the score, and another text that shows the modifier based on the score.

Since we’ll be adding more text boxes with associated “+” and “-” buttons, we’re creating a function for it:

function addCounter(x, y, digits = 2, min = 0) {
    let plusButton = new tp.Button().setFontSize(9).setText("+");
    let minusButton = new tp.Button().setFontSize(9).setText("-");
    let textBox = new tp.TextBox().setMaxLength(digits).setFontSize(19);
    if (min < 0) {  
        textBox.setInputType(3);
    }
    else {
        textBox.setInputType(4);
    }

    plusButton.onClicked.add(() => {
        textBox.setText(Math.min(10**digits - 1, parseInt(textBox.getText()) + 1).toString()); 
    });
    minusButton.onClicked.add(() => { 
        textBox.setText(Math.max(min, parseInt(textBox.getText()) - 1).toString());
    });

    canvas.addChild(plusButton, x, y, 20, 20);
    canvas.addChild(minusButton, x, y + 22, 20, 20);
    canvas.addChild(textBox, x + 22, y, digits * 15 + 9, 42);

    return textBox;
}

The function takes the coordinates where the widgets should be added to the canvas, the maximum number of allowed digits, and the smallest allowed value. It creates two small buttons and one large text box. The onClicked callback of the buttons executes a function that increments or decrements the value in the text boxes. Finally, the widgets are added to the canvas and the text box is returned.

Using this function, we define an addAbility function and call it for each ability:

function addAbility(name, x, y) {
    let textBox = addCounter(x + 58, y);
    let modifierText = new tp.Text().setFontSize(19);

    function updateModifierText() {
        modifierText.setText(scoreToModifierString(textBox.getText())); 
    }
    textBox.onTextChanged.add(updateModifierText);

    canvas.addChild(new tp.Text().setText(name).setFontSize(18), x, y + 4, 55, 30);
    canvas.addChild(modifierText, x + 125, y + 4, 50, 30);

    textBox.setText("10");
    return textBox;
}

let y_offset = 0;
for (ability in abilities) {
    abilityTextBoxes[ability] = addAbility(ability, 10, 235 + y_offset);
    y_offset += 50;
}

Whenever the text box created by addCounter changes, the modifier text next to it is updated using the helper functions we defined above. When creating the ability widgets in the loop, we add the text box widgets to the abilityTextBoxes object so we can easily reference them later.

To add the skills and saving throws, we define another function:

function addSkill(name, ability, x, y) {
    let checkBox = new tp.CheckBox();
    let modifierText = new tp.Text();
    canvas.addChild(checkBox, x, y, 20, 20);
    canvas.addChild(new tp.Border().setColor(new tp.Color(0.07, 0.07, 0.07)).setChild(modifierText), x + 17, y - 2, 32, 24);
    canvas.addChild(new tp.Text().setText(name), x + 54, y, 200, 25);

    function updateSkillModifier() {
        let modifier = getAbilityModifier(ability);
        if (checkBox.isChecked()) {
            modifier += getProficiency();
        }
        modifierText.setText(modifierToString(modifier));
    };
    checkBox.onCheckStateChanged.add(updateSkillModifier);
    abilityTextBoxes[ability].onTextChanged.add(updateSkillModifier);
    proficiencyTextBox.onTextChanged.add(updateSkillModifier);
    updateSkillModifier();

    return checkBox;
}

We add three elements to the canvas: a checkbox for the player to indicate if they are proficient in the skill, a modifier text surrounded by a border, and another text to display the name. We then define a function to update the modifier based on the associated ability and the proficiency modifier if the player has indicated that they’re proficient by activating the check box. We then add this function to several callbacks: we want to update the modifier if the new check box state changes, or if the associated ability or proficiency value changes.

Now we just have to call this function for every skill and ability (since there is one saving throw for each ability):

let y_offset = 0;
for (ability in abilities) {
    saveCheckBoxes.push(addSkill(abilities[ability], ability, 220, 377 + y_offset));
    y_offset += 26;
}

y_offset = 0;
for (skill in skills) {
    skillCheckBoxes.push(addSkill(skill, skills[skill], 410, 65 + y_offset));
    y_offset += 26;
}

Persisting state

The final functionality we’ll cover here is how to store what they player entered. We use GameObject.setSavedData to store entries for all player editable information. Since the saved data strings have to be sent over the network to all players, we keep it small by using arrays of 1/0 instead of individual entries with true/false. We could reduce the size further by not using JSON, but that would make it more difficult to change the character sheet or save format later on.

// Store reference object in variable to use in closures to save and load
var rObj = tp.refObject;
var save = () => {
    rObj.setSavedData(nameTextBox.getText(), "name");
    rObj.setSavedData(classTextBox.getText(), "class");
    rObj.setSavedData(raceTextBox.getText(), "race");
    rObj.setSavedData(backgroundTextBox.getText(), "background");
    rObj.setSavedData(alignmentSelection.getSelectedIndex(), "alignment");
    rObj.setSavedData(xpTextBox.getText(), "xp");
    rObj.setSavedData(proficiencyTextBox.getText(), "proficiency");
    rObj.setSavedData(acTextBox.getText(), "ac");
    rObj.setSavedData(initiativeTextBox.getText(), "initiative");
    rObj.setSavedData(inspirationTextBox.getText(), "inspiration");
    rObj.setSavedData(currentHPTextBox.getText(), "hp");
    rObj.setSavedData(maxHPTextBox.getText(), "maxHp");

    for (ability in abilities) {
        rObj.setSavedData(parseInt(abilityTextBoxes[ability].getText()), ability);
    }

    var ds = []
    for (checkBox of deathSuccessCheckBoxes) {
        ds.push(checkBox.isChecked() ? 1 : 0);
    }
    rObj.setSavedData(JSON.stringify(ds), "ds");

    var df = []
    for (checkBox of deathFailureCheckBoxes) {
        df.push(checkBox.isChecked() ? 1 : 0);
    }
    rObj.setSavedData(JSON.stringify(df), "df");

    var sv = []
    for (checkBox of saveCheckBoxes) {
        sv.push(checkBox.isChecked() ? 1 : 0);
    }
    rObj.setSavedData(JSON.stringify(sv), "sv");

    var sk = []
    for (checkBox of skillCheckBoxes) {
        sk.push(checkBox.isChecked() ? 1 : 0);
    }
    rObj.setSavedData(JSON.stringify(sk), "sk");
}

The corresponding load function is not shown here but works in the same way: read the entries using GameObject.getSavedData, parse arrays as JSON strings and update the state of all widgets based on the read values.

Putting it all together

Now we just need to create a UIElement with the canvas and attach it to the refObject. We also use a Border to get a background color. Finally, we call the load function to update the sheet from previously stored information, and use setInterval to call save every 10 seconds. We could call save whenever the player modifies any information, but that would result in lots of updates when the player enters text and increase required network bandwidth. On the other hand, calling save again when nothing has changed doesn’t result in a network update.

// Add UI to object
let ui = new tp.UIElement();
ui.position = new tp.Vector(0, 0, 7);
ui.widget = new tp.Border().setChild(canvas);
ui.width = 600;
ui.height = 537;
ui.useWidgetSize = false;
tp.refObject.addUI(ui);

// Try to load now and save every 10 seconds
load();
setInterval(save, 10000);

And that’s all you need to create a fully functional automated character sheet! Here’s how the final result looks like in-game:

User Interface

You can use scripting to create user interface elements like buttons or text fields in the world. When players interact with the UI elements, they generate events that you can subscribe to. The following examples will assume that you already know your way around Tabletop Playground scripting, see Scripting basics to get started.

Object UI

There are two ways to add UI elements: globally or attached to an object. In both cases, you create a widget (like a button or check box), and you create a UIElement to determine how and where your widget is shown in the game. For example, here’s how to add a large text above an object:

let ui = new UIElement();
ui.position = new Vector(0, 0, 5); 
ui.widget = new Text().setText("Look at me!").setFontSize(28);
refObject.addUI(ui);

Note how the set methods on the widget return the widget itself so you can use method chaining. When the object is moved, the text will be moved along with it.

Global UI

When you want to add UI at a fixed position instead of attached to an object, you can use the the methods from GameWorld. For example, a button at the center of the table that prints a message when pressed can be created like this:

let printButton = new Button().setText("Print");
printButton.onClicked.add((button, player) => {
    console.log(player.getName(), "pressed the button!"); 
});

let ui = new UIElement();
ui.position = new Vector(0, 0, 80.5); // For 80 cm table height 
ui.widget = printButton;
world.addUI(ui);

Hit point counter example

That’s already all you need to know for creating a custom user interface for your game! Let’s try a more complex example: a hit point counter. It will have three components: a progress bar to show the current hit points and two buttons to increase or decrease the hit point count. We will implement it as a reusable function. You could have this function in a separate file that you import using require, or even in an npm library! For this tutorial, we’ll just leave it in the object script file.

const { Vector, Rotator, ProgressBar, Button, UIElement, refObject, Canvas } = require('@tabletop-playground/api');

function addHitPointCounter(hitPointObj, maxHitPoints) {
    function setBarValue(obj) {
        obj.progressBar
            .setText((`${obj.hitPoints}/${obj.maxHitPoints}`))
            .setProgress(obj.hitPoints / obj.maxHitPoints);
        obj.plusButton.setEnabled(obj.hitPoints < maxHitPoints);
    }
    
    function minusPressed(button, player) {
        let owner = button.getOwningObject();
        if (owner.hitPoints <= 1) {
            // Final hit point lost, die!
            owner.destroy();
        }
        else {
            owner.hitPoints--;
            setBarValue(owner);

            // Store hitpoints in game states (number is automatically converted to string) 
            owner.setSavedData(owner.hitPoints);
        }
    }
    
    function plusPressed(button, player) {
        let owner = button.getOwningObject();
        owner.hitPoints = Math.min(owner.hitPoints + 1, owner.maxHitPoints);
        setBarValue(owner);
    }
    
    // Add hit point variables to the object
    hitPointObj.maxHitPoints = maxHitPoints;

    // If this object is loaded from a game state or copied from another object, it may 
    // already have a stored hit point value.
    hitPointObj.hitPoints = parseInt(refObject.getSavedData());
    if (isNaN(hitPointObj.hitPoints)) {
        hitPointObj.hitPoints = maxHitPoints;
    }
    
    // Create widgets
    let minusButton = new Button().setText("-").setFontSize(11);
    minusButton.onClicked.add(minusPressed);
    
    hitPointObj.plusButton = new Button().setText("+").setFontSize(11);
    hitPointObj.plusButton.onClicked.add(plusPressed);

    hitPointObj.progressBar = new ProgressBar();
    setBarValue(hitPointObj);

    // Place widgets next to each other on a canvas
    let canvas = new Canvas();
    canvas.addChild(minusButton, 0, 2, 25, 26);
    canvas.addChild(hitPointObj.progressBar, 25, 0, 60, 30);
    canvas.addChild(hitPointObj.plusButton, 85, 2, 25, 26);
    
    // Add the UI element at an angle
    let ui = new UIElement();
    ui.useWidgetSize = false;
    ui.width = 110;
    ui.height = 30;
    ui.position = new Vector(-3, 0, 0);
    ui.rotation = new Rotator(25, 0, 0);
    ui.scale = 0.5;
    ui.widget = canvas;
    hitPointObj.addUI(ui);
}

// Add a counter for 10 hit points to our reference object
addHitPointCounter(refObject, 10);

Note that we used more properties of UIElement in this example: By default, the UI element will just as large as necessary, and one UI pixel corresponds to one millimeter in the 3D world. When you set useWidgetSize to false, the pixel size is determined by the width and height properties. With scale, you can determine how big each pixel should be in the 3D world. With the default scale of 1, each pixel corresponds to one millimeter. When you use a Canvas to layout your widgets like in this example, you need to give an explicit width and height in the UIElement because a Canvas does not have a preferred size on its own.

Available widgets

You can find all the available widgets in the API documentation in the list of subclasses for Widget. Border is a special widget that is used as a container for other widgets when you want a background color. For example, a Slider usually has a transparent background, and you can wrap it in a border to make it easier to see:

let slider = new Slider().setMaxValue(10);
let ui = new UIElement();
ui.widget = new Border().setChild(slider);
refObject.addUI(ui);
Slider without and with border

The Canvas, HorizontalBox and VerticalBox widgets are containers, too. You can add child widgets to create composite interfaces, all within one UIElement! If you want to learn more about creating more complex UIs, check out the advanced UI tutorial.

Using boardgame.io

You can embed boardgame.io, an open source game engine for turn based games, in the Tabletop Playground JavaScript environment. If you already have a game implemented using boardgame.io, this allows you to use Tabletop Playground as visualization and networking layer, while you can reuse all the game logic you already have!

If you create a new game in Tabletop Playground, using boardgame.io can help you to keep track of the game state and rules. And when you use boardgame.io to create your package, you can easily port your game to be playable in a web browser by connecting to a different frontend.

Using boardgame.io together with Tabletop Playground means that you won’t use some of the functionality that boardgame.io offers: the multiplayer and lobby features are taken care of by Tabletop Playground, and your JavaScript code only runs on the host. You also don’t need the React bindings, instead you’ll be writing code to use Tabletop Playground as your view layer!

Set up boardgame.io

This tutorial will assume that you have some JavaScript knowledge and you are familiar with how scripting in Tabletop Playground works (see Scripting Basics). In order to set up boardgame.io for your package, you first need to make sure that you have npm installed. Then open up a command line in your package’s “Scripts” folder and enter:

npm install boardgame.io

This will install boardgame.io and it’s dependencies in the “node_modules” folder in your scripts directory. You now have boardgame.io available in the scripts for your package.

Create the game logic

As an example of how boardgame.io can be used, we’ll adapt the tic-tac-toe tutorial at https://boardgame.io/documentation/#/tutorial to work within Tabletop Playground. You can download the completed package at mod.io: https://tabletopplayground.mod.io/tic-tac-toe-boardgameio

For the game logic, we can use the code from the tutorial almost unchanged, we only need to change the first line from import to require and use module.exports instead of export:

import { INVALID_MOVE } from 'boardgame.io/core';

const TicTacToe = {
  setup: () => ({ cells: Array(9).fill(null) }),

  turn: {
    moveLimit: 1,
  },

  moves: {
    clickCell: (G, ctx, id) => {
      if (G.cells[id] !== null) {
        return INVALID_MOVE;
      }
      G.cells[id] = ctx.currentPlayer;
    },
  },

  endIf: (G, ctx) => {
    if (IsVictory(G.cells)) {
      return { winner: ctx.currentPlayer };
    }
    if (IsDraw(G.cells)) {
      return { draw: true };
    }
  },
};

// Return true if `cells` is in a winning configuration.
function IsVictory(cells) {
  const positions = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6],
  ];

  const isRowComplete = row => {
    const symbols = row.map(i => cells[i]);
    return symbols.every(i => i !== null && i === symbols[0])
  };

  return positions.map(isRowComplete).some(i => i === true);
}

// Return true if all `cells` are occupied.
function IsDraw(cells) {
  return cells.filter(c => c === null).length === 0;
}

module.exports = TicTacToe;

This gives us the basic mechanics of a tic-tac-toe game and a check whether the game is over and who has won.

Connect with Tabletop Playground

Instead of writing a view layer for a browser, we now connect the game logic to Tabletop Playground objects. For this example, we can use a board with snap points, and two card stacks to represent the X and O symbols that players can place.

Simple tic-tac-toe in Tabletop Playground

To connect these objects with the game logic represented in boardgame.io, we create a global script. First, we import the Tabletop Playground API, our TicTacToe object, and the boardgame.io client. Then we initialize the client object:

const {world, globalEvents} = require('@tabletop-playground/api');
const { TicTacToe } = require('./Game');
const { Client } = require('boardgame.io/client');

client = Client({ game: TicTacToe });
client.start();

Now we need to get the information about player actions to boardgame.io. Since we created a board with snap points, the relevant event is onSnapped: when an X or O card is snapped to the board, a player has placed a mark. We add the onSnapped callback new objects of the card types – new objects are created when taking a card from a stack.

o_id = world.getObjectById('O Cards').getTemplateId();
x_id = world.getObjectById('X Cards').getTemplateId();

function snapped(obj, player, snap) {
    client.moves.clickCell(snap.getIndex());
};

globalEvents.onObjectCreated.add(function(obj) {
    if (obj.getTemplateId() === o_id || obj.getTemplateId() === x_id) {
        obj.onSnapped.add(snapped);
    }
});

Finally, we can subscribe to state updates from boardgame.io to react to events in the game. For example, we can show a message when the game is over:

client.subscribe(function (state) {
    if (state.ctx.gameover) {
        if (state.ctx.gameover.winner !== undefined) {
            console.log("Winner: " + state.ctx.gameover.winner);
        }
        else {
            console.log("Draw");
        }
    }
})

And that’s it! With just a few lines of code, we’ve connected an existing boardgame.io game to Tabletop Playground. There’s still a lot that could be improved, of course. Most importantly, actions that players can take are usually not restricted in Tabletop Playground, so the scripts could check whether a player action is valid and react appropriately if it is not. For the tic-tac-toe game, this could mean moving a card back to the stack if it is snapped to an invalid position or dropped somewhere else.

Note that recent versions of boardgame.io use the library nanoid, which is not compatible with the JavaScript environment in Tabletop Playground. It will cause an error to be printed on the console when you import the boardgame.io client (“Cannot use import statement outside a module”). Apart from that message, everything should still work fine though, the parts of boardgame.io where nanoid is called are not required when used within Tabletop Playground.

Chess AI Scripting Example

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
let 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)
	{
		let 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)
{
    let localPos = this.worldPositionToLocal(pos);
    let col = String.fromCharCode(96 + Math.floor(4.587 - localPos.y / 5.572 + 0.5));
    let 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)
{
    let x = (96 + 4.578 - chessPos.charCodeAt(0)) * 5.572;
    let 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)
{
    let result = [];
    let from = pos.add(new Vector(0, 0, -10));
    let to = pos.add(new Vector(0, 0, 10));
    let matches = world.lineTrace(from, to);
    if (matches)
    {
        // filter out chessboard from results
        for (let 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
    let from = this.worldPosToChessPos(grabPosition);
    let 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
        let move = refObject.chessAI.get_best_move();
        console.log("Response", move.from, "->", move.to);

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

        // try to execute the ai move
        let 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
		let 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
        let 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
        let 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
            let 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)
{
    let 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