Neil's Blog

Ironsworn Stats

Sun Jan 26 19:09:41 2020 +1100

I've recently gotten into Ironsworn, a table top RPG by Shawn Tomkin. It is free, too. I discovered it by watching Adam Koebel's first look, setup and playthrough of it.

It's two big selling points for me is that is built to be played without a GM, potentially solo; and that it isn't just a dungeon crawl game. Adamantly I'm only two sessions in, but I've not had to kill anyone and claim the title of murder hobo yet.

I was originally writing an elisp library for it, as that would allow me to play it inline with my write up of my sessions (which will go up here, when they are more than just dot points), but I realised that building a pretty character sheet and ability roller would make a good portfolio piece, and good content for my blog, so here we are.

Through this series I'll build a character sheet, and then potentially look into adding moves and oracles, depending on if I don't come up with a better portfolio piece to build. Ironsworn and its text and content is Copyright © Shawn Tomkin and released under a CC-BY-NC-SA 4.0 licence.

Dice rolling and stat blocks

At its core, like most table top RPGs, Ironsworn is a game where you roll dice against stats in order to determine if you succeed or fail. As such, it is the perfect place to start writing functionality.

In Ironsworn, to determine success or failure, you roll two challenge dice and compare them to the result of your action die plus a stat, or a progress tracker. Starting with Stats, we need to be able to store and display five stats, and be able roll them, display and record the rolls.

I'll build a stat block of five literal blocks which, when clicked on, will roll the stat and display the result in two places, a card placed in a card draw, and a text log which records all of your actions.

Here is the final result, have a play and then scroll past to see how I've done it.

🎲Edge
🎲Heart
🎲Iron
🎲Shadow
🎲Wits

Building the Stat block and dice roller

UI Stat Block

The first thing I need are some stats. I'll set them pragmatically latter, when I setup the log to both be readable input as well as a log of events, but for the moment I'll just hard code them.

let stats = {iron: 1,
             heart: 1,
             shadow: 2,
             edge: 3,
             wits: 2};

I now need to actually draw the stat block to the screen. I'll write up a HTML block that I will then edited when the stats change, which is currently only on page load. I want each stat block to show the stat's name and value, as well as something to indicate that the stat can be rolled. For now I'll use the d6 emoji, but I may change it latter when I clean up the look of the whole sheet.

<div class="stat-block"> 
<div id="stat-edge" class="stat-box" role="button" tabindex="0">
<span>🎲</span><span class="stat-number"></span><span>Edge</span>
</div>
<div id="stat-heart" class="stat-box" role="button" tabindex="0">
<span>🎲</span><span class="stat-number"></span><span>Heart</span>
</div>
<div id="stat-iron" class="stat-box" role="button" tabindex="0">
<span>🎲</span><span class="stat-number"></span><span>Iron</span>
</div>
<div id="stat-shadow" class="stat-box" role="button" tabindex="0">
<span>🎲</span><span class="stat-number"></span><span>Shadow</span>
</div>
<div id="stat-wits" class="stat-box" role="button" tabindex="0">
<span>🎲</span><span class="stat-number"></span><span>Wits</span>
</div>
</div>

I'll then achieve editing the stat blocks by writing a function I can place in callbacks latter, which takes jQuery selector text and the name of the stat to use as a key.

In the future I may reimplement this function to use predefined selectors so that I'm not finding things on the DOM all the time, but performance is an issue, simplicity is key.

/*
 * updateStatBox(element, key)
 * Edit the element text with the given stat.
 *
 * element: jQuery selector string to edit.
 * key:     string key to the stats block.
 */
function updateStatBox(element, key) {
    $( '#' + element ).find(".stat-number").text(stats[key]);
}

$( document ).ready(() => {
    updateStatBox("stat-edge", "edge");
    updateStatBox("stat-heart", "heart");
    updateStatBox("stat-iron", "iron");
    updateStatBox("stat-shadow", "shadow");
    updateStatBox("stat-wits", "wits");
});

All that's left is to format the HTML. I want them to be five literal blocks, mimicking what is on the official character sheet. I want each span to be centred and stacked, and the number to be the predominant element.

I'll also set up :hover and :focus classes, to visually indicate that it can be interacted with when the user gives them focus.

.stat-block {
    display: flex;
}

.stat-box {
    border: 2px solid #444444;
    width: 20%;
    background: #bbbbbb;
    color: #222222;
}

.stat-box:focus, .stat-box:hover {
    border: 3px solid black;
    color: black;
}

.stat-box span {
    display: table;
    margin-left: auto;
    margin-right: auto;
}

.stat-number {
    font-size: 30pt;
}

Dice rolling and results cards

When you click on a stat, it will roll make an action roll, and then add a card showing the results of the roll to the Results Log.

A results log would be the place that records all actions taken in the course of a session, but in this instance we just want to show the last roll. This will probably go next to or under the character sheet.

I'll just uses a div that looks like my blog's code blocks for now.

<div class="ResultsLogCards">

</div>
.ResultsLogCards {
    margin-top: 3em;
    min-height: 10em;
    background-color: #252525;
    padding: .5em;
    border: 1px solid black;
}

Before we can do anything else, we need some dice rolling functionality. We need to be able to roll d6s and d10s.

function rollDice(dice) {
    return Math.floor((Math.random() * dice) + 1);
}

Now that we can roll rice, we need to be able to make an action roll.

There are two kinds of rolls in Ironsworn, action rolls and progress rolls. With both rolls, you need to roll two d10s, the challenge dice. The difference is if you compare the challenge dice to a d6, your action die, and a stat, or if you compare to the value of a progress tracker.

In both cases, we need an integer value to compare the challenge dice to, we just don't know if we are adding a d6 to it, or using it as is. It is trivial to only get it to roll the action die if it is not a progress roll. I let it default to not being a progress roll because I think action rolls are more common.

/*
 * roll(value, progress=false)
 * Make an Ironsworn roll, returning an array with the
 * action die/progress value and the challenge dice.
 *
 * value:    An integer value to add to a challenge die,
 *           or the progress value to compare to the
 *           challenge dice.
 * progress: If it is a progress roll, defaults to false.
 *
 * Returns: [int value, int challengeDie, int challengeDie]
 */
function roll(value, progress=false) {
    return [progress ? value : rollDice(6)+value,
            rollDice(10),
            rollDice(10)];
}

Once we have a die result, we need to use it to build the result card.

The way Ironsworn rolls work is you compare the action die result to the challenge dice:

Strong Hit
If the action die result is greater than both challenge dice.
Weak Hit
If the action die result is only greater than one of the challenge dice.
Miss
If both the challenge dice are greater than the action die result.

You are also looking for doubles on the challenge dice, as that is incentive to make something interesting happen. On a Miss it should be another complication, but on a Strong Hit it should be beneficial.

The card will just be a block that tells us what the last roll was, the result of the action die, the result of the challenge dice, and then if you hit, missed, or if something interesting happens.

function makeCard(title, dice) {
    rv  = "<div class=card>";
    rv += "<span class=title>" + title + "</span>";
    rv += "<span>Action Die: <div class=d6>" + dice[0];
    rv += "</div></span>";
    rv += "<span>Challenge Dice: <div class=d10>" + dice[1];
    rv += "</div>, ";
    rv += "<div class=d10>" + dice[2] + "</div> </span> ";
    if (dice[0] > dice[1] && dice[0] > dice[2])
        rv += "<span class=strongHit>Strong Hit ";
    else if (dice[0] > dice[1] || dice[0] > dice[2])
        rv += "<span class=weakHit>Weak Hit ";
    else
        rv += "<span class=miss>Miss ";
    rv += "";
    if (dice[1] == dice[2])
        rv += "<span class=doubles>Something interesting happens</span>";
    rv += "</span></div>";
    return rv;
}

To get this working, all we have to do is add the card to the Results Log when the user clicks on a stat. So, we just setup some callbacks that do just that.

$( document ).ready(() => {
    $( "#stat-iron" ).click(() => {
        let dice = roll(stats["iron"]);
        $( ".ResultsLogCards" ).html(makeCard("Roll Iron", dice));
    });
    $( "#stat-heart" ).click(() => {
        let dice = roll(stats["heart"]);
        $( ".ResultsLogCards" ).html(makeCard("Roll Heart", dice));
    });
    $( "#stat-edge" ).click(() => {
        let dice = roll(stats["edge"]);
        $( ".ResultsLogCards" ).html(makeCard("Roll Edge", dice));
    });
    $( "#stat-shadow" ).click(() => {
        let dice = roll(stats["shadow"]);
        $( ".ResultsLogCards" ).html(makeCard("Roll Shadow", dice));
    });
    $( "#stat-wits" ).click(() => {
        let dice = roll(stats["wits"]);
        $( ".ResultsLogCards" ).html(makeCard("Roll Wits", dice));
    });
});

The last thing to do is lay the card out to read nicely. We have three bits of information in the card: What the card represents, the detail of the roll, and the result of the roll, so I've split them into sections by background colour.

The title has its own bit, and is bigger than the rest of the text; the detail is there in case you want to examine it, but can just be black text on a great background; and I've made the result prominent by sectioning it off with a coloured background, based off the result, and making it bold.

