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:

Table of Contents