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"
}

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

var 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) {
    var plusButton = new tp.Button().setFontSize(9).setText("+");
    var minusButton = new tp.Button().setFontSize(9).setText("-");
    var 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) {
    var textBox = addCounter(x + 58, y);
    var 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;
}

var 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) {
    var checkBox = new tp.CheckBox();
    var 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() {
        var 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):

var 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 define a toJSON function that stores all player editable information in a JSON string. The string is the stored using GameObject.setSavedData. Since the saved data string has to be sent over the network to all players, we keep it small by using abbreviated names and 1/0 instead of 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.

function toJSON() {
    saveData = {
        "nm": nameTextBox.getText(),
        "cl": classTextBox.getText(),
        "rc": raceTextBox.getText(),
        "bg": backgroundTextBox.getText(),
        "al": alignmentSelection.getSelectedIndex(),
        "xp": parseInt(xpTextBox.getText()),
        "pf": parseInt(proficiencyTextBox.getText()),
        "ac": parseInt(acTextBox.getText()),
        "ini": parseInt(initiativeTextBox.getText()),
        "ins": parseInt(inspirationTextBox.getText()),
        "hp": parseInt(currentHPTextBox.getText()),
        "mhp": parseInt(maxHPTextBox.getText()),
        "ds": [],
        "df": [],
        "sv": [],
        "sk": []
    }

    for (ability in abilities) {
        saveData[ability] = parseInt(abilityTextBoxes[ability].getText());
    }

    for (checkBox of deathSuccessCheckBoxes) {
        saveData["ds"].push(checkBox.isChecked() ? 1 : 0);
    }
    for (checkBox of deathFailureCheckBoxes) {
        saveData["df"].push(checkBox.isChecked() ? 1 : 0);
    }
    for (checkBox of saveCheckBoxes) {
        saveData["sv"].push(checkBox.isChecked() ? 1 : 0);
    }
    for (checkBox of skillCheckBoxes) {
        saveData["sk"].push(checkBox.isChecked() ? 1 : 0);
    }
    
    return JSON.stringify(saveData);
}

// Store reference object in variable to use in closures
var rObj = tp.refObject;
var save = () => { rObj.setSavedData(toJSON()); };

The corresponding load function is not shown here but works in the same way: read the JSON string and parsing it to an object, then update the state of all widgets based on read object.

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
ui = new tp.UIElement();
var 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.attachUI(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:

var ui = new UIElement();
ui.position = new Vector(0, 0, 5); 
ui.widget = new Text().setText("Look at me!").setFontSize(28);
refObject.attachUI(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:

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

var 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) {
        var 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) {
        var 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
    var 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
    var 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
    var 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.attachUI(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:

var slider = new Slider().setMaxValue(10);
var ui = new UIElement();
ui.widget = new Border().setChild(slider);
refObject.attachUI(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
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

Scripting 101: How to get started

This tutorial is aimed at people who have coded before but don’t feel comfortable yet to dive straight into the API. It’s good to have some JavaScript knowledge, but not required.

Where do I put my script files?

Scripts have to be created outside of the Tabletop Playground Editor with your favourite editor. We recommend Visual Studio Code, a lightweight editor with code completion for all platforms. After you have created a new project in Tabletop Playground, simply navigate to your package folder and put your script files into the Scripts folder:

[Tabletop Playground Folder]\TabletopPlayground\PersistentDownloadDir\[Name of your package]\Scripts

Any .js script files in this folder can be selected in Tabletop Playground.

Global vs. object scripts

There are two way to include script files in your project. One global script and objects scripts which are directly associated with individual objects.

Global script

The global script can be added through the session options when editing a state for your project.

You can find the session options in the right click menu

It is executed once, as soon as the corresponding state is loaded. It is loaded after all objects have been spawned, so it’s a good place to set up all your game related variables and logic.

Object scripts

An object script is directly attached to an object. It can be added either through a template or directly attached to an individual model when editing a state.

When an object is loaded into the world, any attached object script is executed (once). This happens after everything in the world is loaded (so all objects are there and after the global script has been run. If there is both a directly attached script as well as a template script, only the directly attached script gets executed.

Making stuff happen

As mentioned above, scripts are only executed once. So what you do in your script file is to set up any data and methods that you need and then tell Tabletop Playground when to call which of your functions (registering callbacks).

Don’t put any infinite loops inside the script! This will cause the script to hang and potentially crash Tabletop Playground.

For example for an object you can register an onGrab function which gets called by Tabletop Playground, whenever that object gets grabbed. Let’s look at some example code:

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


// add a new function to be called by Tabletop Playground when
// this object gets grabbed by any player
refObject.onGrab.add(
    function(obj, player)
    {
        console.log("I got grabbed by", player.getName());
    }
);

You can also define the function separately, but you won’t get auto completion for parameters this way. It can help making your code more readable though if you’re writing a long complicated function, or if you want to use the same function in multiple places.

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

// define our function
grabCallBack = function(obj, player)
{
    console.log("I got grabbed by", player.getName());
}

// add a new function to be called by Tabletop Playground when
// this object gets grabbed by any player
refObject.onGrab.add(grabCallBack);

If you add this script file to an object, whenever any player picks it up, it will put the defined message into the script log:

Console output after the chess piece got grabbed by the player Zorfmorf

You probably already noticed the usage of the variable refObject. It’s provided by Tabletop Playground and is the GameObject representation of your object, in the above example the chess piece. We can therefore make use of all the functionality outlined in the API. It’s only available when you attach a script directly to an object.

If you look at the definition of the onGrab function that we used to register our function, the function takes a GameObject (the object being grabbed) and a Player (the player grabbing the object) as parameter, which is why we have to add these parameters to our callback function grabCallBack.

So as a next step, let’s try to add a callback for onTick, which is called on every game tick, many times per second. Be careful what you do inside this method. If you put any complicated computations or queries in here, this can have a bad performance impact!

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

// add a callback for on-tick
refObject.onTick.add(function(object, deltaTime) {
    // let's launch the object slowly upwards like a rocket
    object.applyForce(new Vector(0, 0, 1000));
});

This should launch whatever piece you attach this to slowly upwards. Note that we omitted the second parameter for applyForce (useMass) since it defaults to false which is what we wanted in this situation. If your callback doesn’t seem to work, make sure that you provided all the required parameters.

Global Events

Next we are going to take a look the global script. In generally you want to put all code and callbacks here that is independent of individual objects. Let’s add some global events and try them out!

const { globalEvents } = require('@tabletop-playground/api');

// called whenever a chat message is sent
globalEvents.onChatMessage.add(function(player, message) {
    // show a message on screen of the player who wrote the message
    player.showMessage("Should you really say something like \"" + message + "\"");
});

// called whenever one ore more dice are rolled and sets all dice to six
globalEvents.onDiceRolled.add(function(player, dice) {
    dice.forEach(
        function(d, index) {
            // set the dice to 6 (faces 0 to 5)
            d.setCurrentFace(5);
        }
    );
});
It just works!

The world object

The world object allows you to interact with the world, which is a representation of the currently loaded level and everything that is included in it. It’s especially useful to find all or specific objects and players:

  • Load in new objects
  • Get a reference for a specific objects or a list of all existing objects
  • Draw debug lines
  • Line traces or sphere traces