I'm not worried about using red and green together, as it is an additional way to provide information at a glance, secondary to the text, and because the red is much greyer than the more saturated green, which should make them different to look at, which should be okay for a secondary sign.

In the future I may want to look at doing something special with the dice numbers, like rendering them inside images of dice of the right type. For now they can just be bold.

.card {
    border: 2px solid #444444;
    background: #bbbbbb;
    color: #222222;
    border: 1px solid black;
    margin-bottom: .5em;
} 

.card span {
    display: table;
    width: 100%;
    box-sizing: border-box;
    padding: .3em 1em;
}

.card span.title,
.card span.strongHit,
.card span.weakHit,
.card span.miss {
    font-weight: bold;
    color: whitesmoke;
}

.card span.title {
    font-size: 16pt;
    background-color: #4d4d4d;
    padding: .5em 1em;
}

.d6, .d10 {
    display: inline;
    font-weight: bold;
}

.card span.strongHit,
.card span.weakHit,
.card span.miss {
    padding: .5em 1em 1em 1em;
}
.card span.strongHit,
.card span.weakHit {
    background-color: #0A500A;
}

.card span.miss {
    background-color: #573333;
}

.card span.doubles {
    padding: .5em 0 0 0 ;
}

Text Log

The last major thing I want to happen is to have a text log of events. Eventually I will make this a form text box that you can edit to change the results, or copy and load your character sheet into and out of. Potentially even write up your notes inside, so you can play the game entirely inside the form.

For the moment, however, I'll just use a divthat looks like the other ones.

<div class="ResultsTextLog">

</div>
.ResultsTextLog {
    margin-top: 3em;
    min-height: 10em;
    background-color: #252525;
    padding: .5em;
    border: 1px solid black;
    font-family: "Lucida Console", Monaco, monospace;
    font-size: 10pt;
}

We then need to build a line of text to go into this. This is very similar to our card from before, except I've changed the surrounding class.

function makeLog(title, dice) {
    return makeCard(title, dice).replace(/card/, "logline")
                                .replace(/<\/div>/, "</div> ")
                                .replace(/<\/span>/, " </span>");
}

The only CSS change I'll make to this for the moment is making the title bold. The dice being bold is already being picked up because I didn't lock the class to be a subclass of .card.

.logline span.title {
    font-weight: bold;
}

Registering the events

I'm not having issues with registereing multiple call backs here because I didn't actually export and run the other code block. This is the only one running.

Now we just need our callbacks to work for each of our stat blocks. They are getting a bit more complicated now, so written a generic callback function, which I can just call with the appropriate parameters in the Closure.

The other thing I've done is pre-resolved the elements that I want to edit. This is for speed.

let sheetElements = {
    ResultsTextLog:  $( ".ResultsTextLog" ),
    ResultsLogCards: $( ".ResultsLogCards" )
};

function statRollCallback(stat, titleText) {
    return () => {
        let dice = roll(stats[stat]);
        sheetElements["ResultsTextLog"].append(makeLog(titleText, dice));
        sheetElements["ResultsLogCards"].html(makeCard(titleText, dice));
    };
}

$( document ).ready(() => {
    $( "#stat-iron" ).click(statRollCallback("iron", "Roll Iron"));
    $( "#stat-heart" ).click(statRollCallback("heart", "Roll Heart"));
    $( "#stat-edge" ).click(statRollCallback("edge", "Roll Edge"));
    $( "#stat-shadow" ).click(statRollCallback("shadow", "Roll Shadow"));
    $( "#stat-wits" ).click(statRollCallback("wits", "Roll Wits"));
});

Keyboard events

The last touch is to get keyboard control working. The browser and HTML are doing all of the navigation work, the only other thing is that keypress. the click() method registers a mouse click, and is not actually the equivalent to the HTML onclick property.

This means I've got to set up a separate callback for when each box receives keyboard input, and then check if it is enter before calling the callback for rolling the dice.

function enterRollCallback(stat, titleText) {
    return (event) => {
        if (event.which == 13) {
            statRollCallback(stat, titleText)();
        }
    };
}

$( document ).ready(() => {
    $( "#stat-iron" ).keypress(enterRollCallback("iron", "Roll Iron"));
    $( "#stat-heart" ).keypress(enterRollCallback("heart", "Roll Heart"));
    $( "#stat-edge" ).keypress(enterRollCallback("edge", "Roll Edge"));
    $( "#stat-shadow" ).keypress(enterRollCallback("shadow", "Roll Shadow"));
    $( "#stat-wits" ).keypress(enterRollCallback("wits", "Roll Wits"));
});

A beginning

And there we have a working Ironsworn stat block, that rolls the stats as needed.

Next time I'll put together progress trackers and the ability to roll on them.