Appearance
Calculators
A calculator turns a deck into a small stats dashboard: a curve, a type breakdown, opening-hand odds, whatever numbers you care about. Every launched game ships with a ready-made one, and you can build your own in two ways:
- The no-code builder. Pick widgets from a palette, choose what they measure from your game's card fields, and watch a live preview.
- Code. Every builder calculator is a short JavaScript function underneath. Open it as code to go past what the builder can do, or write one from scratch.
This guide walks through both. For the exact shape of every argument and widget field, see the calculator script API.
Where calculators show up
Look for the Calculators button (a calculator icon) on any deck:
- in the deck builder, next to the deck's other actions,
- on a deck page and in the deck preview dialog,
- in the action menu of your saved decks.
It opens a dialog with a Calculator picker at the top. The picker has two groups: Defaults (the calculators Turny.gg ships for the game) and Your calculators (the ones you made or saved). Pick one and it runs over the deck in front of you. A deck set is counted as one combined deck.
You manage calculators in Settings → Calculators (/settings/custom-calculators) on a game site such as pokemon.turny.gg. Calculators belong to one game, so the list shows only that game's calculators. From there you can:
- switch any calculator on or off; a switched-off calculator is hidden from the picker (it says "Hidden from deck views"),
- View code of any calculator, including the defaults,
- Duplicate any calculator into an editable copy of your own,
- Edit or Delete calculators you made.
Default calculators are read-only. To change one, duplicate it and edit the copy.
The default dashboards
Every launched game has one default calculator, called Deck breakdown. It is on for everyone until you switch it off.
| Game | What Deck breakdown shows |
|---|---|
| Legends of Runeterra | Mana curve, card types (champions, followers, spells, landmarks, equipment), rarity, regions, average cost, and a units to spells ratio. |
| Pokémon TCG | Pokémon / Trainer / Energy breakdown, energy types, mulligan chance, an Opening Hand Starters table (the odds each Basic Pokémon starts, and the odds it is your only Basic), retreat cost, and regulation marks. |
| Riftbound | Energy curve, domains, card types, average energy, and a unit / spell / gear split. |
The defaults are ordinary calculator scripts, and their full source is on the calculator script API page. They make good starting points: Duplicate one and edit the copy.
Tutorial: build a dashboard without code
This tutorial builds a Pokémon TCG calculator called Starter check with two widgets: a retreat cost curve for your Pokémon, and a table of opening-hand starter odds for every Basic Pokémon. The same steps work in every game; only the field names change.
1. Start a new calculator
- Go to
pokemon.turny.ggand sign in. - Open Settings → Calculators and click New calculator.
- Type
Starter checkin Calculator name.
The dialog has three parts:
- Sample deck at the top: the deck the preview runs on. It starts as a popular deck for the game. Click Change deck and paste a deck code or deck list to preview against your own deck instead; Reset to sample switches back.
- Add a widget: the palette, with one button per widget type.
- Live preview on the right, which reruns as you edit.
The palette names are friendlier than the widget types in the script API:
| Palette button | Widget type | Shows |
|---|---|---|
| Stat | stat | One number, like an average or a count. |
| Curve | histogram | Bars over a number range, like a mana or energy curve. |
| Donut | donut | A ring split by category. |
| Gauge | gauge | A bar split by category. |
| Table | table | Rows and columns: a grouped count, or one row per card. |
| Draw odds | hypergeometric | The chance of drawing at least one (or exactly N) of a group. |
| Ratio | ratio | One count divided by another. |
2. Add a retreat cost curve
- Click Curve. A widget card appears and the preview shows a curve right away.
- Set Title to
Retreat cost. - Set Number field to Retreat cost. Leave Bucket size at
1, Min at0and Max empty (empty means the curve stretches to the highest value in the deck). - Under Filter, click Add condition, then set the condition to Card type · is · Pokémon.
The preview now shows one bar per retreat cost, counting only Pokémon.
Two settings appear on every widget:
- Count: Card copies counts a 3-of three times; Distinct cards counts it once.
- Filter: which cards the widget looks at. With no conditions, every card counts. With two or more conditions, choose whether a card must Match all or any of them. Categorical fields (card type, stage, energy type, regulation mark) offer a dropdown of values; others take free text, and is one of takes a comma-separated list.
3. Add an opening-hand starter table
- Click Table.
- Set Title to
Opening hand starters. - Set Group by to Per card. The table switches to one row per distinct card, and it comes preset with three columns: Card (the card name), Possible starter and Forced starter, each over Cards drawn
7. - Under Filter, click Add condition, then set it to Stage · is · Basic.
The preview now lists each Basic Pokémon with two odds:
- Possible starter: the chance at least one copy is in your opening 7 cards.
- Forced starter: the chance it is your only Basic in those 7 cards, so it has to start. The filter defines what counts as "a Basic": every other card that matches the filter competes with it.
Use Stage, not Subtype, for this filter. Basic Energy cards carry a "Basic" subtype too; Stage only applies to Pokémon, so energy stays out.
Use the arrows on a widget card to reorder widgets and × to remove one.
4. Save it
Leave Make public unchecked for now and click Create. Starter check appears in your list, switched on and marked Custom and Private. Open any Pokémon deck, click the Calculators button, and pick Starter check under Your calculators.
To change it later, click Edit: a calculator made in the builder reopens in the builder with all its widgets.
Peek at the code
View compiled script under the widgets shows the JavaScript the builder generated. You don't need it, but it is the bridge to the next tutorial.
Tutorial: switch to code and add your own widget
The builder covers a lot, but not everything. This tutorial adds a Mulligan chance stat to Starter check: the chance your opening 7 cards hold no Basic Pokémon at all. The builder can show the chance of drawing at least one Basic, but it can't show "1 minus that" as a percentage, so this is a job for code.
1. Open the builder's code
- In Settings → Calculators, click Edit on Starter check.
- Click Drop to code under the widgets.
The widget form is replaced by a code editor holding the generated script, with the live preview beside it. The script looks like this, shortened:
text
// @calculator-spec:v1 {"widgets":[{"kind":"histogram","label":"Retreat cost",...}]}
//
// Generated by the Turny.gg calculator builder. ...
export default function compute(pile, cardsById) {
// One row per distinct card in the deck: `quantity` copies of `card`.
const rows = Object.keys(pile).map((ref) => ({ ... }));
// ---- Shared helpers ----
const toNumber = (value) => { ... };
const filterRows = (test) => rows.filter(test);
const totalWeight = (selection, weightOf) => ...;
const containsValue = (haystack, needle) => { ... };
const roundTo = (value, places) => { ... };
// ...more helpers...
const widgets = [];
// Widget 1: "Retreat cost" (curve histogram)
{ ... widgets.push({ kind: "histogram", ... }); }
// Widget 2: "Opening hand starters" (distinct-card table)
{ ... widgets.push({ kind: "table", ... }); }
return { widgets: widgets };
}What you get to work with:
rows: one entry per distinct card in the deck, withrow.quantity(copies) androw.card(the full card, orundefinedif the catalog doesn't know it).- The shared helpers, such as
filterRows(test)(the rows that pass a test),totalWeight(rows, weightOf)(add up a number per row),containsValue(list, value)androundTo(number, places). widgets: the array every widget block pushes into. The function ends by returning it.
The card fields under row.card.game are different in every game; see the card data reference for Legends of Runeterra, Pokémon TCG and Riftbound.
2. Add the widget
Put the cursor at the start of the line return { widgets: widgets }; near the end of the script and paste this block above it:
js
// Mulligan chance (hand-written)
{
const deckSize = totalWeight(rows, (row) => row.quantity);
const basics = totalWeight(
filterRows(
(row) =>
row.card?.game?.supertype === "Pokemon" &&
containsValue(row.card?.game?.subtypes, "Basic"),
),
(row) => row.quantity,
);
let chance = 0;
if (deckSize >= 7 && deckSize - basics >= 7) {
chance = 1;
for (let i = 0; i < 7; i = i + 1) {
chance = (chance * (deckSize - basics - i)) / (deckSize - i);
}
}
widgets.push({
kind: "stat",
label: "Mulligan chance",
value: roundTo(chance * 100, 1),
unit: "%",
});
}The chance of no Basic in 7 cards is the chance that each draw in turn is a non-Basic: (non-Basics / deck) × (non-Basics − 1) / (deck − 1) × ..., seven times. If there are fewer than 7 non-Basics, a Basic can't be avoided and the chance is 0. The card type check reads "Pokemon" without the accent, which is how the catalog spells it.
The preview shows a third widget, Mulligan chance, under the other two.
3. Delete the marker line, then save
Look at the first line of the script, the one starting with // @calculator-spec:v1. That is the marker: the builder's own settings for the calculator, saved as a comment. It has no effect on how the script runs. The builder reads it to reopen the calculator:
- While the code is still exactly what the builder generates from the marker, Back to builder above the editor works, and you lose nothing by switching back.
- As soon as you change the code, Back to builder turns off with the note "This script has been edited by hand, so it can no longer open in the builder." Undo your edits and it turns back on.
Delete the marker when you edit by hand
If you save hand-edited code with the marker still in it, the next Edit opens the calculator in the builder, rebuilt from the marker. Saving from there replaces your code with the builder's version and your hand edits are lost.
So once you edit the code, delete the whole first line (the // @calculator-spec:v1 ... line) before you click Save changes. A script without a marker always opens in the code editor.
Delete the marker line and click Save changes. Starter check now has three widgets and opens in the code editor from now on. To get back to the builder, make a new calculator.
Starting from scratch
You don't have to start from builder output. New calculator → Drop to code with no widgets gives you an empty generated script to fill in, and Duplicate on a default gives you a hand-written script to adapt. The smallest calculator that works is:
js
export default function compute(pile) {
let total = 0;
Object.keys(pile).forEach((ref) => {
total += pile[ref];
});
return {
widgets: [
{ kind: "stat", label: "Deck size", value: total, unit: "cards" },
],
};
}compute gets two arguments: pile, which maps each card ref in the deck to its number of copies, and cardsById, which maps each of those refs to its card. The calculator script API also documents a third, ctx, with the catalog's globals. Calculators opened on Turny.gg today are called without it, so don't rely on it.
Widget cookbook
One small, complete calculator per widget type, each reading real card fields. Paste one into the code editor to try it, or copy the part you need. Every field of every widget is listed in the widget reference, and the script API page has a game-neutral example of each.
js
// Legends of Runeterra: champion copies in the deck.
export default function compute(pile, cardsById) {
let champions = 0;
Object.keys(pile).forEach((ref) => {
const card = cardsById[ref];
if (card && card.game && card.game.supertypeRef === "Champion") {
champions += pile[ref];
}
});
return {
widgets: [
{ kind: "stat", label: "Champions", value: champions, unit: "copies" },
],
};
}js
// Riftbound: energy curve, with 7 and up in the last bar.
export default function compute(pile, cardsById) {
const labels = ["0", "1", "2", "3", "4", "5", "6", "7+"];
const buckets = [0, 0, 0, 0, 0, 0, 0, 0];
Object.keys(pile).forEach((ref) => {
const card = cardsById[ref];
const energy = card && card.game ? card.game.energy : undefined;
if (typeof energy === "number") {
buckets[Math.min(Math.max(energy, 0), 7)] += pile[ref];
}
});
return {
widgets: [
{
kind: "histogram",
label: "Energy curve",
buckets: buckets,
labels: labels,
},
],
};
}js
// Legends of Runeterra: copies per region.
export default function compute(pile, cardsById) {
const counts = new Map();
Object.keys(pile).forEach((ref) => {
const card = cardsById[ref];
const regions =
card && card.game && Array.isArray(card.game.regionRefs)
? card.game.regionRefs
: [];
regions.forEach((region) => {
counts.set(region, (counts.get(region) || 0) + pile[ref]);
});
});
const segments = [];
counts.forEach((value, region) => {
segments.push({ key: region, label: region, value: value, color: "" });
});
return { widgets: [{ kind: "donut", label: "Regions", segments: segments }] };
}js
// Pokémon TCG: Pokémon / Trainer / Energy split as one bar.
export default function compute(pile, cardsById) {
let pokemon = 0;
let trainers = 0;
let energy = 0;
Object.keys(pile).forEach((ref) => {
const card = cardsById[ref];
const supertype = card && card.game ? card.game.supertype : undefined;
if (supertype === "Pokemon" || supertype === "Pokémon") {
pokemon += pile[ref];
} else if (supertype === "Trainer") {
trainers += pile[ref];
} else if (supertype === "Energy") {
energy += pile[ref];
}
});
return {
widgets: [
{
kind: "gauge",
label: "Deck split",
segments: [
{ key: "pokemon", label: "Pokémon", value: pokemon, color: "" },
{ key: "trainer", label: "Trainer", value: trainers, color: "" },
{ key: "energy", label: "Energy", value: energy, color: "" },
],
},
],
};
}js
// Riftbound: copies per domain, most first.
export default function compute(pile, cardsById) {
const counts = new Map();
Object.keys(pile).forEach((ref) => {
const card = cardsById[ref];
const domains =
card && card.game && Array.isArray(card.game.domains)
? card.game.domains
: [];
domains.forEach((domain) => {
counts.set(domain, (counts.get(domain) || 0) + pile[ref]);
});
});
const segments = [];
counts.forEach((value, domain) => {
segments.push({ key: domain, label: domain, value: value, color: "" });
});
segments.sort((left, right) => right.value - left.value);
return { widgets: [{ kind: "bars", label: "Domains", segments: segments }] };
}js
// Riftbound: units sorted by might.
export default function compute(pile, cardsById) {
const rows = [];
Object.keys(pile).forEach((ref) => {
const card = cardsById[ref];
if (card && card.game && card.game.type === "unit") {
const might = typeof card.game.might === "number" ? card.game.might : 0;
rows.push([card.name, might, pile[ref]]);
}
});
rows.sort((left, right) => right[1] - left[1]);
return {
widgets: [
{
kind: "table",
label: "Units by might",
columns: ["Unit", "Might", "Copies"],
rows: rows,
},
],
};
}js
// Pokémon TCG: odds of at least one Basic Pokémon in the opening 7.
export default function compute(pile, cardsById) {
let deckSize = 0;
let basics = 0;
Object.keys(pile).forEach((ref) => {
const card = cardsById[ref];
const game = card && card.game ? card.game : {};
deckSize += pile[ref];
if (
game.supertype === "Pokemon" &&
Array.isArray(game.subtypes) &&
game.subtypes.indexOf("Basic") !== -1
) {
basics += pile[ref];
}
});
return {
widgets: [
{
kind: "hypergeometric",
label: "Basic Pokémon in opening hand",
population: deckSize,
successCount: basics,
draws: 7,
exactCounts: [1, 2],
},
],
};
}js
// Legends of Runeterra: units per spell.
export default function compute(pile, cardsById) {
let units = 0;
let spells = 0;
Object.keys(pile).forEach((ref) => {
const card = cardsById[ref];
const type = card && card.game ? card.game.typeRef : undefined;
if (type === "Unit") {
units += pile[ref];
} else if (type === "Spell") {
spells += pile[ref];
}
});
return {
widgets: [
{
kind: "ratio",
label: "Units : Spells",
numerator: units,
denominator: spells,
format: "ratio",
},
],
};
}A few things these examples do on purpose:
- Guard every card.
cardsById[ref]can beundefinedwhen the catalog doesn't know a card, so each example checkscard && card.gamebefore reading fields. - Use the English
...Reffields in Legends of Runeterra.typeRef,supertypeRefandregionRefsare the same in every language;typeandsupertypefollow the viewer's language. - Leave
colorempty ("") to let Turny.gg pick segment colors, or set a CSS color like"#3a78e8". - Return data, never markup. Labels and values are drawn as plain text, so HTML in a label shows up as literal text.
Sharing on your profile
Check Make public in the calculator dialog (on create or edit) and save. The calculator's badge changes from Private to Public, and it appears on the Calculators tab of your profile (/users/<your id>/calculators), on the game site it belongs to.
On your profile, each public calculator shows its name, game and last update. Show source expands its full code, so other players can read it and copy it into a new calculator of their own. The profile's Calculators tab only appears to other players once you have at least one public calculator.
Things to know:
- A duplicate always starts private, even when you duplicate a public calculator.
- Uncheck Make public and save to take a calculator off your profile.
- Public means the code is public. Don't put anything in a comment or label you wouldn't want others to read.
Troubleshooting
The preview or the calculator shows an error
The live preview shows the error instead of the dashboard. On a deck, a calculator that fails shows "This calculator could not run. Its script may have an error." Common causes:
| Message | Cause |
|---|---|
| Calculator script source is empty. | The editor is empty. |
| Calculator script did not export a compute function. | No export default function compute(...), no export default function, and no top-level function compute(...). |
A JavaScript error such as Cannot read properties of undefined | The script read a field on a missing card or missing field. Guard with card && card.game (or ?.) first. |
"This calculator script uses APIs or syntax that are not allowed."
Calculators run in the same locked-down sandbox as custom rulesets, and a safety check reads every script before it runs or saves. When it rejects a script, the preview shows an error and Save refuses with this message. The usual suspects:
var(useletorconst),- globals that aren't on the allowed list, such as
fetch,window,globalThisoreval, async,awaitand generator functions,class,this,deleteandimport,newon anything other thanDate,Map,RegExporSet,- property names such as
constructor,prototypeand__proto__, - assigning to a variable you didn't declare.
The full rules, with every allowed global and every rejection code, are on the script sandbox page.
It runs forever, then fails
On a deck, a calculator that runs longer than 5 seconds is stopped and shows the error message. That almost always means a loop that never ends, such as a while whose condition never turns false. The live preview in the editor runs your script directly in the page, so an endless loop there can freeze the tab: reload the page and fix the loop. Calculators only ever see one deck, so a script that finishes quickly on the sample deck will finish quickly everywhere.
Some of my output is missing
Nothing a calculator returns is drawn as-is. It is checked first, and anything that doesn't fit is repaired or dropped instead of breaking the dashboard:
- A widget with an unknown
kindis dropped; the rest still draw. - Numbers that aren't numbers become
0. - A widget with nothing to show (no rows, no positive values, a zero denominator) draws "No data".
- Output past the caps is cut off: at most 50 widgets, 100 segments per donut or gauge, 200 histogram bars, 200 table rows, 20 table columns, and 200 characters per label or table cell.
The exact rules are under How bad output is handled and Limits.
My code edits disappeared
The calculator still had its // @calculator-spec:v1 marker when you saved, so the next Edit reopened it in the builder, and saving from the builder wrote the builder's version back. See Delete the marker line, then save. If you still have your edited code, paste it back, delete the marker line and save.
The builder offers the wrong fields
The fields in the builder come from the game site you are on: Pokémon fields on pokemon.turny.gg, Runeterra fields on lor.turny.gg, and so on. Calculators are per game, so open Settings on the site for the game you are building for.