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://mod.io/g/tabletopplayground/m/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:
