Randomness exposes a JavaScript API for other plugins and for Templater scripts. Use it to roll generators from code — for example, to populate a freshly created note from a shared generator library.
const api = app.plugins.plugins["randomness"].api;
const result = await api.roll("VillainName");
console.log(result.result); // -> "Mordred the Pale"- API version:
1.4.0(readapi.version) - The API is stable within a major version. New methods may be added in minor versions; breaking changes bump the major.
| Method | Purpose |
|---|---|
roll(tableName, opts?) |
Roll a named table in note scope. |
rollUnscoped(tableName, opts?) |
Roll a named table found anywhere in the vault, ignoring scope. |
rollExpression(rawExpr, opts?) |
Roll an arbitrary expression, e.g. "[@A] of [@B]". |
rollFormula(nameOrFormula, opts?) |
Roll a dice formula or a saved alias, in Dice Roller syntax. |
formulas() |
The saved dice formula aliases, { alias: formula }. |
tables(callerNotePath?) |
List table names visible from a note's scope. |
tablesWithSources(callerNotePath?) |
List tables with their source files and scope flag. |
onRoll(callback) |
Subscribe to every roll attempt. Returns an unsubscribe fn. |
randomNote(folder?, opts?) |
A random markdown note from a folder (recursive), with its frontmatter. |
portraits.* |
Portrait compositor: roll/render/savePng/snippets (see below). |
version |
The API version string. |
All roll methods are async and return a Promise<RollResult>.
This is the most important distinction in the API.
roll() is scoped. It resolves tables the way a codeblock in a note
would: it can see same-note tables and whatever that note's Use:
imports bring in. It is the right call when you are rolling from the
context of a specific note and want that note's scope to apply.
Scoped rolls can only reach tables defined in markdown codeblocks (and their
Use:graph). They cannot reach a bare.iptfile'sTable:definitions unless that file isUse:d into scope.
rollUnscoped() ignores scope. It searches every .ipt file in the
vault (under the generator root, if one is configured) for a table with
the given name, loads that file plus its entire Use: graph, and rolls
it. This is the right call for automation and note generation, where
there is no note scope wired up yet.
Use
rollUnscoped()when a Templater template creates a new note and needs to roll a generator from your shared library. The new note has no scope, so a scopedroll()would find nothing.
Roll a named table in note scope. Internally wraps the name as
[@tableName].
const r = await api.roll("Weather", {
callerNotePath: "Campaigns/Saltmarsh/Session 12.md",
});opts (RollOptions, all optional):
| Field | Type | Meaning |
|---|---|---|
callerNotePath |
string |
Note path used to resolve scope (which Use: imports and same-note tables are visible). Falls back to the active note, then to no scope. |
seed |
number |
Deterministic roll: same seed + same expression + same scope → same result. Omit for normal random behaviour. |
promptValues |
Record<string,string> |
Override generator prompts, keyed by prompt label. Prompts without an override use their declared default. |
dictKey |
string |
For dictionary tables (Type: Dictionary), the key to look up. Equivalent to evaluating [#<key> <Table>] directly. See Dictionary tables below. |
Roll a named table found anywhere in the vault, ignoring note scope.
const r = await api.rollUnscoped("TF-Inn", {
promptValues: { town: "Frostkey", shopName: "The Salty Anchor" },
});opts (UnscopedRollOptions, all optional):
| Field | Type | Meaning |
|---|---|---|
seed |
number |
Deterministic roll (wired to the engine RNG). |
promptValues |
Record<string,string> |
Prompt overrides keyed by prompt label. |
filePath |
string |
Disambiguate when multiple files define the same table name: only consider the file at this exact vault path. |
dictKey |
string |
For dictionary tables, the key to look up. See Dictionary tables below. |
Collision handling. If two files define the same table name, the first discovered (sorted by path) wins, and a one-time warning is logged to the developer console:
randomness: rollUnscoped("Inn") is ambiguous — 2 files define this
table (.../Inns.ipt, .../place-inn.ipt). Using ".../Inns.ipt". Pass
{ filePath: "..." } to choose a specific one.
Pass filePath to pick the one you mean, or give your tables unique
names to avoid the collision entirely.
Roll an arbitrary expression rather than a single named table. Accepts
the same RollOptions as roll().
const r = await api.rollExpression("[@FirstName] [@Surname] of [@City]");(Added in API 1.3.0.) Roll a dice formula the way the dice tray and
inline dice: spans do — including the formulas you have saved as
aliases.
nameOrFormula is first matched against Settings → Randomness → Dice
formula aliases (the same list the tray's ★ button writes to). The
match is whole-string, trimmed and case-insensitive, exactly as for an
inline span. On a hit, the alias's formula is rolled; on a miss, the
string itself is rolled as a formula.
await api.rollFormula("sneak"); // saved alias → e.g. 4d6dl1
await api.rollFormula("2d6! + 3"); // raw formula, exploding
await api.rollFormula("[[Loot^gems]]"); // a table in a note
await api.rollFormula("#rumour|link"); // a tag rollThe whole Dice Roller compat grammar is accepted: modifiers
(kh/kl/dl/dh, !, !!, r, s, u, cs), special dice
(d%, d66%, dF, d[3,5]), [[Note^id]] table rolls with
repetitions and |Column picks, and #tag rolls.
Accepts the same opts as roll() (callerNotePath, seed,
promptValues). In the returned RollResult, table is the alias name
(or the raw input when no alias matched) and expression is the
translated native expression — handy when you want to show the user
what actually ran:
const r = await api.rollFormula("sneak");
r.table; // "sneak"
r.expression; // "{4d6dl1}"
r.result; // "14"
roll(),rollUnscoped()androllExpression()deliberately do not resolve aliases — an expression that happens to share a name with an alias keeps its existing meaning.rollFormulais the alias-aware entry point.
Initiative-tracker recipe. Roll a saved formula and write the number into a monster note's frontmatter, from a button:
const api = app.plugins.plugins["randomness"].api;
const file = app.vault.getAbstractFileByPath("Bestiary/Ogre.md");
const r = await api.rollFormula("init-step-8");
await app.fileManager.processFrontMatter(file, (fm) => {
fm.initiative = Number(r.result); // Number() so tables sort numerically
});Fire that on a click (Meta Bind button, Templater command, QuickAdd macro) — not from a render-time block. See Storing results in a note's frontmatter below for why.
(Added in API 1.3.0.) The saved dice formula aliases, as
{ alias: formula }. Synchronous. Returns a copy, so mutating it does
not touch settings.
const saved = api.formulas();
// { sneak: "4d6dl1", "init-step-8": "2d6!" }
for (const [name, formula] of Object.entries(saved)) {
console.log(name, "=", formula);
}Use it to populate a dropdown, or to check an alias exists before rolling it.
List table names visible from a note's scope, deduplicated and sorted.
const names = await api.tables("Notes/Generators Hub.md");
// -> ["City", "FirstName", "Surname", "Weather", ...]Like tables(), but each entry reports where the table lives and
whether it's in the caller's scope. In-scope tables come first.
const all = await api.tablesWithSources();
const innFiles = all.filter((t) => t.name === "Inn");
// Inspect innFiles[].filePath to see which files define "Inn".Each TableSource:
| Field | Type | Meaning |
|---|---|---|
name |
string |
Table name. |
source |
string |
Source label (file title, or (this note) for in-note tables). |
filePath |
string |
Vault path of the defining file; "" for in-note tables. |
inScope |
boolean |
True if reachable from the caller note's scope. |
This is the diagnostic to reach for when a
rollUnscoped()returns the "wrong" generator: filter by the table name and inspect thefilePaths to find the colliding file.
Subscribe to every roll attempt — both successes and failures. Returns an unsubscribe function.
const off = api.onRoll((result) => {
if (result.error) console.warn("roll failed:", result.error);
else console.log("rolled:", result.result);
});
// later:
off();Every roll method resolves to a RollResult:
| Field | Type | Meaning |
|---|---|---|
result |
string |
Rendered output. On failure, an error-marker string [ROLL ERROR: ...] so spliced text shows something visible. |
table |
string |
Table name requested (or the raw expression for rollExpression). |
expression |
string |
Full expression evaluated (e.g. "[@TableName]"). |
source |
string? |
Note/file path the roll was scoped to, if any. |
error |
string? |
Set only when the attempt threw; the error message. |
timestamp |
string |
ISO 8601 timestamp of the attempt. |
rollId |
string |
Unique ID for this roll (for dedup/history). |
Roll methods do not reject on a generator error — they resolve with a
RollResult whose error is set and whose result is the
[ROLL ERROR: ...] marker. Check result.error if you need to branch.
A generator can declare prompts:
Prompt: town {} an unnamed town
Prompt: shopName {}
promptValues overrides them by label:
await api.rollUnscoped("TF-Inn", {
promptValues: { town: "Frostkey", shopName: "The Salty Anchor" },
});- Keys must match the
Prompt:labels exactly. - A prompt with no override uses its declared default.
- Inside the generator, prompts are read positionally as
{$prompt1},{$prompt2}, … in declaration order. (A common pattern is to copy them into named variables:Set: town={$prompt1}.)
The generator format supports dictionary tables — named lookups rather than random draws. Each entry has a key and a value; you don't roll them, you pick one by key.
Table: SkillML
Type: Dictionary
Inept: {1d20+29}
Novice: {1d10+49}
Aspirant: {1d10+59}
Professional: {1d10+69}
Expert: {1d10+79}
Paragon: {1d10+89}
Calling roll("SkillML") on this with no key returns "" — dictionary
tables don't roll randomly. Pass the key as dictKey:
const r = await api.rollUnscoped("SkillML", { dictKey: "Inept" });
// r.result is a string like "37" (1d20+29 evaluated)Equivalent to writing the pick expression directly:
const r = await api.rollExpression("[#Inept SkillML]");dictKey is purely a convenience for callers that already have a key
in hand (commonly from frontmatter or a meta-bind input):
const r = await api.rollUnscoped("SkillML", {
dictKey: dv.current().competence, // e.g. "Professional"
});dictKey is passed verbatim to the dictionary lookup — keys can
contain anything, including spaces, hyphens, quotes, or other
characters that wouldn't fit in a single bareword:
Table: Occupation
Type: Dictionary
Knight Bachelor: a sworn knight in service to a lord
Master-Adept: an established mage of some renown
const r = await api.rollUnscoped("Occupation", {
dictKey: "Knight Bachelor", // multi-word keys work directly
});In raw generator source, the bare [#key Table] form whitespace-splits the
key, so a multi-word key has to be quoted:
[#"Knight Bachelor" Occupation]
[#"a key with: weird, punctuation" Table]
Embedded double-quotes can be escaped with a backslash:
[#"a \"b\" c" Table]. Single-word keys still work unquoted —
[#Plain Table], [#Master-Adept Table], [#{$variable} Table]
all behave exactly as before.
The value-side expressions ({1d20+29} etc.) are evaluated the same
way as any other table item, so dice, variables, and nested rolls all
work inside dictionary entries.
Unknown keys return an empty string rather than throwing — match
the behaviour of [#bogus Table] in generator source. If you need a default,
test for an empty result on the caller side.
<%*
const api = app.plugins.plugins["randomness"].api;
const fm = tp.frontmatter;
const r = await api.rollUnscoped("TF-Inn", {
promptValues: { town: fm.town, shopName: fm.name },
});
tR += r.result;
%>const a = await api.rollUnscoped("Weather", { seed: 12345 });
const b = await api.rollUnscoped("Weather", { seed: 12345 });
// a.result === b.resultIf a roll's result is written into the same note's frontmatter from
inside a render-time block (a dataviewjs codeblock, a Templater
<%* ... %> script that's re-evaluated on render, or a Meta Bind
button that auto-fires), you can end up in a feedback loop:
- The block runs and rolls fresh values.
- It writes those values to frontmatter via
processFrontMatter/tp.file.process_frontmatter/ equivalent. - The write modifies the file.
- The renderer sees the file change and re-runs the block.
- Goto 1 — and the next roll produces different numbers, so step 2 keeps registering as a real change. The note re-rolls forever as long as it's open.
This isn't specific to dice expressions in the result — the loop fires whenever a non-idempotent call writes back to the watched file. The fix is to make the rolls idempotent under repeated renders, so step 2's write produces the same value as last time and the file isn't actually modified.
Two patterns:
Seed off a stable input. If you want "the same NPC every time this note renders, but a fresh one when the user clicks a Reroll button", seed on something that doesn't change between renders, and bump it explicitly when you want a reroll:
// Tiny string→int hash, good enough for seeding
const stableSeed = (s) => {
let h = 0;
for (const c of s) h = ((h << 5) - h + c.charCodeAt(0)) | 0;
return h;
};
const noteFile = app.workspace.getActiveFile();
const fm = app.metadataCache.getFileCache(noteFile)?.frontmatter ?? {};
const seed = stableSeed(noteFile.path + "|" + (fm.rerollToken ?? "0"));
const r1 = await api.rollUnscoped("Occupation", { seed: seed });
const r2 = await api.rollUnscoped("Weapons", { seed: seed + 1 });
const r3 = await api.rollUnscoped("Armor", { seed: seed + 2 });
await app.fileManager.processFrontMatter(noteFile, (f) => {
f.occupation = r1.result;
f.weapons = r2.result;
f.armor = r3.result;
});Repeated renders produce identical values; processFrontMatter
sees no change; no loop. A "Reroll" button just writes a new
rerollToken (e.g. Date.now()) and the seed shifts.
Or, don't write to frontmatter on render at all. Move the
processFrontMatter calls into a Meta Bind button or Templater
command the user invokes deliberately. Render-time blocks become
read-only, the loop has nowhere to start. This is the right pattern
when you want a fresh roll on every open and only persist on demand.
const all = await api.tablesWithSources();
console.table(
all.filter((t) => t.name === "Inn").map((t) => ({ file: t.filePath }))
);Pick a random markdown note, optionally limited to a folder (searched recursively). Great for "roll a random encounter/NPC/rumour note" tables you don't want to maintain by hand:
const api = app.plugins.plugins["randomness"].api;
const enc = api.randomNote("Encounters/Forest");
if (enc) tR += `Tonight: ${enc.link}`; // -> Tonight: [[Encounters/Forest/Wolves]]Returns { path, basename, link, frontmatter } (the link is
path-qualified), or null when the folder contains no notes.
opts.seed makes the pick deterministic. Prefer a hand-curated .ipt
table of [[links]] when you want to control weighting.
frontmatter is the note's properties straight from Obsidian's
metadata cache — {} when it has none, so it's always safe to reach
into. Keys keep their authored casing.
const api = app.plugins.plugins["randomness"].api;
const m = api.randomNote("Bestiary");
if (m) {
const { cr = "?", hp = "?" } = m.frontmatter;
tR += `${m.link} — CR ${cr}, ${hp} HP`;
}Filtering on properties (rather than just reading them) is a job for
the inline syntax: `rdm:*|folder=Bestiary|cr=3|link`. That syntax
can print them too — `rdm:*|folder=Bestiary|prop:{{link}} — CR {{cr}}` — which is usually less code than a Templater block. See
Random lines, blocks, and tagged notes in the reference.
When a portrait pack is installed (Settings → Randomness), the API can
roll layered character portraits — built for Templater templates
that stamp out NPC notes. Every method throws when no pack is
installed, so check available() first.
| Method | Purpose |
|---|---|
available(pack?) |
true when a pack manifest is installed. |
roll(opts?) |
Roll a portrait. opts: seed, pack, and constraints gender, race, age (rerolls until matched; maxTries caps it, default 400). |
render(recipe, opts?) |
Re-render an exact recipe (drift-proof). |
savePng(portraitOrRecipe, opts?) |
Write a PNG into the vault (default folder Portraits/); returns the vault path. |
name(recipe, pack?) |
The portrait's deterministic display name. |
blockSnippet(recipe) |
Ready-to-paste ```portrait codeblock pinned to the recipe. |
inlineSnippet(recipe, size?) |
Ready-to-paste inline `portrait:` span. |
roll/render resolve to a PortraitResult:
{
recipe, // serializable PortraitRecipe — persist this
svg, // rendered SVG markup
seed, // the rolled seed
name, // engine-rolled, race/gender-appropriate name
race, // race token from the base layer (e.g. "elf") or null
gender, // "male" | "female"
age, // "young" | "adult" | "old"
}<%*
const rnd = app.plugins.plugins["randomness"].api;
if (!(await rnd.portraits.available())) {
tR += "_No portrait pack installed._";
} else {
// Constrained roll: a female elf, any age.
const p = await rnd.portraits.roll({ gender: "female", race: "elf" });
// Live, drift-proof portrait pinned in the note:
tR += rnd.portraits.inlineSnippet(p.recipe, 160) + "\n\n";
tR += `# ${p.name}\n`;
tR += `**Race:** ${p.race} · **Age:** ${p.age}\n\n`;
// Mix with table rolls from your own generators:
const job = await rnd.rollUnscoped("Occupation");
tR += `**Occupation:** ${job.result}\n`;
// Prefer a permanent image file instead of a live span?
// const path = await rnd.portraits.savePng(p);
// tR += `![[${path.split("/").pop()}]]\n`;
// Keep the recipe in frontmatter for later re-rendering:
// tR = `---\nportrait: '${JSON.stringify(p.recipe)}'\n---\n` + tR;
}
%>Notes:
-
Constrained rolls reroll until they match. Gendered gating (facial hair, etc.) happens at roll time, so the API never edits a recipe in place to meet a constraint. Race tokens come from the pack's
base_<race>_NNfilenames — ask for a race the pack doesn't ship androllthrows aftermaxTries. -
seedmakes the roll deterministic and ignores constraints. -
One person across a whole template: roll the portrait once, then pass its facts into your text generator as prompts, with the generator falling back to its own rolls when they're empty:
const keeper = await rnd.portraits.roll(); const shop = await rnd.rollUnscoped("TF-ShopByType", { promptValues: { keeperName: keeper.name, keeperRace: keeper.race ?? "", keeperGender: keeper.gender, keeperAge: keeper.age, }, });
and in the
.ipt(prompts are positional —{$prompt5}here):Prompt: keeperName {} Table: Shopkeeper Set: owner=[when]{$prompt5}=[do][@PersonName][else]{$prompt5}[end]The portrait, infobox, and rolled prose all describe the same NPC; rolled standalone (no prompts), the generator behaves exactly as before.
-
The recipe is the persistence format: it re-renders byte-identically even after the pack gains new parts.
api.version is the API surface version (see the header at the top of
this file), not the plugin version. Consumers can check it:
const api = app.plugins.plugins["randomness"]?.api;
if (!api) {
// Randomness not installed/enabled.
} else if (!api.version.startsWith("1.")) {
// Built against a different major; behaviour may differ.
}