CookbookHobbies and playNo. 52
A tabletop campaign settinga players’ guide on one link, the game master’s secrets kept apart
See it in know.sh
4d6 drop lowest, roll with advantage, or roll a full set of ability scores.Tidewrack — at the tablePlayers’ guide
2of 5
Character creation
Observation, key section, 1 element, 1 note, 270 words
Rules. Characters are made with the fifth-edition rules in the SRD 5.2, starting at first level. Any species and class in the SRD is welcome; for anything from elsewhere, ask first.
Ability scores. Roll them at session zero, at the table where everyone can see: 4d6, drop the lowest die, six times, then give the six numbers to the abilities in any order. If you would rather not trust the dice, take the standard array instead: 15, 14, 13, 12, 10 and 8. Each score’s modifier is the score minus 10, halved and rounded down, so 16 gives +3 and 9 gives −1.
Advantage and disadvantage. With advantage, roll two d20s and keep the higher; with disadvantage, keep the lower. If you have both, they cancel and you roll one d20.
Practice dice. The roller below is for practice and for the odd roll between sessions, such as a downtime check by message. It opens the same way every time, so the rolls that make your character happen with real dice, at the table.
Dice for the table
Type dice notation (2d6+3, 1d20, d%, 4d6 drop lowest, 3d8+2d6-1) and press Roll, or use the quick dice; a d20 rolls with advantage or disadvantage by rolling two and keeping the higher or lower, and ability scores are 4d6 drop the lowest, six times, with the modifier beside each. Every die is shown, dropped ones struck through. The numbers come from a seeded generator, so the first three rolls are the same every time the page opens; after that the moment of each click is stirred in. Fine for practice and for rolls between sessions; for the rolls that matter, use real dice at the table.
<div class="dice">
<div class="entry">
<label for="expr">Dice notation</label>
<div class="line">
<input id="expr" type="text" value="2d6+3" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="hint">
<button type="button" id="roll" class="primary">Roll</button>
</div>
<small id="hint">Enter rolls it. Try 1d20+5, d% or 4d6 drop lowest.</small>
</div>
<div class="quick" role="group" aria-label="Roll one die">
<button type="button" data-q="d4">d4</button><button type="button" data-q="d6">d6</button><button type="button" data-q="d8">d8</button><button type="button" data-q="d10">d10</button><button type="button" data-q="d12">d12</button><button type="button" data-q="d20">d20</button><button type="button" data-q="d100">d100</button>
</div>
<div class="more">
<button type="button" id="adv" aria-label="d20 with advantage"><span class="long">d20, advantage</span><span class="short">Advantage</span></button>
<button type="button" id="dis" aria-label="d20 with disadvantage"><span class="long">d20, disadvantage</span><span class="short">Disadvantage</span></button>
<button type="button" id="abil" aria-label="Roll ability scores"><span class="long">Roll ability scores</span><span class="short">Abilities</span></button>
</div>
<div class="result" id="result" aria-live="polite" aria-atomic="true">
<p class="empty">Nothing rolled yet.</p>
</div>
<div class="hist" id="hist" hidden>
<div class="histhead"><span>Rolls so far</span><button type="button" id="clear" class="link">Clear</button></div>
<ol id="history"></ol>
</div>
</div>.dice { font: 400 14px/1.4 var(--sans); color: var(--ink); }
.entry label { display: block; margin-bottom: 4px; }
.line { display: flex; gap: 8px; }
.line input { flex: 1; min-width: 0; font: 400 17px var(--mono); padding: 8px; }
.entry small { display: block; margin-top: 4px; color: var(--ink-3); font-size: 12.5px; }
button { font: 500 14px/1 var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--ink); border-radius: 0; padding: 10px 12px; min-height: 40px; cursor: pointer; touch-action: manipulation; }
button.primary { background: var(--ink); color: var(--paper); padding: 10px 20px; }
button:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.quick { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); margin-top: 12px; }
.quick button { padding: 10px 0; }
.quick button + button { border-left: 0; }
.more { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.more button { flex: 1 1 auto; }
.result { margin-top: 14px; padding: 10px 0 12px; border-top: 1px solid var(--ink); border-bottom: 1px solid var(--rule); min-height: 110px; }
.hist[hidden] { display: none; }
.short { display: none; }
.result .empty { color: var(--ink-3); font: italic 400 16px var(--serif); margin: 0; }
.head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.total { font: 400 46px/1 var(--sans); font-variant-numeric: tabular-nums; }
.what { font: 400 14px var(--mono); color: var(--ink-2); }
.dz { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin-top: 10px; }
.die { min-width: 32px; height: 32px; padding: 0 4px; display: inline-grid; place-items: center; border: 1px solid var(--ink); font: 500 15px/1 var(--sans); font-variant-numeric: tabular-nums; }
.die.max { border-width: 2px; }
.die.out { border-style: dashed; border-color: var(--ink-3); color: var(--ink-3); text-decoration: line-through; }
.op { font: 400 15px var(--mono); color: var(--ink-2); padding: 0 3px; }
.tag { font: 400 12px var(--sans); color: var(--ink-3); margin-right: 2px; }
.note { margin: 8px 0 0; font: italic 400 15px/1.4 var(--serif); font-variant-numeric: lining-nums; color: var(--ink-2); }
.scores { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 20px; margin-top: 4px; }
.score { display: flex; align-items: center; gap: 4px; }
.score .die { min-width: 26px; height: 26px; font-size: 13px; }
.score b { margin-left: auto; font: 600 17px var(--sans); font-variant-numeric: tabular-nums; }
.score i { font: 400 13px var(--sans); color: var(--ink-3); font-style: normal; min-width: 2.4em; text-align: right; }
.hist { margin-top: 10px; }
.histhead { display: flex; align-items: center; justify-content: space-between; font: 500 13px var(--sans); color: var(--ink-2); }
button.link { border: 0; background: none; padding: 6px 0 6px 12px; min-height: 32px; text-decoration: underline; color: var(--ink-2); font-weight: 400; font-size: 13px; }
.hist ol { list-style: none; margin: 4px 0 0; padding: 0; }
.hist li { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; border-bottom: 1px solid var(--rule); font: 400 13px/1.35 var(--mono); color: var(--ink-2); }
.hist li b { font: 600 14px var(--sans); color: var(--ink); font-variant-numeric: tabular-nums; }
.hist li:first-child { color: var(--ink); }
@media (max-width: 480px) { .scores { grid-template-columns: 1fr; gap: 4px; } .total { font-size: 40px; } .quick button { font-size: 13px; } .more { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } .more button { padding: 10px 2px; font-size: 13px; } .long { display: none; } .short { display: inline; } }(function () {
var root = document.querySelector('.dice');
function $(id) { return root.querySelector('#' + id); }
var MAX_DICE = 40, SHOW = 5;
// mulberry32: a small seeded generator, so the page starts the same way each time.
var seed = 20260927, rolls = 0, lastClick = 0;
function rnd() {
seed = (seed + 0x6D2B79F5) | 0;
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
function d(sides) { return 1 + Math.floor(rnd() * sides); }
// From the fourth roll on, the time between clicks is stirred into the seed.
function stir() {
rolls++;
var t = performance.now();
if (rolls >= 4) seed = (seed ^ Math.imul(Math.floor((t - lastClick) * 1000) | 0, 0x9E3779B1)) | 0;
lastClick = t;
}
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
// Dice notation to terms: [{ sign, count, sides, keep: { how, n } } | { sign, value }].
function parse(src) {
var s = String(src).toLowerCase().trim();
s = s.replace(/\s*drop(?:\s+the)?\s+lowest(?:\s+(\d+))?/g, function (m, n) { return 'dl' + (n || 1); })
.replace(/\s*drop(?:\s+the)?\s+highest(?:\s+(\d+))?/g, function (m, n) { return 'dh' + (n || 1); })
.replace(/\s*keep(?:\s+the)?\s+highest\s+(\d+)/g, 'kh$1')
.replace(/\s*keep(?:\s+the)?\s+lowest\s+(\d+)/g, 'kl$1')
.replace(/\s+/g, '').replace(/−/g, '-');
if (!s) throw new Error('Type some dice first, like 2d6+3.');
var terms = [], dice = 0, first = true;
var re = /^([+-])?(?:(\d*)d(\d+|%)(?:(dl|dh|kh|kl|k)(\d+))?|(\d+))/;
while (s.length) {
var m = re.exec(s);
if (!m || (!first && !m[1])) throw new Error('That is not dice notation: "' + s + '". Try 2d6+3, 1d20 or 4d6 drop lowest.');
var sign = m[1] === '-' ? -1 : 1;
if (m[6] !== undefined) terms.push({ sign: sign, value: Number(m[6]) });
else {
var count = m[2] === '' ? 1 : Number(m[2]);
var sides = m[3] === '%' ? 100 : Number(m[3]);
if (count < 1) throw new Error('Roll at least one die.');
if (sides < 2 || sides > 1000) throw new Error('Dice need 2 to 1,000 sides.');
dice += count;
var keep = null;
if (m[4]) {
var how = m[4] === 'k' ? 'kh' : m[4], n = Number(m[5]);
if (how[0] === 'd' && count < 2) throw new Error('Dropping a die needs at least two dice, like 4d6 drop lowest.');
if (how[0] === 'd' && (n < 1 || n >= count)) throw new Error('Of ' + count + ' dice you can drop at most ' + (count - 1) + '.');
if (how[0] === 'k' && (n < 1 || n > count)) throw new Error('Of ' + count + ' dice you can keep 1 to ' + count + '.');
keep = { how: how, n: n };
}
terms.push({ sign: sign, count: count, sides: sides, keep: keep });
}
s = s.slice(m[0].length); first = false;
}
if (dice > MAX_DICE) throw new Error('At most ' + MAX_DICE + ' dice in one roll.');
if (!dice) throw new Error('Add at least one die, like 1d20+5.');
return terms;
}
function rollTerm(t) {
var vals = [];
for (var i = 0; i < t.count; i++) vals.push({ v: d(t.sides), out: false });
if (t.keep) {
var order = vals.map(function (x, i) { return i; }).sort(function (a, b) { return vals[a].v - vals[b].v || a - b; });
var drop = [];
if (t.keep.how === 'dl') drop = order.slice(0, t.keep.n);
if (t.keep.how === 'dh') drop = order.slice(order.length - t.keep.n);
if (t.keep.how === 'kh') drop = order.slice(0, order.length - t.keep.n);
if (t.keep.how === 'kl') drop = order.slice(t.keep.n);
drop.forEach(function (i) { vals[i].out = true; });
}
var sum = vals.reduce(function (a, x) { return a + (x.out ? 0 : x.v); }, 0);
return { dice: vals, sum: sum };
}
function dieHtml(x, sides) {
var cls = 'die' + (x.out ? ' out' : '') + (x.v === sides ? ' max' : '');
return '<span class="' + cls + '" aria-label="' + x.v + (x.out ? ', dropped' : '') + '">' + x.v + '</span>';
}
var history = [];
function remember(label, detail, total) {
history.unshift({ label: label, detail: detail, total: total });
if (history.length > SHOW) history.pop();
$('hist').hidden = false;
$('history').innerHTML = history.map(function (h) {
return '<li><span>' + esc(h.label) + ': ' + esc(h.detail) + '</span><b>' + esc(h.total) + '</b></li>';
}).join('');
}
function show(html) { $('result').innerHTML = html; }
function fail(msg) { show('<p class="note">' + esc(msg) + '</p>'); }
function rollNotation(src, label) {
var terms;
try { terms = parse(src); } catch (e) { fail(e.message); return; }
stir();
var total = 0, parts = [], words = [], note = '';
terms.forEach(function (t, i) {
var op = i === 0 ? (t.sign < 0 ? '−' : '') : (t.sign < 0 ? ' − ' : ' + ');
if (t.value !== undefined) {
total += t.sign * t.value;
parts.push('<span class="op">' + op.trim() + ' ' + t.value + '</span>');
words.push(op + t.value);
return;
}
var r = rollTerm(t);
total += t.sign * r.sum;
var name = t.count + 'd' + t.sides + (t.keep ? ' ' + { dl: 'drop lowest', dh: 'drop highest', kh: 'keep highest', kl: 'keep lowest' }[t.keep.how] + (t.keep.n > 1 || t.keep.how[0] === 'k' ? ' ' + t.keep.n : '') : '');
parts.push((op.trim() ? '<span class="op">' + op.trim() + '</span>' : '') + '<span class="tag">' + esc(name) + '</span>' + r.dice.map(function (x) { return dieHtml(x, t.sides); }).join(''));
words.push(op + '[' + r.dice.map(function (x) { return x.out ? '(' + x.v + ')' : x.v; }).join(', ') + ']');
if (t.sides === 100 && t.count === 1) {
var v = r.dice[0].v, tens = v === 100 ? '00' : String(Math.floor(v / 10) * 10), units = String(v % 10);
if (tens === '0') tens = '00';
note = 'As percentile dice: tens ' + tens + ', units ' + units + (v === 100 ? ' (00 and 0 read as 100).' : '.');
}
if (t.sides === 20 && t.count === 1 && terms.length <= 2) {
if (r.dice[0].v === 20) note = 'A natural 20.';
if (r.dice[0].v === 1) note = 'A natural 1.';
}
});
var shown = label || src.trim();
show('<div class="head"><span class="total">' + total + '</span><span class="what">' + esc(shown) + '</span></div><div class="dz">' + parts.join('') + '</div>' + (note ? '<p class="note">' + esc(note) + '</p>' : ''));
remember(shown, words.join('').trim(), total);
}
function rollD20(adv) {
stir();
var a = d(20), b = d(20);
var keepFirst = adv ? a >= b : a <= b;
var kept = keepFirst ? a : b;
var dice = [{ v: a, out: !keepFirst }, { v: b, out: keepFirst }];
var label = adv ? 'd20 with advantage' : 'd20 with disadvantage';
var note = (adv ? 'Two d20s, the higher kept.' : 'Two d20s, the lower kept.') + (kept === 20 ? ' A natural 20.' : kept === 1 ? ' A natural 1.' : '');
show('<div class="head"><span class="total">' + kept + '</span><span class="what">' + label + '</span></div><div class="dz">' + dice.map(function (x) { return dieHtml(x, 20); }).join('') + '</div><p class="note">' + note + '</p>');
remember(label, '[' + dice.map(function (x) { return x.out ? '(' + x.v + ')' : x.v; }).join(', ') + ']', kept);
}
function mod(score) { var m = Math.floor((score - 10) / 2); return (m < 0 ? '−' : '+') + Math.abs(m); }
function rollAbilities() {
stir();
var rows = [], scores = [];
for (var i = 0; i < 6; i++) {
var r = rollTerm({ count: 4, sides: 6, keep: { how: 'dl', n: 1 } });
scores.push(r.sum);
rows.push('<div class="score">' + r.dice.map(function (x) { return dieHtml(x, 6); }).join('') + '<b>' + r.sum + '</b><i>' + mod(r.sum) + '</i></div>');
}
var sum = scores.reduce(function (a, b) { return a + b; }, 0);
show('<div class="head"><span class="what">Ability scores, 4d6 drop the lowest, six times</span></div><div class="scores">' + rows.join('') + '</div><p class="note">' + scores.join(', ') + ': ' + sum + ' in all. Assign them to the six abilities as you choose.</p>');
remember('Ability scores', scores.join(', '), sum);
}
$('roll').addEventListener('click', function () { rollNotation($('expr').value); });
$('expr').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); rollNotation($('expr').value); } });
root.querySelectorAll('[data-q]').forEach(function (b) {
b.addEventListener('click', function () { rollNotation('1' + b.getAttribute('data-q'), b.getAttribute('data-q')); });
});
$('adv').addEventListener('click', function () { rollD20(true); });
$('dis').addEventListener('click', function () { rollD20(false); });
$('abil').addEventListener('click', rollAbilities);
$('clear').addEventListener('click', function () { history = []; $('history').innerHTML = ''; $('hist').hidden = true; $('expr').focus(); });
})();```element widget
{
"title": "Dice for the table",
"caption": "Type dice notation (2d6+3, 1d20, d%, 4d6 drop lowest, 3d8+2d6-1) and press Roll, or use the quick dice; a d20 rolls with advantage or disadvantage by rolling two and keeping the higher or lower, and ability scores are 4d6 drop the lowest, six times, with the modifier beside each. Every die is shown, dropped ones struck through. The numbers come from a seeded generator, so the first three rolls are the same every time the page opens; after that the moment of each click is stirred in. Fine for practice and for rolls between sessions; for the rolls that matter, use real dice at the table.",
"height": 560,
"html": "<div class=\"dice\">\n <div class=\"entry\">\n <label for=\"expr\">Dice notation</label>\n <div class=\"line\">\n <input id=\"expr\" type=\"text\" value=\"2d6+3\" autocomplete=\"off\" autocapitalize=\"off\" spellcheck=\"false\" aria-describedby=\"hint\">\n <button type=\"button\" id=\"roll\" class=\"primary\">Roll</button>\n </div>\n <small id=\"hint\">Enter rolls it. Try 1d20+5, d% or 4d6 drop lowest.</small>\n </div>\n <div class=\"quick\" role=\"group\" aria-label=\"Roll one die\">\n <button type=\"button\" data-q=\"d4\">d4</button><button type=\"button\" data-q=\"d6\">d6</button><button type=\"button\" data-q=\"d8\">d8</button><button type=\"button\" data-q=\"d10\">d10</button><button type=\"button\" data-q=\"d12\">d12</button><button type=\"button\" data-q=\"d20\">d20</button><button type=\"button\" data-q=\"d100\">d100</button>\n </div>\n <div class=\"more\">\n <button type=\"button\" id=\"adv\" aria-label=\"d20 with advantage\"><span class=\"long\">d20, advantage</span><span class=\"short\">Advantage</span></button>\n <button type=\"button\" id=\"dis\" aria-label=\"d20 with disadvantage\"><span class=\"long\">d20, disadvantage</span><span class=\"short\">Disadvantage</span></button>\n <button type=\"button\" id=\"abil\" aria-label=\"Roll ability scores\"><span class=\"long\">Roll ability scores</span><span class=\"short\">Abilities</span></button>\n </div>\n <div class=\"result\" id=\"result\" aria-live=\"polite\" aria-atomic=\"true\">\n <p class=\"empty\">Nothing rolled yet.</p>\n </div>\n <div class=\"hist\" id=\"hist\" hidden>\n <div class=\"histhead\"><span>Rolls so far</span><button type=\"button\" id=\"clear\" class=\"link\">Clear</button></div>\n <ol id=\"history\"></ol>\n </div>\n</div>",
"css": ".dice { font: 400 14px/1.4 var(--sans); color: var(--ink); }\n.entry label { display: block; margin-bottom: 4px; }\n.line { display: flex; gap: 8px; }\n.line input { flex: 1; min-width: 0; font: 400 17px var(--mono); padding: 8px; }\n.entry small { display: block; margin-top: 4px; color: var(--ink-3); font-size: 12.5px; }\nbutton { font: 500 14px/1 var(--sans); color: var(--ink); background: var(--paper); border: 1px solid var(--ink); border-radius: 0; padding: 10px 12px; min-height: 40px; cursor: pointer; touch-action: manipulation; }\nbutton.primary { background: var(--ink); color: var(--paper); padding: 10px 20px; }\nbutton:focus-visible, input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }\n.quick { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); margin-top: 12px; }\n.quick button { padding: 10px 0; }\n.quick button + button { border-left: 0; }\n.more { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }\n.more button { flex: 1 1 auto; }\n.result { margin-top: 14px; padding: 10px 0 12px; border-top: 1px solid var(--ink); border-bottom: 1px solid var(--rule); min-height: 110px; }\n.hist[hidden] { display: none; }\n.short { display: none; }\n.result .empty { color: var(--ink-3); font: italic 400 16px var(--serif); margin: 0; }\n.head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }\n.total { font: 400 46px/1 var(--sans); font-variant-numeric: tabular-nums; }\n.what { font: 400 14px var(--mono); color: var(--ink-2); }\n.dz { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin-top: 10px; }\n.die { min-width: 32px; height: 32px; padding: 0 4px; display: inline-grid; place-items: center; border: 1px solid var(--ink); font: 500 15px/1 var(--sans); font-variant-numeric: tabular-nums; }\n.die.max { border-width: 2px; }\n.die.out { border-style: dashed; border-color: var(--ink-3); color: var(--ink-3); text-decoration: line-through; }\n.op { font: 400 15px var(--mono); color: var(--ink-2); padding: 0 3px; }\n.tag { font: 400 12px var(--sans); color: var(--ink-3); margin-right: 2px; }\n.note { margin: 8px 0 0; font: italic 400 15px/1.4 var(--serif); font-variant-numeric: lining-nums; color: var(--ink-2); }\n.scores { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 20px; margin-top: 4px; }\n.score { display: flex; align-items: center; gap: 4px; }\n.score .die { min-width: 26px; height: 26px; font-size: 13px; }\n.score b { margin-left: auto; font: 600 17px var(--sans); font-variant-numeric: tabular-nums; }\n.score i { font: 400 13px var(--sans); color: var(--ink-3); font-style: normal; min-width: 2.4em; text-align: right; }\n.hist { margin-top: 10px; }\n.histhead { display: flex; align-items: center; justify-content: space-between; font: 500 13px var(--sans); color: var(--ink-2); }\nbutton.link { border: 0; background: none; padding: 6px 0 6px 12px; min-height: 32px; text-decoration: underline; color: var(--ink-2); font-weight: 400; font-size: 13px; }\n.hist ol { list-style: none; margin: 4px 0 0; padding: 0; }\n.hist li { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; border-bottom: 1px solid var(--rule); font: 400 13px/1.35 var(--mono); color: var(--ink-2); }\n.hist li b { font: 600 14px var(--sans); color: var(--ink); font-variant-numeric: tabular-nums; }\n.hist li:first-child { color: var(--ink); }\n@media (max-width: 480px) { .scores { grid-template-columns: 1fr; gap: 4px; } .total { font-size: 40px; } .quick button { font-size: 13px; } .more { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } .more button { padding: 10px 2px; font-size: 13px; } .long { display: none; } .short { display: inline; } }",
"js": "(function () {\n var root = document.querySelector('.dice');\n function $(id) { return root.querySelector('#' + id); }\n var MAX_DICE = 40, SHOW = 5;\n\n // mulberry32: a small seeded generator, so the page starts the same way each time.\n var seed = 20260927, rolls = 0, lastClick = 0;\n function rnd() {\n seed = (seed + 0x6D2B79F5) | 0;\n var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n }\n function d(sides) { return 1 + Math.floor(rnd() * sides); }\n // From the fourth roll on, the time between clicks is stirred into the seed.\n function stir() {\n rolls++;\n var t = performance.now();\n if (rolls >= 4) seed = (seed ^ Math.imul(Math.floor((t - lastClick) * 1000) | 0, 0x9E3779B1)) | 0;\n lastClick = t;\n }\n\n function esc(s) { return String(s).replace(/[&<>\"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '\"': '"' }[c]; }); }\n\n // Dice notation to terms: [{ sign, count, sides, keep: { how, n } } | { sign, value }].\n function parse(src) {\n var s = String(src).toLowerCase().trim();\n s = s.replace(/\\s*drop(?:\\s+the)?\\s+lowest(?:\\s+(\\d+))?/g, function (m, n) { return 'dl' + (n || 1); })\n .replace(/\\s*drop(?:\\s+the)?\\s+highest(?:\\s+(\\d+))?/g, function (m, n) { return 'dh' + (n || 1); })\n .replace(/\\s*keep(?:\\s+the)?\\s+highest\\s+(\\d+)/g, 'kh$1')\n .replace(/\\s*keep(?:\\s+the)?\\s+lowest\\s+(\\d+)/g, 'kl$1')\n .replace(/\\s+/g, '').replace(/−/g, '-');\n if (!s) throw new Error('Type some dice first, like 2d6+3.');\n var terms = [], dice = 0, first = true;\n var re = /^([+-])?(?:(\\d*)d(\\d+|%)(?:(dl|dh|kh|kl|k)(\\d+))?|(\\d+))/;\n while (s.length) {\n var m = re.exec(s);\n if (!m || (!first && !m[1])) throw new Error('That is not dice notation: \"' + s + '\". Try 2d6+3, 1d20 or 4d6 drop lowest.');\n var sign = m[1] === '-' ? -1 : 1;\n if (m[6] !== undefined) terms.push({ sign: sign, value: Number(m[6]) });\n else {\n var count = m[2] === '' ? 1 : Number(m[2]);\n var sides = m[3] === '%' ? 100 : Number(m[3]);\n if (count < 1) throw new Error('Roll at least one die.');\n if (sides < 2 || sides > 1000) throw new Error('Dice need 2 to 1,000 sides.');\n dice += count;\n var keep = null;\n if (m[4]) {\n var how = m[4] === 'k' ? 'kh' : m[4], n = Number(m[5]);\n if (how[0] === 'd' && count < 2) throw new Error('Dropping a die needs at least two dice, like 4d6 drop lowest.');\n if (how[0] === 'd' && (n < 1 || n >= count)) throw new Error('Of ' + count + ' dice you can drop at most ' + (count - 1) + '.');\n if (how[0] === 'k' && (n < 1 || n > count)) throw new Error('Of ' + count + ' dice you can keep 1 to ' + count + '.');\n keep = { how: how, n: n };\n }\n terms.push({ sign: sign, count: count, sides: sides, keep: keep });\n }\n s = s.slice(m[0].length); first = false;\n }\n if (dice > MAX_DICE) throw new Error('At most ' + MAX_DICE + ' dice in one roll.');\n if (!dice) throw new Error('Add at least one die, like 1d20+5.');\n return terms;\n }\n\n function rollTerm(t) {\n var vals = [];\n for (var i = 0; i < t.count; i++) vals.push({ v: d(t.sides), out: false });\n if (t.keep) {\n var order = vals.map(function (x, i) { return i; }).sort(function (a, b) { return vals[a].v - vals[b].v || a - b; });\n var drop = [];\n if (t.keep.how === 'dl') drop = order.slice(0, t.keep.n);\n if (t.keep.how === 'dh') drop = order.slice(order.length - t.keep.n);\n if (t.keep.how === 'kh') drop = order.slice(0, order.length - t.keep.n);\n if (t.keep.how === 'kl') drop = order.slice(t.keep.n);\n drop.forEach(function (i) { vals[i].out = true; });\n }\n var sum = vals.reduce(function (a, x) { return a + (x.out ? 0 : x.v); }, 0);\n return { dice: vals, sum: sum };\n }\n\n function dieHtml(x, sides) {\n var cls = 'die' + (x.out ? ' out' : '') + (x.v === sides ? ' max' : '');\n return '<span class=\"' + cls + '\" aria-label=\"' + x.v + (x.out ? ', dropped' : '') + '\">' + x.v + '</span>';\n }\n\n var history = [];\n function remember(label, detail, total) {\n history.unshift({ label: label, detail: detail, total: total });\n if (history.length > SHOW) history.pop();\n $('hist').hidden = false;\n $('history').innerHTML = history.map(function (h) {\n return '<li><span>' + esc(h.label) + ': ' + esc(h.detail) + '</span><b>' + esc(h.total) + '</b></li>';\n }).join('');\n }\n\n function show(html) { $('result').innerHTML = html; }\n function fail(msg) { show('<p class=\"note\">' + esc(msg) + '</p>'); }\n\n function rollNotation(src, label) {\n var terms;\n try { terms = parse(src); } catch (e) { fail(e.message); return; }\n stir();\n var total = 0, parts = [], words = [], note = '';\n terms.forEach(function (t, i) {\n var op = i === 0 ? (t.sign < 0 ? '−' : '') : (t.sign < 0 ? ' − ' : ' + ');\n if (t.value !== undefined) {\n total += t.sign * t.value;\n parts.push('<span class=\"op\">' + op.trim() + ' ' + t.value + '</span>');\n words.push(op + t.value);\n return;\n }\n var r = rollTerm(t);\n total += t.sign * r.sum;\n var name = t.count + 'd' + t.sides + (t.keep ? ' ' + { dl: 'drop lowest', dh: 'drop highest', kh: 'keep highest', kl: 'keep lowest' }[t.keep.how] + (t.keep.n > 1 || t.keep.how[0] === 'k' ? ' ' + t.keep.n : '') : '');\n parts.push((op.trim() ? '<span class=\"op\">' + op.trim() + '</span>' : '') + '<span class=\"tag\">' + esc(name) + '</span>' + r.dice.map(function (x) { return dieHtml(x, t.sides); }).join(''));\n words.push(op + '[' + r.dice.map(function (x) { return x.out ? '(' + x.v + ')' : x.v; }).join(', ') + ']');\n if (t.sides === 100 && t.count === 1) {\n var v = r.dice[0].v, tens = v === 100 ? '00' : String(Math.floor(v / 10) * 10), units = String(v % 10);\n if (tens === '0') tens = '00';\n note = 'As percentile dice: tens ' + tens + ', units ' + units + (v === 100 ? ' (00 and 0 read as 100).' : '.');\n }\n if (t.sides === 20 && t.count === 1 && terms.length <= 2) {\n if (r.dice[0].v === 20) note = 'A natural 20.';\n if (r.dice[0].v === 1) note = 'A natural 1.';\n }\n });\n var shown = label || src.trim();\n show('<div class=\"head\"><span class=\"total\">' + total + '</span><span class=\"what\">' + esc(shown) + '</span></div><div class=\"dz\">' + parts.join('') + '</div>' + (note ? '<p class=\"note\">' + esc(note) + '</p>' : ''));\n remember(shown, words.join('').trim(), total);\n }\n\n function rollD20(adv) {\n stir();\n var a = d(20), b = d(20);\n var keepFirst = adv ? a >= b : a <= b;\n var kept = keepFirst ? a : b;\n var dice = [{ v: a, out: !keepFirst }, { v: b, out: keepFirst }];\n var label = adv ? 'd20 with advantage' : 'd20 with disadvantage';\n var note = (adv ? 'Two d20s, the higher kept.' : 'Two d20s, the lower kept.') + (kept === 20 ? ' A natural 20.' : kept === 1 ? ' A natural 1.' : '');\n show('<div class=\"head\"><span class=\"total\">' + kept + '</span><span class=\"what\">' + label + '</span></div><div class=\"dz\">' + dice.map(function (x) { return dieHtml(x, 20); }).join('') + '</div><p class=\"note\">' + note + '</p>');\n remember(label, '[' + dice.map(function (x) { return x.out ? '(' + x.v + ')' : x.v; }).join(', ') + ']', kept);\n }\n\n function mod(score) { var m = Math.floor((score - 10) / 2); return (m < 0 ? '−' : '+') + Math.abs(m); }\n\n function rollAbilities() {\n stir();\n var rows = [], scores = [];\n for (var i = 0; i < 6; i++) {\n var r = rollTerm({ count: 4, sides: 6, keep: { how: 'dl', n: 1 } });\n scores.push(r.sum);\n rows.push('<div class=\"score\">' + r.dice.map(function (x) { return dieHtml(x, 6); }).join('') + '<b>' + r.sum + '</b><i>' + mod(r.sum) + '</i></div>');\n }\n var sum = scores.reduce(function (a, b) { return a + b; }, 0);\n show('<div class=\"head\"><span class=\"what\">Ability scores, 4d6 drop the lowest, six times</span></div><div class=\"scores\">' + rows.join('') + '</div><p class=\"note\">' + scores.join(', ') + ': ' + sum + ' in all. Assign them to the six abilities as you choose.</p>');\n remember('Ability scores', scores.join(', '), sum);\n }\n\n $('roll').addEventListener('click', function () { rollNotation($('expr').value); });\n $('expr').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); rollNotation($('expr').value); } });\n root.querySelectorAll('[data-q]').forEach(function (b) {\n b.addEventListener('click', function () { rollNotation('1' + b.getAttribute('data-q'), b.getAttribute('data-q')); });\n });\n $('adv').addEventListener('click', function () { rollD20(true); });\n $('dis').addEventListener('click', function () { rollD20(false); });\n $('abil').addEventListener('click', rollAbilities);\n $('clear').addEventListener('click', function () { history = []; $('history').innerHTML = ''; $('hist').hidden = true; $('expr').focus(); });\n})();"
}
```Tidewrack — at the tablePlaces of the Tidewrack
3of 14
Kessrin’s Ferry and the toll house
Observation, key section, 1 note, 236 words
What a traveller sees. A flat-bottomed ferry on a hawser across the mouth of the Brenn, where the coast road ends and the Salt Road begins. On the near bank, a stone toll house with shuttered windows and a bell on a post. Ring it, and someone comes, eventually.
Kessrin's FerryPlace, on the Salt Road
- Where
- Half a day up the coast from Brenmoor; the Salt Road starts on the far bank
- Crossing
- Dawn to dusk. After dark the ferryman will not cross, at any price
- Toll
- Two silver a head, five for a cart, taken for the Ashen Court. The Saltwardens call it unlawful, says Captain Morrow (session 14)
- Ferryman
- Aldo Grieve, who overcharges and says so
- Found here
- A letter sealed with ash, not wax, in the ferryman's strongbox (session 11)
- Still unknown
- Who Kessrin was. Nobody at the ferry will say
What the party knows after session 14. New rows go in the morning after a session.
Kessrin's Ferry · Place, on the Salt Road
Where: Half a day up the coast from Brenmoor; the Salt Road starts on the far bank
Crossing: Dawn to dusk. After dark the ferryman will not cross, at any price
Toll: Two silver a head, five for a cart, taken for the Ashen Court. The Saltwardens call it unlawful, says Captain Morrow (session 14)
Ferryman: Aldo Grieve, who overcharges and says so
Found here: A letter sealed with ash, not wax, in the ferryman's strongbox (session 11)
Still unknown: Who Kessrin was. Nobody at the ferry will say
What the party knows after session 14. New rows go in the morning after a session.
```element card
{
"label": "Kessrin's Ferry",
"aside": "Place, on the Salt Road",
"rows": [
{
"term": "Where",
"value": "Half a day up the coast from Brenmoor; the Salt Road starts on the far bank"
},
{
"term": "Crossing",
"value": "Dawn to dusk. After dark the ferryman will not cross, at any price"
},
{
"term": "Toll",
"value": "Two silver a head, five for a cart, taken for the Ashen Court. The Saltwardens call it unlawful, says Captain Morrow (session 14)"
},
{
"term": "Ferryman",
"value": "Aldo Grieve, who overcharges and says so"
},
{
"term": "Found here",
"value": "A letter sealed with ash, not wax, in the ferryman's strongbox (session 11)"
},
{
"term": "Still unknown",
"value": "Who Kessrin was. Nobody at the ferry will say"
}
],
"note": "What the party knows after session 14. New rows go in the morning after a session."
}
```What people say. In Brenmoor they say the toll house has never once been empty at night. Rumours are written as you heard them.
How it works
Every campaign has two versions: the harbour town your players know, and who really runs it. Your assistant files your notebook onto two shelves, one for the table and one behind the screen that never gets a link. Each place carries a handout card of what the party has learned, and the players’ guide has a dice roller.
Try this prompt
Made with
ShelfYour AI assistantElementsProposed notesPublic linkLook up