A Quick Aside on Triangular Numbers
Ranks make heavy use of Triangular Numbers. Imagine levels, 1, 2, 3, 4, 5. The number of steps in that level equals the level itself. So, the 3rd level has 3 steps within it.
You might want to get the total value by adding the steps of all levels beneath this one. So to reach the 4th level, you have to add the 1st, 2nd and 3rd levels together – the steps for each level are 1 + 2 +3, so a total value of 6.
Maybe you start with a total value of, say, 11, and want to know what triangular level you are in. If we did this manually, we can see that level 4 has a minimum value of 10 (1+2+3+4), and 15 would be the next level, so it must be in the 4th level.
We then need to translate “levels” to ranks (rank 0 = Poor, 1 = Fair, 2 = Good, etc). We can see that the 4th level would therefore be a Superb Rank.
We’ll need some custom JavaScript functions to handle all this.
Configuration Values
It’s sometimes handy to store values you’ll be using repeatedly in their own attributes. You don’t need all the attributes created here, but for the method chosen, they are handy.
<input type="hidden" name="attr_skill_advances" value="0" class="skill_advances"/>
<input type="hidden" name="attr_skill_level" value="0" class="skill_level"/>
<input type="hidden" name="attr_skill_overflow" value="0" class="skill_overflow"/>
<input type="hidden" name="attr_skill_rank" value="Poor" class="skill_rank"/>
<input type="hidden" name="attr_traits_advanced" value="0" class="traits_advances"/>
<input type="hidden" name="attr_traits_level" value="0" class="traits_level"/>
<input type="hidden" name="attr_traits_overflow" value="0" class="traits_overflow"/>
<input type="hidden" name="attr_traits_rank" value="Poor" class="traits_rank"/>
Code language: HTML, XML (xml)
Here we have 8 stats in two sets, skills and traits. Each set has four attributes: advances
(the total number of advances), level
(the numeric value of the current triangular number for that total), the overflow
(any remainder if you subtract the total triangular value for the current level), and rank
is the string value of the current rank.
We’d also add another set of four for Extras.
In other words, if you have 7 total value, that must have a level of 3 (which takes 6 points to reach), an overflow of 1 (7 – 6), and rank of Great, because the 3rd rank above Poor is Great.
Now let’s think about the times these values can change. There are only two:
- When a character is first being designed, they may be given a rank from Poor and up in each Skills and traits. So we need a function that will calculate each numerical value from the string value.
- When experienced is gained, players may add an advance to either Skills or Traits. This will increase the total points and the overflow. If enough points are gained to increase the Rank (or level), you’ll gain either an extra Skill or Trait, so this must be noted.
We also need to be careful of something being double-triggered. Lets say you have 5 points, and add 1. You are now at 6 points, a Great rank. But this might be the same as manually setting a rank to Great when designing a character, so you might be awarded a skill or trait increase twice.
There’s a property you can add to setAttrs
called {silent:true}
– this means that worker does not trigger any other workers. We’ll try that for now – we might want this worker to trigger other changes later and will need to look again if that happens.
The Sheet Workers
Since the triangular numbers are used a lot, I created some functions that could be handy when dealing with them. The seq
(sequence) and int
(integer) are general functions which are used elsewhere in the sheet, but since they are also used here, I included them to so they could be examined.
const seq = (length, start = 0) => [...Array(length).keys()].map(i => i += start);
const int = (score, fallback = 0) => parseInt(score) || fallback;
const triangle = n => int(n) * (int(n)+1) /2;
// find the last triangular number below the value
const least_triangle = n => {
let t = 0;
seq(11).forEach(i => {
let sum = triangle(i);
if (sum <= int(n)) t = i;
});
return t;
};
// given a number, find the amount it exceeds its least_triangle (can be zero).
const triangle_overflow = n => int(n) - triangle(least_triangle(int(n)));
//given a number report both level and overflow
const triangle_factors = n => (triangle_overflow(int(n)) > 10 || int(n) <= 0) ? [0, 0] : [least_triangle(int(n)), triangle_overflow(int(n))];
// calculate the adjective rank. Note that triangle 0 = poor, so we have to add to the rank.
const triangle_rank = n => ranks[int(n) +1];
// finally a function to set the numeric rank values given a rank.
const from_triangle = s => {
let adj, triangular, total;
if (ranks.includes(s.toLowerCase()) ){
adj = s.toLowerCase();
triangular = ranks.indexOf(adj) -1;
total = triangle(triangular);
} else {
triangular = 0;
total = 0;
}
return [triangular, total, 0];
}
Code language: JavaScript (javascript)
triangle
calculates a triangular total, least_triangle
calculates the triangle number for a given total. So if your total is 8, the triangle number is 3.
triangle_overflow
calculates the remainder, so if your total is 8, the remainder is 2. And triangle_factors
calculates both of these in the form of an array, so you can do something like
const [triangle, overflow] = triangle_factors(8)
Finally, we need to be able to calculate the numerical properties from the adjective rank. This is where from_triangle
comes in. Supply it a correctly spelled rank, and it returns necessary values:
const [triangle, total, overflow] = from_triangle(rank)
With those functions created, we can return to working on the sheet.
Step 1 – The Advance Track
For our first step, we need to build this (notice, some changed labeling along the top for clarity).

