Opposed Rolls are laborious to implement in a VTT, but Rolls With Memory offer an interesting way to manage these fairly smoothly.
The basic principle is as follows: each time you make a roll, that is saved to hidden attributes. Then, when another character rolls against you, their roll is compared to your hidden values, and both rolls are displayed together.
That’s the basic idea, but it’s more complex to code than you might imagine, so we’ll follow a three-step process.
- Show how to Save Rolls (already done – see last post)
- Pit two Saved Rolls against each other (this post)
- Have one roll which chooses whether to make a new roll or a saved roll (next post)
Step 3 above is what we need to do, but in this post, we’ll develop an intermediate step, and show the coding needed for it. That should make the next post less of a massive jump.
In This Post
This post handles a very specific situation. You have already made a roll, and the person you are opposing has already made a roll, and then we compare those results.
We are assuming that both participants have already made rolls that are meant to be opposed. If those rolls haven’t yet been made, or the wrong roll has been saved, the results won’t be correct. In the next post, we’ll handle the situation when one or both of those rolls haven’t yet been made.
Because we are looking only at rolls that have already been made, we need a single action button in the HTML. This one:
<button type="action" name="act_oppose">Roll Opposed</button>
Code language: HTML, XML (xml)
We also need the template roll. One of the complications of handling CRP (Custom Roll Parsing) is you need a sheet worker (JavaScript), a rolltemplate (HTML and CSS), and the template roll (Roll20 Markup). In practice, you’ll build them in conversation with each other – you’ll figure out some HTML, that then leads to a change in the CSS and maybe JS, and so on. Here we have done that already and are showing th4e completed result – but remember, when you make your own, there will probably be a lot of trial and error.
Template Roll
We need to know the roll being sent to the template. This is a monstrosity, but can be more easily understood if we break it down.
const roll_string = `&{template:pendragon} {{title=@{last_rating}}} {{die=[[@{last_die}]]}} {{score=[[@{last_score}]]}} {{modifier=[[@{last_modifier}]]}} {{target_rating=[[0@{target|last_rating}]]}} {{target_die=[[@{target|last_die}]]}} {{target_score=[[@{target|last_score}]]}} {{target_modifier=[[@{target|last_modifier}]]}} {{who=@{character_name} }} {{outcome=[[0]]}}`;
Code language: Markdown (markdown)
Let’s break down this roll into parts that make sense.
&{template:pendragon}
{{title=@{last_rating}}} {{die=[[@{last_die}]]}} {{score=[[@{last_score}]]}} {{modifier=[[@{last_modifier}]]}}
{{target_rating=[[0@{target|last_rating}]]}} {{target_die=[[@{target|last_die}]]}} {{target_score=[[@{target|last_score}]]}} {{target_modifier=[[@{target|last_modifier}]]}}
{{who=@{character_name} }}
{{outcome=[[0]]}}`;
Code language: Markdown (markdown)
We start by naming the template this roll should be sent to. Then we grab four attributes – now, remember, these have already been rolled, so we just need to access attributes on the character sheet.
Then we grab the same attributes from the target – those attributes are more wordy, but you can see they are the same thing.
Then we grab {{who}}
– which character is making this roll. Now realistically, this should probably also show the target name, but we are keeping things simple.
Finally, we create a roll in which we can save the outcome. The [[0]] value is meaningless and will not be shown in the template. But we’ve created a place to hold the computed
value.
Using Target
The @{target|
operator allows you to grab the values of ratings on another character. This requires that you have a token that represents a character – a token can represent multiple identical characters, but the token must exist.
The attributes we are interested in are the saved rolls:
@{target|last_rating}
@{target|last_score}
@{target|last_modifier}
@{target|last_die}
Code language: Markdown (markdown)
If the target has recently made a roll, they’ll be occupied, and if they haven’t, those will be empty. That gives us an easy method to check if a previous roll exists.
HTML
Now we need to create the structure of the rolltemplate, and we want tt to work for both unopposed rolls (from the last post) and opposed rolls, so that creates a complication.
<rolltemplate class="sheet-rolltemplate-pendragon">
<div class="heading">{{who}} rolls {{rating}}</div>
<div class="properties">
<div class="title">{{rating}}</div>
<div class="attempt">{{computed::score}}</div>
<div class="die">{{computed::die}}</div>
<div class="result">{{computed::modifier}}</div>
{{#target_rating}}
<div class="title">{{computed::target_rating}}</div>
<div class="attempt">{{computed::target_score}}</div>
<div class="die">{{computed::target_die}}</div>
<div class="result">{{computed::target_modifier}}</div>
<div class="title">Outcome</div>
<div class="result outcome">
{{computed::outcome}}
</div>
{{/target_rating}}
</div>
</rolltemplate>
Code language: HTML, XML (xml)
The HTML is not that complicated – the meat of the template is in the CSS. But there are two complications.
<div class="heading">{{who}} rolls {{rating}}</div>
Code language: HTML, XML (xml)
First, we know who makes the roll and what is being rolled. The contents here could be changed, but this works for now. I’ll show below what it looks like.
{{#target_rating}}
<!-- some lines of code -->
{{/target_rating}}
Code language: HTML, XML (xml)
The important part is here. We check if the roll has a key named target_rating: if so, those extra lines are displayed. If it doesn’t (its not an opposed roll), we don’t need to show those lines. With CSS added, this is what that looks like for unopposed rolls and then an opposed roll:
These rolls show the name of the thing being rolled, the score of that thing, an icon showing the die rolled, and finally the result of that roll.
In the opposed roll, it also shows who won, so you can determine this kind of thing at a glance.
There’s a bunch of ways you might tweak this rolltemplate, but that would likely require modifications to any or all of the roll20 macro, HTML, CSS, and JS. CRP solutions can be hard!
CSS
The CSS to produce all of that is very similar to the CSS from the last post, so there’s no need to talk about it much. There’s a lot of it, but it’s really not that complicated.
.sheet-rolltemplate-pendragon {
background: white;
border: 1pt solid black;
line-height: 2em;
}
.sheet-rolltemplate-pendragon .sheet-heading {
background: black;
color: white;
font-weight: bold;
}
.sheet-rolltemplate-pendragon .sheet-properties {
display: grid;
grid-template-columns: repeat(4, auto);
column-gap: 0.5%;
}
.sheet-rolltemplate-pendragon .sheet-title {
font-weight: bold;
}
.sheet-rolltemplate-pendragon .sheet-properties .sheet-title,
.sheet-rolltemplate-pendragon .sheet-properties .sheet-result,
.sheet-rolltemplate-pendragon .sheet-properties .sheet-attempt,
.sheet-rolltemplate-pendragon .sheet-properties .sheet-die {
margin: auto;
width:auto;
}
.sheet-rolltemplate-pendragon .sheet-properties .sheet-title {
text-align: center;
padding: 5px;
}
.sheet-rolltemplate-pendragon .sheet-properties .sheet-result,
.sheet-rolltemplate-pendragon .sheet-properties .sheet-attempt,
.sheet-rolltemplate-pendragon .sheet-heading,
.sheet-rolltemplate-pendragon .sheet-outcome {
text-align: center;
}
.sheet-rolltemplate-pendragon .sheet-properties .sheet-die {
text-align: left;
font-family: dicefontd20;
font-size: 36px;
line-height: 40px;
}
.sheet-rolltemplate-pendragon .sheet-outcome {
grid-column: 1 / 5;
border-top: 1px solid black;
}
.sheet-rolltemplate-pendragon .inlinerollresult,
.sheet-rolltemplate-pendragon .inlinerollresult.fullfail,
.sheet-rolltemplate-pendragon .inlinerollresult.fullcrit,
.sheet-rolltemplate-pendragon .inlinerollresult.importantroll {
background-color: transparent;
border: none;
}
.sheet-rolltemplate-pendragon {
pointer-events: none;
margin-left: -37px;
}
.withoutavatars .sheet-rolltemplate-pendragon {
margin-left: -7px;
}
.ui-dialog .charsheet button[type="action"].pseudo-roll::before {
font-family: "dicefontd20";
content: "t";
}
Code language: CSS (css)
The Sheet Worker (JavaScript)
The real heart of the CRP is the sheet worker, and there are a few things worth mentioning here. First, I’ll show the complete code, then break it down into the important chunks.
on('clicked:oppose', () => {
getAttrs(['last_rating', 'last_die', 'last_score', 'last_modifier'], v => {
const roll_string = `&{template:pendragon} {{rating=@{last_rating}}} {{die=[[@{last_die}]]}} {{score=[[@{last_score}]]}} {{modifier=[[@{last_modifier}]]}} {{target_rating=[[0@{target|last_rating}]]}} {{target_die=[[@{target|last_die}]]}} {{target_score=[[@{target|last_score}]]}} {{target_modifier=[[@{target|last_modifier}]]}} {{who=@{character_name} }} {{outcome=[[0]]}}`;
startRoll(roll_string, pendragon => {
const score_calc = (score_base) => (String(score_base).indexOf("+") !== -1) ? (score_base.split('+')[0] ) : +score_base;
const score_finish = (score, modifier) => (score > 20 ? `20 +${score-20}` : score) + (modifier != 0 ? ` ${modifier >= 0 ? '+' : ''}${modifier}`: '');
const die_calc = die => String.fromCharCode(96 + die);
const rating = v.last_rating;
const die = +v.last_die || 0;
const score_base = v.last_score;
const score = score_calc(score_base);
const modifier = +v.last_modifier || 0;
const target_rating = pendragon.results.target_rating.expression.substring(1);
const target_score_base = pendragon.results.target_score.result;
const target_score = score_calc(target_score_base);
const target_die = +pendragon.results.target_die.result || 0;
const target_modifier = +pendragon.results.target_modifier.result || 0;
const result_calc = (score, modifier, die ) => {
const temp_total = score + modifier;
const target = Math.max(1, Math.min(20, temp_total));
const bonus = Math.min(20, Math.max(0, temp_total -20));
const roll = Math.max(0, Math.min(20, die + bonus));
const result = roll === target ? 'Critical' :
roll === 20 ? 'Fumble' :
roll > target ? 'Failure' : `Success (${roll})`;
const effect = roll == target ? 20 : roll == 20 ? -1 : roll > target ? 0 : Math.min(roll, 20);
return {result: result, effect: effect}
}
const roll = result_calc(score, modifier, die );
const target_roll = result_calc(target_score, target_modifier, target_die );
const outcome = ((!roll.effect || roll.effect == -1) &&
(!target_roll.effect || target_roll.effect -1)) ? 'Both Fail' :
(roll.effect ==20 && target_roll.effect == 20) ? 'Both Critical' :
(roll.effect == target_roll.effect) ? "Draw" :
(roll.effect > target_roll.effect) ? `Attacking ${rating} Wins` :
`Defending ${target_rating} Wins`;
finishRoll(pendragon.rollId, {
score: score_finish(score, modifier),
die: die_calc(die),
modifier: roll.result,
target_rating: target_rating,
target_score: score_finish(target_score, target_modifier),
target_die: die_calc(target_die),
target_modifier: target_roll.result,
outcome: outcome
});
});
});
});
Code language: JavaScript (javascript)
Some of the code here has already been described in the last post. I’ll only talk about new additions, and why they exist.
getAttrs(['last_rating', 'last_die', 'last_score', 'last_modifier'], v => {
Code language: JavaScript (javascript)
For this worker, we know a roll has already been made and its relative properties saved, so we can use getAttrs
to grab those values.
const score_calc = (score_base) => (String(score_base).indexOf("+") !== -1) ? (score_base.split('+')[0] ) : +score_base;
const score_finish = (score, modifier) => (score > 20 ? `20 +${score-20}` : score) + (modifier != 0 ? ` ${modifier >= 0 ? '+' : ''}${modifier}`: '');
const die_calc = die => String.fromCharCode(96 + die);
Code language: JavaScript (javascript)
We are performing some calculations twice (once for your own roll, and again for the target’s ratings), so to avoid duplicating a lot of code, we break some of those calculations out into custom functions. These operations are described in the last post.
const score = score_calc(score_base);
Code language: JavaScript (javascript)
Here’s an example of how one of those functions is used.
const target_rating = pendragon.results.target_rating.expression.substring(1);
Code language: JavaScript (javascript)
This part of the function definitely needs explanation. The original part of the roll is this:
{{target_rating=[[0@{target|last_rating}]]}}
Code language: JavaScript (javascript)
We want to use that rating name in a later calculation, so we need a way to grab it from the roll. You can only grab things from inline rolls, and inline rolls must be numbers. But there’s a clever way to get around this problem.
See the 0 before the rating? That means that is treated as a roll and can be put in an inline roll. However almost all values of the roll will be 0 or undefined
, except for expression
. That shows the actual roll made.
That gives us something like 0Sword
or 0Hafted Weapon
, and that starting 0 is a pain. Luckily, JavaScript gives a lot of ways to manipulate strings. Substring(1)
is a function that removes the first character of a string, so there we have it. We can now grab the actual name of the rating and store it in a variable.
const result_calc = (score, modifier, die ) => {
const temp_total = score + modifier;
const target = Math.max(1, Math.min(20, temp_total));
const bonus = Math.min(20, Math.max(0, temp_total -20));
const roll = Math.max(0, Math.min(20, die + bonus));
const result = roll === target ? 'Critical' :
roll === 20 ? 'Fumble' :
roll > target ? 'Failure' : `Success (${roll})`;
const effect = roll == target ? 20 : roll == 20 ? -1 : roll > target ? 0 : Math.min(roll, 20);
return {result: result, effect: effect}
}
Code language: JavaScript (javascript)
This is another custom function. See the return line? That shows what this result generates. Given a score, modifier, and die result, it creates two values stored in an object, like {result: 'Success (2)', effect: 2}
.
If you run through the function, you can figure out how it works, but it’s the result we are most concerned with. And here we use that function:
const roll = result_calc(score, modifier, die );
const target_roll = result_calc(target_score, target_modifier, target_die );
const outcome = ((!roll.effect || roll.effect == -1) &&
(!target_roll.effect || target_roll.effect -1)) ? 'Both Fail' :
(roll.effect ==20 && target_roll.effect == 20) ? 'Both Critical' :
(roll.effect == target_roll.effect) ? "Draw" :
(roll.effect > target_roll.effect) ? `Attacking ${rating} Wins` :
`Defending ${target_rating} Wins`;
Code language: JavaScript (javascript)
The first two lines here call the result_calc
function, and create an object for the rolling character and another for their target.
And then in outcome, we compare those rolls to find out which roll wins, and generate a string describing this.
Finally, we save a bunch of values to the computed values, and that’s it!
finishRoll(pendragon.rollId, {
score: score_finish(score, modifier),
die: die_calc(die),
modifier: roll.result,
target_rating: target_rating,
target_score: score_finish(target_score, target_modifier),
target_die: die_calc(target_die),
target_modifier: target_roll.result,
outcome: outcome
});
Code language: JavaScript (javascript)
Wrapping Up
In the last post, we created a system to make unopposed rolls in Pendragon and, importantly, save that roll. In this post, we have shown how to compare results from two different characters, using those saved results. This has a couple of downsides: both participants need to already have made a roll first, and the target dialog is always presented so you can’t easily use this method to make unopposed rolls.
In the next post, we’ll solve those problems.
In the meantime, it would be nice to have button to clear the existing roll. That is very easy to code:
on('clicked:clear', () => {
const output = {
last_rating: '', last_die: 0, last_score: 0, last_modifier: 0
};
setAttrs(output);
});
Code language: JavaScript (javascript)
We just have to set the values back to their default ratings. The button to do this could be placed on a macro so the GM can easily clear a bunch of characters in one go.
And that’s it for this post. On to the biggest challenge!