The HTML for this is really long, but it is essentially the same stuff repeated over and over. Here it is:
<div class="experience">
<button type="action" name="act_show_xp" class="show-xp title" class="full-width">
<h3>Advance Track</h3>
</button>
<span class="rank">Ranks</span>
<span class="rank">Fair</span>
<span class="rank">Good</span>
<span class="rank">Great</span>
<span class="rank">Superb</span>
<span class="rank">Spectacular</span>
<span class="rank">End</span>
<span class="rank">Skills</span>
<div>
<input type="checkbox" name="attr_sp_poor_0" class="no-click" value="1" />
</div>
<div>
<input type="checkbox" name="attr_sp_fair_0" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_fair_1" class="no-click" value="1" />
</div>
<div>
<input type="checkbox" name="attr_sp_good_0" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_good_1" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_good_2" class="no-click" value="1" />
</div>
<div>
<input type="checkbox" name="attr_sp_great_0" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_great_1" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_great_2" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_great_3" class="no-click" value="1" />
</div>
<div>
<input type="checkbox" name="attr_sp_superb_0" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_superb_1" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_superb_2" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_superb_3" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_superb_4" class="no-click" value="1" />
</div>
<div>
<input type="checkbox" name="attr_sp_spectacular_0" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_spectacular_1" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_spectacular_2" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_spectacular_3" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_spectacular_4" class="no-click" value="1" />
<input type="checkbox" name="attr_sp_spectacular_5" class="no-click" value="1" />
</div>
<span class="rank">Traits</span>
<div>
<input type="checkbox" name="attr_tp_poor_0" class="no-click" value="1" />
</div>
<div>
<input type="checkbox" name="attr_tp_fair_0" class="no-click" value="1" />
<input type="checkbox" name="attr_tp_fair_1" class="no-click" value="1" />
</div>
<div>
<input type="checkbox" name="attr_tp_good_0" class="no-click" value="1" />
<input type="checkbox" name="attr_tp_good_1" class="no-click" value="1" />
<input type="checkbox" name="attr_tp_good_2" class="no-click" value="1" />
</div>
<div>
<input type="checkbox" name="attr_tp_great_0" class="no-click" value="1" />
<input type="checkbox" name="attr_tp_great_1" class="no-click" value="1" />
<input type="checkbox" name="attr_tp_great_2" class="no-click" value="1" />
<input type="checkbox" name="attr_tp_great_3" class="no-click" value="1" />
</div>
<div>
<input type="checkbox" name="attr_tp_superb_0" class="no-click" value="1" />
<input type="checkbox" name="attr_tp_superb_1" class="no-click" value="1" />
<input type="checkbox" name="attr_tp_superb_2" class="no-click" value="1" />
<input type="checkbox" name="attr_tp_superb_3" class="no-click" value="1" />
<input type="checkbox" name="attr_tp_superb_4" class="no-click" value="1" />
</div>
<div></div>
<span class="rank">Extras</span>
<div>
<input type="checkbox" name="attr_ep_poor_0" class="no-click" value="1" />
</div>
<div>
<input type="checkbox" name="attr_ep_fair_0" class="no-click" value="1" />
<input type="checkbox" name="attr_ep_fair_1" class="no-click" value="1" />
</div>
<div>
<input type="checkbox" name="attr_ep_good_0" class="no-click" value="1" />
<input type="checkbox" name="attr_ep_good_1" class="no-click" value="1" />
<input type="checkbox" name="attr_ep_good_2" class="no-click" value="1" />
</div>
<div>
<input type="checkbox" name="attr_ep_great_0" class="no-click" value="1" />
<input type="checkbox" name="attr_ep_great_1" class="no-click" value="1" />
<input type="checkbox" name="attr_ep_great_2" class="no-click" value="1" />
<input type="checkbox" name="attr_ep_great_3" class="no-click" value="1" />
</div>
<div>
<input type="checkbox" name="attr_ep_superb_0" class="no-click" value="1" />
<input type="checkbox" name="attr_ep_superb_1" class="no-click" value="1" />
<input type="checkbox" name="attr_ep_superb_2" class="no-click" value="1" />
<input type="checkbox" name="attr_ep_superb_3" class="no-click" value="1" />
<input type="checkbox" name="attr_ep_superb_4" class="no-click" value="1" />
</div>
</div>
Code language: HTML, XML (xml)
This is a lot of checkboxes, which show a character’s progress towards skills, traits, or extras. It is of course arranged with CSS Grid, which is why all those inputs
are inside divs
. The divs
are organised into nice columns. Note also the inputs have a no-click
class:
div.xp-floater div.experience {
display:grid;
grid-template-columns: repeat(3, 50px) 70px 90px 100px 110px;
column-gap: 5px;
margin-bottom: 10px;
text-align: center;
}
.charsheet input.no-click {
pointer-events: none;
}
Code language: CSS (css)
The no-click
class means that players can not enter values in those inputs, nor remove values from them. It’s handled through the buttons below and some sheet workers.
Step 2 – Adding Advances
After clicking the advance
button (which is not shown here), the following buttons become enabled, and remain enabled till you have less than 5 XP left. The first time you click any of these buttons is free, but each extra click costs 5 XP. We’ll deal with that later.
First, a 3 column grid called how-many
is created, and in the first column we first find those Buy A Skill/Trait/Extra Advance
buttons:
<div class="insert">
<button type="action" name="act_advance_skill" class="nod20">
<span>Buy a Skill Advance</span>
</button>
<button type="action" name="act_advance_trait" class="nod20">
<span>Buy a Trait Advance</span>
</button>
<button type="action" name="act_advance_extras" class="nod20">
<span>Buy an Extra Advance</span>
</button>
</div>
Code language: HTML, XML (xml)
Now, each time one of these buttons is clicked, the relative number of advances increases by one, the checkpoints are updated, and the rest of the attributes are calculated. This is handled with a sheet worker for each loop:
['skill', 'trait', 'extras'].forEach(type => {
on(`change:${type}_advances sheet:opened`, () => {
getAttrs([`${type}_advances`], v => {
const advances = int(v[`${type}_advances`]);
const [level, overflow] = triangle_factors(advances);
const rank = triangle_rank(level);
const type_index = type[0];
const output = {};
let advances_temp = 0;
ranks.forEach((rank, index) => {
if(rank != "Legendary" && rank != 'Mythic' && rank != 'Perfect') {
seq(index).forEach(position => {
advances_temp ++;
output[`${type_index}p_${rank.toLowerCase()}_${position}`] = advances_temp <= advances ? 1 : 0;
});
}
});
output[`${type}_level`] = level;
output[`${type}_overflow`] = overflow;
output[`${type}_rank`] = rank;
setAttrs(output);
});
});
on(`clicked:advance_${type}`, () => {
log('checking')
getAttrs([`${type}_advances`], v => {
const advances = int(v[`${type}_advances`]) +1;
log({advances})
setAttrs({
[`${type}_advances`]: advances
});
});
});
});
Code language: JavaScript (javascript)
The first sheet worker shown responds to changes in the _advances
attribute, and fills in the checkboxes under advance tracks. It’ll handle reductions as well as increases.
Then we have the three advances buttons – click them and the advances increase, and that triggers the first sheet worker to calculate all relevant attributes.
We need to modify those advances buttons so they cant be triggered unless the chapter button is enabled – but we’ll leave that till we add that function. For now, we want to move on to handling experience
and achievements
.