# Turny.gg Docs > Documentation for Turny.gg: custom rulesets, calculators, card search, tournaments and the deck builder. --- url: https://docs.turny.gg/ description: "Documentation for Turny.gg, the multi-game tournament platform. Custom rulesets, calculators, card search, tournaments and the deck builder." --- # Turny.gg Docs Documentation for Turny.gg, the multi-game tournament platform. Custom rulesets, calculators, card search, tournaments and the deck builder. - [Custom rulesets](https://docs.turny.gg/guide/rulesets): Write deck and deck-set rules in JavaScript and run them on every registration. - [Calculators](https://docs.turny.gg/guide/calculators): Turn any deck into odds, curves and stats with the no-code builder or a script. - [Script reference](https://docs.turny.gg/reference/scripting/rulesets): Every argument, return value and sandbox rule your ruleset and calculator scripts can use. - [Tournaments](https://docs.turny.gg/guide/tournaments): Formats, pairings, standings and tiebreakers, with worked examples checked against the engine. - [Deck builder](https://docs.turny.gg/guide/deck-builder): Deck codes and list formats for every game, piles, deck sets and templates. - [Card search](https://docs.turny.gg/reference/search/lor): Find exactly the cards you want with each game's fields, operators and shortcuts.

Built for AI assistants too

Asking an assistant to help write a ruleset or calculator? Point it at these docs. Every page is also available as plain markdown by adding `.md` to its URL.

--- url: https://docs.turny.gg/guide/ description: "Step-by-step guides for Turny.gg's custom rulesets, calculators, tournaments and deck builder." --- # Guides Guides walk through a feature from start to finish. For the exact shape of every script argument and card field, see the [reference](https://docs.turny.gg/reference/). - [Custom rulesets](https://docs.turny.gg/guide/rulesets): write deck and deck-set rules in JavaScript. - [Calculators](https://docs.turny.gg/guide/calculators): build deck stats with the no-code builder or a script. - [Tournaments](https://docs.turny.gg/guide/tournaments): formats, pairings, standings and tiebreakers. - [Deck builder](https://docs.turny.gg/guide/deck-builder): deck codes, imports, piles and templates. --- url: https://docs.turny.gg/guide/rulesets description: "Write a custom ruleset: card, deck and deck set rules in JavaScript, from a first ban list to pool filters, sharing and troubleshooting." --- # Custom rulesets A custom ruleset is a short JavaScript function that decides whether a card, a deck or a whole lineup is legal. Turny.gg runs it in the deck builder while players build, and again on the server when a deck is submitted to a tournament. If your community plays a format the built-in rules don't cover, a ruleset is how you enforce it. This guide explains when to use each kind of rule, walks through writing your first one, and covers sharing and troubleshooting. You need a Turny.gg account and a little JavaScript. For the exact contract (every argument, return value and limit), see the [ruleset script API](https://docs.turny.gg/reference/scripting/rulesets) and the [script sandbox](https://docs.turny.gg/reference/scripting/sandbox). ## What a ruleset is Every ruleset has a **scope**, which decides what it looks at and when it runs: | Scope | Runs | Use it for | | ------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | Card rule | Once per card | Which cards are legal at all: sets, formats, rarities, a ban list. In the deck builder it also hides illegal cards. | | Deck rule | Once per deck | Anything about a single deck: deck size, copy limits, champion limits, bans that depend on how many copies. | | Deck set rule | Once per lineup, with every deck the player brings | Anything that compares decks: no shared champions, no repeated region pairs, one deck per archetype. | Pick the smallest scope that can answer the question. "Is this card allowed?" is a card rule. "Is this deck allowed?" is a deck rule. Only reach for a deck set rule when the answer depends on more than one deck. A tournament always uses exactly one rule of each scope, and so does the deck builder. Each game ships built-in rules (for Legends of Runeterra, for example: the Standard and Eternal card rules, the Riot constructed deck rule and the Riot lock deck set rule). ::: warning Your rule replaces the built-in one Choosing a custom rule for a scope replaces the built-in rule for that scope; it does not add to it. If your deck rule doesn't check deck size, nothing does. The quickest start is often to **Duplicate** a built-in ruleset and edit the copy. ::: ::: tip Just banning cards? In Legends of Runeterra and Pokémon, a card rule has a **Direct bans** picker. Pick cards from a list and they are banned on top of whatever the script allows, no code needed. ::: ## Create a ruleset and use it Rulesets belong to a game, so create them on that game's site (for example `lor.turny.gg` for Legends of Runeterra). 1. Open **Settings → Custom rulesets** and click **New ruleset**. 2. Enter a **Ruleset name** and pick a **Rule scope**. The **Source code** editor fills in a starter function for that scope. 3. Write your rule, then click **Create**. The script is checked for [unsafe code](https://docs.turny.gg/reference/scripting/sandbox) when you save, and an unsafe script cannot be saved. The list shows your rulesets next to the built-in ones. Built-in and downloaded rulesets are read-only, but you can **View code** or **Duplicate** them. The switch next to each ruleset hides it from every rule picker and legality check without deleting it. **Script API** opens the [ruleset script API](https://docs.turny.gg/reference/scripting/rulesets) reference in a new tab, and the **Card fields** link under each code editor opens the card data reference for the current game. Your enabled rulesets then show up wherever rules are chosen: - **Deck builder:** deck rules in the rules dropdown above the deck, card rules in the **Card ruleset** filter, and deck set rules in **Multi Pile** mode. - **Tournament create:** the **Deck settings** section has one dropdown per scope (**Deck sets**, **Decks**, **Cards**). **View Decks code** (and its siblings) shows the selected script, read-only. ::: info Tournaments keep a copy When you save a tournament, it stores a copy of each selected script. Editing the ruleset later does not change tournaments that already use it. To move a tournament to your latest version, open it (**Administration → Edit tournament**) and save it again: the form reloads the current code of every saved ruleset it uses. ::: ## Tutorial: your first ruleset This tutorial builds a small Legends of Runeterra format called **Two-of Standard**: at most two copies of any card, two banned cards, and no champion shared between a player's decks. Every step works the same way in the other games; only the card fields change (see the card data pages for [Legends of Runeterra](https://docs.turny.gg/reference/card-data/lor), [Pokémon](https://docs.turny.gg/reference/card-data/pokemon) and [Riftbound](https://docs.turny.gg/reference/card-data/riftbound)). ### 1. Write a deck rule Go to **Settings → Custom rulesets → New ruleset**, name it `Two-of Standard`, set **Rule scope** to **Deck rule**, and replace the starter code with: ```js // Deck rule: "Two-of Standard". const BANNED = ["01IO049", "01SI001"]; // Deny, Vengeance const MAX_COPIES = 2; const DECK_SIZE = 40; export default function validateDeck(deck, cardsById) { const issues = []; let total = 0; for (const [ref, copies] of Object.entries(deck)) { const name = cardsById[ref]?.name ?? ref; total += copies; if (BANNED.includes(ref)) { issues.push({ code: "banned_card", message: `${name} is banned.` }); } if (copies > MAX_COPIES) { issues.push({ code: "copy_limit", message: `${name}: at most ${MAX_COPIES} copies (found ${copies}).`, }); } } if (total !== DECK_SIZE) { issues.push({ code: "deck_size", message: `Decks need exactly ${DECK_SIZE} cards (found ${total}).`, }); } return { valid: issues.length === 0, issues }; } ``` What it does: - `deck` maps each card's ref to its number of copies (`{ "01IO009": 3 }`). For Legends of Runeterra the ref is the card code. - `cardsById` holds the catalog entry for every card in the deck, so `cardsById[ref].name` is the card's name. The `?.` guards against a ref the catalog doesn't know. - It collects **every** problem instead of stopping at the first, then returns `valid` plus the list. - It checks the deck size because this rule replaces the built-in deck rule, which used to check it. Click **Create**. ### 2. Try it in the deck builder Open **Deck Builder** and pick **Two-of Standard** in the rules dropdown above the deck. Paste this deck code into **Import deck code** and click **Import**: ```text CEAQCAICBEAREAICAUDAQDAQCEJBKFY2DMOCIJRKFQYTEAIBAEBDI ``` It is 40 Ionia cards with three copies of Zed and two copies of Deny. A warning icon appears next to the card count; hover it to see your messages: - Zed: at most 2 copies (found 3). - Deny is banned. The deck uses cards that are no longer in Standard, so the builder's default **Standard** card rule adds "Only Standard-legal cards can be submitted" lines too. Switch the **Card ruleset** filter to **Eternal** to see only your rule's issues. ### 3. Add a deck set rule Formats with several decks per player usually need a rule across the lineup. Create a second ruleset named `No shared champions`, with **Rule scope** set to **Deck set rule**: ```js // Deck set rule: "No shared champions". export default function validateDeckset(decks, cardsById) { const firstDeckByChampion = new Map(); const issues = []; decks.forEach((deck, index) => { const deckNumber = index + 1; for (const ref of Object.keys(deck)) { const card = cardsById[ref]; if (card?.game?.supertypeRef !== "Champion") { continue; } const firstDeck = firstDeckByChampion.get(ref); if (firstDeck === undefined) { firstDeckByChampion.set(ref, deckNumber); } else { issues.push({ code: "shared_champion", message: `${card.name} is in deck ${firstDeck} and deck ${deckNumber}.`, }); } } }); return { valid: issues.length === 0, issues }; } ``` `decks` is an array with one deck per entry, in the same shape as the deck rule's `deck`. Champions are found through `card.game.supertypeRef`, which is `"Champion"` in every language (the plain `supertype` is translated, so don't compare against it). To try it, click **Multi Pile** in the deck builder, pick **No shared champions** in the deck set dropdown, click **Add deck** for a second deck, and import one of these codes into each deck (both run two copies of Zed): ```text CEAACFABAICQMCAJBQIBCEQVC4NBWHBEEYVCYMRUGUAA CEAAEAIBAIERGAIEAEBAGBAFAYDQUDANBYHRAEISCMKBKFYA ``` The lineup warning reads "Zed is in deck 1 and deck 2." This rule replaces the built-in Riot lock deck set rule, so the lineup is no longer checked for repeated region pairs. Add that check here if your format needs it. The number of decks a player brings is a stage setting (**Decks to bring**), not part of the ruleset. ### 4. Attach it to a tournament 1. Go to **Tournaments → Create tournament** and fill in the basics. 2. In **Deck settings**, set **Deck sets** to `No shared champions`, **Decks** to `Two-of Standard` and **Cards** to `Eternal`. 3. Click **Save as draft** (or create the tournament). The tournament's **Rules** tab now lists both scripts under **Deck rules**, marked **Unverified code** because they are yours rather than built in. Its **Deck Builder** button opens the builder with exactly this tournament's rules, which is the fastest way to check a deck against them. When players submit decks, the server runs the same three rules again and rejects a lineup that fails any of them. ## Reporting good errors Players only see what your rule returns, so the messages are the user interface of your format. - **Return every issue, with a `message`.** The message is shown to the player as written. `code` is a stable, machine-readable id (`copy_limit`, `banned_card`); it is shown only when there is no message, with underscores turned into spaces. - **Name the card and the numbers.** "Zed: at most 2 copies (found 3)." tells the player exactly what to change; "Deck is illegal." does not. - **Don't return a bare `false`.** It fails with a generic fallback ("Saved deck rule rejected this deck.") that doesn't say why. - **Don't number decks in a deck rule.** On submission the server already prefixes each issue with where it came from (`Deck 2: …`, `Lineup: …`, `Card : …`). A deck set rule, which sees every deck at once, should name the decks itself, as the tutorial does. - **Don't throw.** A thrown error is shown as an error instead of your issues, and the script can't create an `Error` object anyway. Return `{ valid: false, issues }` instead. A deck rule written this way reads like a checklist: ```js // Every problem at once, each with a code and a message. export default function validateDeck(deck, cardsById) { const issues = []; const refs = Object.keys(deck); if (refs.length < 10) { issues.push({ code: "too_few_cards", message: `Use at least 10 different cards (found ${refs.length}).`, }); } for (const ref of refs) { if (!cardsById[ref]) { issues.push({ code: "unknown_card", message: `Unknown card ${ref}.` }); } } return { valid: issues.length === 0, issues }; } ``` The full list of accepted return values is on the [ruleset script API](https://docs.turny.gg/reference/scripting/rulesets#return-values) page. ## Pool filters A deck rule can also help players build: an optional `resolvePoolFilters` export sets the deck builder's card filters for the deck being edited. The Riftbound built-in rules use it to show only the domains of the Legend you picked. It never runs on submission and never changes whether a deck is legal. Add this below `validateDeck` in the tutorial's deck rule. Once the deck has cards from exactly two regions, the builder's card pool narrows to those two regions: ```js // Builder only: once the deck has two regions, filter the pool to them. export function resolvePoolFilters(input) { const pile = input.lineup.find( (entry) => entry.pileId === input.activePileId, ); if (!pile) { return null; } const regions = new Set(); for (const ref of Object.keys(pile.cards)) { for (const region of input.cardsById[ref]?.game?.regionRefs ?? []) { // Skip Runeterra champion codes such as "06RU006". if (!/^\d/.test(region)) { regions.add(region); } } } return regions.size === 2 ? { regionRefs: [...regions] } : null; } ``` Import the second deck code from step 3 (Zed plus Piltover & Zaun cards) with this rule selected and the card pool switches to Ionia and Piltover & Zaun. How the builder treats the answer: - **It suggests, it doesn't lock.** The player can change the filters afterwards. The builder only reapplies your suggestion when it changes (a card changes the deck's regions) or when the player switches to another deck. - **Return only the filters you mean to set.** A key you leave out keeps the player's current value. `[]` clears that filter. `null` changes nothing. - **Use the game's filter keys and values.** A key the builder doesn't know is dropped silently, so a typo does nothing. The list filters are: | Game | Keys | | -------------------- | ---------------------------------------------------------------------- | | Legends of Runeterra | `regionRefs`, `typeRefs`, `rarityRefs`, `setCodes`, `keywordVocabRefs` | | Pokémon TCG | `cardTypes`, `types`, `stages`, `rarities`, `setCodes` | | Riftbound | `cardTypes`, `domains`, `rarities`, `sets`, `supertypes`, `tags` | Values are the same refs the cards carry: `"Ionia"` and `"PiltoverZaun"` for regions, lowercase `"unit"` for a Riftbound card type. - **It sees the whole lineup.** `input.lineup` has every deck (pile) in the builder, so a rule can read another pile, such as the Legend pile in Riftbound. Only deck rules are asked; card and deck set rules never are. - **Watch for extra refs.** In Legends of Runeterra some cards also list a Runeterra champion's card code (such as `"06RU006"`) in `regionRefs`, next to their real region. That is why the example skips refs that start with a digit. The input fields are listed under [`resolvePoolFilters`](https://docs.turny.gg/reference/scripting/rulesets#resolvepoolfilters) in the reference. ## Sharing rulesets ### Public and private New rulesets are private: only you can see them. Tick **Make public** in the create or edit dialog to share one. A public ruleset appears on the **Rulesets** tab of your profile, grouped into deck set, deck and card rules, where anyone can read its code. Untick it to take it off your profile; copies other players already saved keep working. ### Saving someone else's ruleset There are two places to pick up another player's ruleset: - **A profile's Rulesets tab:** **Save to my account** adds a copy to your custom rulesets, and **Open in deck builder** tries it without saving. - **A tournament's Rules tab:** **Add to saved rulesets** next to each custom rule adds a copy of the exact version that tournament uses. A saved copy is marked **Downloaded**. It is read-only: you can view its code, **Duplicate** it into an editable ruleset of your own, or **Remove** it from your account, which leaves the original untouched. ::: warning Read before you save Custom rules are code written by other players, and Turny.gg does not review it (the Rules tab labels it **Unverified code**). The [sandbox](https://docs.turny.gg/reference/scripting/sandbox) keeps a script away from your account and the page, but a rule can still be wrong or unfair. Read it first. ::: ### Pinned copies and updates A downloaded copy is pinned to the version you saved. When the author edits their ruleset, your copy keeps running the code you saved; nothing changes until you choose to update. That way a rule can't change under a tournament or a deck you already checked. When a copy saved from a tournament's Rules tab falls behind the author's latest version, it shows **Update available** in your custom rulesets. Click **Update** to switch it to the latest version; the copy also takes the author's current name. Copies saved from a profile don't show updates yet: save the ruleset again from the author's profile to get the newest version. ## Troubleshooting ### "This ruleset script uses APIs or syntax that are not allowed." The safety check rejected the script when you saved it. The message doesn't say which line, so look for the usual causes: | You wrote | Write instead | | ----------------------------------- | ------------------------------------------------------------- | | `var total = 0` | `let total = 0` or `const` | | `console.log(…)` | Remove it, or report the value as an issue while debugging | | `throw new Error("…")` | `return { valid: false, issues: ["…"] }` | | `parseInt(x)` | `Number.parseInt(x, 10)` | | `async` / `await`, classes, `this` | Plain synchronous functions | | `import …` or a second named export | One default export (plus `resolvePoolFilters` in a deck rule) | | `delete obj.key` | Build a new object without the key | ```js // Rejected: variable_kind_unsupported, unsafe_global. export default function validateDeck(deck) { var total = 0; console.log(deck); return total === 0; } ``` The [script sandbox](https://docs.turny.gg/reference/scripting/sandbox) lists everything the check allows and every issue code. ### Every deck fails with "Saved deck rule rejected this deck." Your function returned something that isn't a pass: `false`, `undefined` (a missing `return`), or a result object with no `valid: true`. Return `true` or `{ valid: true }` for a legal deck. See [return values](https://docs.turny.gg/reference/scripting/rulesets#return-values). ### The builder shows an error instead of issues The script threw while it ran. The most common cause is reading a field of a card that isn't there, like `cardsById[ref].game.cost` when the ref is unknown. Guard with `?.` and `??`. Writing to the arguments throws too, since they are frozen: build new objects (`{ ...deck }`) instead of changing `deck`. ### It's slow, or stops with a timeout Each check has a time limit (see [time limits](https://docs.turny.gg/reference/scripting/sandbox#time-limits)). A card rule runs once for every card in the catalog when the builder filters the pool, so keep per-card work small and avoid loops inside loops over the whole deck. ### My ruleset isn't in the dropdown Check that it is switched on in **Settings → Custom rulesets**, that it has the scope that dropdown asks for, and that you're on the site of the game you created it for. ### A tournament still runs my old code Tournaments keep the version they were saved with. Edit the tournament and save it to pick up your latest version (see [Create a ruleset and use it](#create-a-ruleset-and-use-it)). ### "Run rules without the sandbox…" Your browser couldn't start the sandbox that runs rule scripts, so the builder can't check legality and saving is disabled. The builder then offers to run deck rules directly in the page instead, with no isolation. It is an explicit opt-in: it applies only to that browser, expires after 30 days, and never affects the server, which always checks submissions in its own sandbox. Only accept it if you trust the rules you use. --- url: https://docs.turny.gg/guide/calculators description: "Build deck calculators with the no-code builder or a compute script, and share them." --- # 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](https://docs.turny.gg/reference/scripting/calculators). ## 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](https://docs.turny.gg/reference/scripting/calculators#examples-the-default-calculators) 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 1. Go to `pokemon.turny.gg` and sign in. 2. Open **Settings → Calculators** and click **New calculator**. 3. Type `Starter check` in **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 1. Click **Curve**. A widget card appears and the preview shows a curve right away. 2. Set **Title** to `Retreat cost`. 3. Set **Number field** to **Retreat cost**. Leave **Bucket size** at `1`, **Min** at `0` and **Max** empty (empty means the curve stretches to the highest value in the deck). 4. 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 1. Click **Table**. 2. Set **Title** to `Opening hand starters`. 3. 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`. 4. 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. ::: tip 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 1. In **Settings → Calculators**, click **Edit** on Starter check. 2. 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, with `row.quantity` (copies) and `row.card` (the full card, or `undefined` if 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)` and `roundTo(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](https://docs.turny.gg/reference/card-data/lor), [Pokémon TCG](https://docs.turny.gg/reference/card-data/pokemon) and [Riftbound](https://docs.turny.gg/reference/card-data/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. ::: warning 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](https://docs.turny.gg/reference/scripting/calculators) 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](https://docs.turny.gg/reference/scripting/calculators#widget-reference), and the [script API page](https://docs.turny.gg/reference/scripting/calculators#widget-examples) has a game-neutral example of each. ::: code-group ```js [stat] // 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 [histogram] // 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 [donut] // 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 [gauge] // 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 [bars] // 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 [table] // 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 [hypergeometric] // 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 [ratio] // 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 be `undefined` when the catalog doesn't know a card, so each example checks `card && card.game` before reading fields. - **Use the English `...Ref` fields in Legends of Runeterra.** `typeRef`, `supertypeRef` and `regionRefs` are the same in every language; `type` and `supertype` follow the viewer's language. - **Leave `color` empty** (`""`) 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//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` (use `let` or `const`), - globals that aren't on the allowed list, such as `fetch`, `window`, `globalThis` or `eval`, - `async`, `await` and generator functions, - `class`, `this`, `delete` and `import`, - `new` on anything other than `Date`, `Map`, `RegExp` or `Set`, - property names such as `constructor`, `prototype` and `__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](https://docs.turny.gg/reference/scripting/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 `kind` is 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](https://docs.turny.gg/reference/scripting/calculators#how-bad-output-is-handled) and [Limits](https://docs.turny.gg/reference/scripting/calculators#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](#_3-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. --- url: https://docs.turny.gg/guide/tournaments description: "How Turny.gg runs tournaments: the formats each game offers, multi-stage events and top cuts, Swiss pairings, byes, late entry, tiebreakers and free-for-all scoring." --- # Tournaments A Turny.gg tournament is one or more **stages** played in order. Each stage has a format (Swiss, single elimination, and so on) and its own settings, and **advancement rules** decide who moves from one stage to the next. This page describes exactly what the tournament engine does with those settings, so you can predict pairings and standings before your event starts. ## Formats ### What each game offers The create form offers these formats and starts every new stage from these defaults. Everything except the match-win % formula and the two floors can be changed per stage, under the stage's **Advanced settings**. | Setting | Legends of Runeterra | Pokémon TCG | Riftbound | | --- | --- | --- | --- | | Formats | Single elimination, Double elimination, Round robin, Swiss | Single elimination, Double elimination, Round robin, Swiss | Single elimination, Double elimination, Round robin, Swiss, Cumulative Points (Free-for-All), Threshold Win (Free-for-All) | | Swiss: number of rounds | 5 | 5, with a recommendation by player count | 5 | | Swiss: points for a win / draw / loss | 3 / 0 / 0 | 3 / 1 / 0 | 3 / 1 / 0 | | Swiss: allow draws | No | Yes | Yes | | Swiss: bye handling | No byes | Award full points (3) | Award full points (3) | | Swiss: pairing style | Random within score groups | Random within score groups | Random within score groups | | Swiss: prevent repeat matchups | Yes | Yes | Yes | | Swiss: tiebreakers | Buchholz | Opponent match win percentage, then Opponent-opponent match win percentage | Opponent match win percentage, then Game win percentage, then Opponent game win percentage | | Swiss: match-win % formula | Not set | Wins over rounds | Match points | | Swiss: opponent match-win floor | None | 25% | 33% | | Swiss: game-win floor | None | None | 33% | | Round robin: points for a win / draw / loss | 3 / 0 / 0 | 3 / 1 / 0 | 3 / 1 / 0 | | Round robin: tiebreakers | Head-to-head, then Score difference | Head-to-head, then Score difference | Head-to-head, then Score difference | ### When to use each - **Swiss**: the default choice for open events. Every player plays every round, paired against players on the same record, for a fixed number of rounds. Pair it with a top cut when you need a single winner. See [Swiss](#swiss). - **Single elimination**: one loss and you are out. Fast and easy to follow, which makes it the usual top cut after Swiss. - **Double elimination**: a player is out after their second loss, dropping to the losers bracket after the first. It takes about twice as many rounds as single elimination, in exchange for one bad match not ending anyone's day. **Grand finals** is either a **Single set**, or a **Bracket reset**: if the losers-bracket player wins the first set, a second set decides the title, since both players then have one loss. - **Round robin**: everyone plays everyone, once or twice (**Round robin cycles**). The fairest format for small groups, but the number of rounds grows with the field. For a big field, split it with **Grouping** (a number of groups, or players per group) under the stage's **Advanced settings**, which every one-on-one format offers. - **Free-for-all** (Riftbound only): three or more players per game, scored by finishing position. See [Free-for-all scoring](#free-for-all-scoring). For single elimination, **Allow byes** is off by default. With it off, the stage only starts when the player count fills the bracket exactly (4, 8, 16 and so on); turn it on to give the top seeds byes into round two. **Hold third-place match** adds a match between the two semifinal losers. The **Seeding method** of an elimination stage (and the **First-round seeding** of a Swiss stage) is one of **Manual**, **Random**, **Rating** or **Prior stage results**. A tournament's first stage defaults to **Random**; any later stage defaults to **Prior stage results**, so the best finishers of the previous stage get the best seeds. ## Multi-stage events and top cut Add a second stage and an advancement rule to turn a Swiss stage into "Swiss plus a top 8". Each rule reads: - **From stage**: the stage whose results it uses. - **Condition**: a **Placement range** (for example 1 to 8) or a **Points range** (for example 9 points or more). - **Action**: **Advance** to a later stage, or **Eliminate**. A classic top cut is one rule: from the Swiss stage, placements 1 to 8, **Advance** to a single-elimination stage seeded by **Prior stage results**. Swiss 1st then plays 8th, 2nd plays 7th, and so on. Advancement uses the earlier stage's final standings, so it happens once that stage is complete. **Cut tiebreaker** decides what happens when players tied on points and on every tiebreaker straddle the cut line (for example 8th and 9th are exactly equal): - **Better initial seed advances** (the default): the cut stays at exactly the size you set. - **Draw lots**: a random draw picks who advances; the cut stays the same size. - **Advance the whole tie**: everyone in the tie advances, so the next stage can get more players than planned. Give it room, for example single elimination with **Allow byes** on. Two options only appear when they apply: - **Allow early cutoff** (a single or double elimination stage, with a placement range starting at 1): the stage stops as soon as the players in the range are decided, instead of playing on to a single winner. Use it for a day-one bracket that only needs to find a top 8. - **Advance mode: Continue brackets** (double elimination into double elimination): the next stage carries on the same bracket where the first one stopped, so every player keeps their winners or losers bracket position. **Reset bracket** (the default) starts a fresh bracket. A tournament is either all one-on-one stages or all free-for-all stages; the two cannot be mixed in one event. ## Swiss ### Pairing Round 1 orders players by the stage's **First-round seeding**, then pairs them by the **Pairing style**. From round 2 on, the engine: 1. Orders players by the current standings: points, then the stage's tiebreakers, then initial seed. 2. Splits them into score groups: everyone on the same points. 3. Pairs each score group from the top. If a group has an odd number of players, its lowest-ranked player drops down and is paired in the next group. **Pairing style** decides who meets whom inside a score group: - **Random within score groups** (the default): random pairings within the group, as in most trading card game events. - **Fold (top half vs bottom half)**: the top half of the group plays the bottom half, 1st against the first player of the bottom half, and so on, as in chess. - **Adjacent standings order**: 1st plays 2nd, 3rd plays 4th, and so on. With **Prevent repeat matchups** on (the default), a pairing that would repeat an earlier match is replaced by the rematch-free pairing that keeps players closest to their own score group. If no rematch-free pairing exists at all, the next round cannot be generated and the organizer sees an error; turn **Prevent repeat matchups** off for that stage, or change its pairing style, both of which stay editable while the stage runs. In a very large, late round the search can give up, and then the engine uses the plain pairing even if it contains a rematch. A Swiss stage ends after its **Number of rounds**. ### Choosing a round count The engine never picks the round count for you: the stage plays exactly the **Number of rounds** you set (default 5). For Pokémon TCG, the create form also suggests the Play! Pokémon handbook's count for your **Maximum participants**, with an **Apply** button. As a rule of thumb, with no draws, `n` rounds leave at most one undefeated player out of `2^n`. That count finds a clear winner without a top cut. With a top cut, you can often stop a round or two sooner, since the cut decides the winner. | Players | Rounds for one undefeated player | Pokémon TCG recommendation | | --- | --- | --- | | 4 | 2 | 3 | | 5 to 8 | 3 | 3 | | 9 to 12 | 4 | 4 | | 13 to 16 | 4 | 5 | | 17 to 32 | 5 | 5 | | 33 to 64 | 6 | 6 | | 65 to 128 | 7 | 7 | | 129 to 226 | 8 | 8 | | 227 to 256 | 8 | 9 | | 257 to 409 | 9 | 9 | | 410 to 512 | 9 | 10 | | 513 to 1024 | 10 | 10 | ### Byes With an odd number of players, one player sits out each round. It is the lowest-ranked player who has not had a bye yet; a player only gets a second bye once everyone has had one. Byes are not opponents: they never count toward Buchholz, opponent win percentages or any other opponent-based tiebreaker. **Bye handling** decides what sitting out is worth: - **Award full points**: the bye counts as a match win worth **Points per bye**. - **Award zero points** or **No byes**: the player still sits out, but gets nothing: no points, and the round is not added to their record. ### Late registrants With **Late entry** on (Swiss only), players can still join an event that has started, up to and including the round set in **Allow late entry through round**. Every round already completed when they join counts as a loss for them: they get the points for a loss (0 by default) and the loss appears in their record, but with no opponent, so it adds nothing to anyone's tiebreakers. It is not a bye, so they can still receive a bye later. They are paired from the next round on, like anyone else on their points. ## Tiebreakers Standings rank players by points first. Players on the same points are compared by each applied tiebreaker in order (the **Applied in order** list in the stage's tiebreaker settings), and if they are still equal, the better initial seed ranks higher. Players equal on points and on every tiebreaker share a placement. Percentages are rounded to four decimal places (0.01%) before they are compared. | Tiebreaker | Value | Available in | How it is computed | | --- | --- | --- | --- | | **Head-to-head** | `head_to_head` | Round robin, Swiss | Among players tied on points, the points each one earned in matches against the others in that tie. A player with no one else on their points total scores 0. | | **Score difference** | `score_difference` | Round robin, Swiss | Games won minus games lost, summed over every match played. | | **Game win percentage** | `game_win_percentage` | Round robin, Swiss | Games won divided by games played. In Swiss, a game-win floor set by the game raises any lower value to the floor. A player with no games played scores 0 (or the floor). | | **Points scored** | `points_scored` | Round robin, Swiss | Total games won, summed over every match played. | | **Buchholz** | `buchholz` | Swiss | The sum of the current points of every opponent played. Byes add nothing. | | **Median Buchholz** | `median_buchholz` | Swiss | Buchholz without the single highest and single lowest opponent. With two or fewer opponents nothing is dropped, so it equals Buchholz. | | **Sonneborn-Berger** | `sonneborn_berger` | Swiss | The current points of every opponent the player beat, plus half the points of every opponent they drew with. Losses add nothing. | | **Opponent match win percentage** | `opponent_match_win_percentage` | Swiss | The average of each opponent's match-win percentage (OMW%). Each opponent's value comes from the stage's win-percentage formula and is raised to the game's floor first, if it has one. Byes are not opponents. | | **Opponent-opponent match win percentage** | `opponent_opponent_match_win_percentage` | Swiss | The average OMW% of the player's opponents (OOMW%), weighted by how many opponents each of them has played. That is the average match-win percentage of the opponents' opponents. | | **Opponent game win percentage** | `opponent_game_win_percentage` | Swiss | The average of each opponent's game-win percentage (OGW%), each raised to the game-win floor first, if the game has one. Byes are not opponents. | ### Worked example Five players play a three-round Swiss with 3 points for a win, 1 for a draw and 0 for a loss, byes worth full points, and best-of-three matches. Scores are games won. | Round | Player | Score | Opponent | | ----- | ------ | ----- | -------- | | 1 | Ana | 2-0 | Ben | | 1 | Cam | 2-1 | Dee | | 1 | Eli | Bye | | | 2 | Ana | 2-1 | Cam | | 2 | Eli | 2-0 | Ben | | 2 | Dee | Bye | | | 3 | Ana | 1-1 | Dee | | 3 | Cam | 2-0 | Eli | | 3 | Ben | Bye | | The final standings, with every tiebreaker computed and Head-to-head applied first. Records are wins, losses and draws, and a bye counts as a win. | Rank | Player | Record | Points | Head-to-head | Score difference | Game win % | Points scored | | ---- | ------ | ------ | ------ | ------------ | ---------------- | ---------- | ------------- | | 1 | Ana | 2-0-1 | 7 | 0 | +3 | 71.43% | 5 | | 2 | Cam | 2-1-0 | 6 | 3 | +2 | 62.5% | 5 | | 3 | Eli | 2-1-0 | 6 | 0 | 0 | 50% | 2 | | 4 | Dee | 1-1-1 | 4 | 0 | -1 | 40% | 2 | | 5 | Ben | 1-2-0 | 3 | 0 | -4 | 0% | 0 | | Player | Buchholz | Median Buchholz | Sonneborn-Berger | OMW% | OOMW% | OGW% | | ------ | -------- | --------------- | ---------------- | ------ | ------ | ------ | | Ana | 13 | 4 | 11 | 50% | 71.43% | 34.17% | | Cam | 17 | 6 | 10 | 66.67% | 57.14% | 53.81% | | Eli | 9 | 9 | 3 | 50% | 70% | 31.25% | | Dee | 13 | 13 | 3.5 | 75% | 58.33% | 66.96% | | Ben | 13 | 13 | 0 | 75% | 50% | 60.71% | The opponent percentages above use no win-percentage formula and no floors (the Legends of Runeterra defaults). Working a few of them through: - **Head-to-head**: Cam and Eli are tied on 6 points and met in round 3, which Cam won. Cam scores the 3 points from that match and Eli scores 0, so Cam ranks above Eli. Ana is alone on 7 points and scores 0. - **Score difference**: Ana won 2-0, 2-1 and drew 1-1: 5 games won, 2 lost, so +3. - **Game win %**: Ana won 5 of her 7 games: 71.43%. Eli won 2 of 4 (the bye adds no games): 50%. - **Points scored**: Ana won 2 + 2 + 1 = 5 games, and so did Cam (2 + 1 + 2). - **Buchholz**: Cam played Dee (4 points), Ana (7) and Eli (6): 4 + 7 + 6 = 17. Eli played only Ben (3) and Cam (6), since the bye is not an opponent: 9. - **Median Buchholz**: Cam drops his best opponent (Ana, 7) and his worst (Dee, 4), leaving Eli's 6. Eli has only two opponents, so nothing is dropped and he keeps 9. In a small event with byes this can favor the players who had one. - **Sonneborn-Berger**: Ana beat Ben (3) and Cam (6) and drew with Dee (half of 4 is 2): 3 + 6 + 2 = 11. Dee's only result that counts is his draw with Ana: half of 7 is 3.5. - **OMW%**: with no formula set, a match-win % is wins plus half of draws over matches, with a full-points bye counting as a won match. Ben (1-2-0) has 33.33%, Cam (2-1-0) 66.67% and Dee (1-1-1) 50%, so Ana's OMW% is (33.33% + 66.67% + 50%) / 3 = 50%. - **OOMW%**: Ana's opponents have OMW% of 75% (Ben, 2 opponents), 66.67% (Cam, 3 opponents) and 75% (Dee, 2 opponents). Weighted by opponents played: (75% × 2 + 66.67% × 3 + 75% × 2) / 7 = 71.43%. - **OGW%**: Ana's opponents won 0% (Ben), 62.5% (Cam) and 40% (Dee) of their games: (0% + 62.5% + 40%) / 3 = 34.17%. ### Match-win formulas and floors OMW% and OOMW% average the opponents' **match-win percentages**, and how a match-win % is worked out from a record is set per game: | Formula | Value | How it is computed | Default for | | --- | --- | --- | --- | | **Not set** | | Wins plus half of draws, divided by matches played. A bye that awards full points counts as a won match. | Legends of Runeterra | | **Match points** | `match_points` | Match points divided by the points for a win times the matches played (Magic Tournament Rules, Appendix C). With 3 points for a win and 1 for a draw, a draw is worth a third of a win. A bye that awards full points counts as a won match. | Riftbound | | **Wins over rounds** | `wins_over_rounds` | Wins divided by rounds played (Play! Pokémon Tournament Rules Handbook, section 5.3.3.1). A draw counts as zero wins, and a bye counts as neither a win nor a round played. | Pokémon TCG | Each player's own match-win % in the worked example, under each formula: | Player | Not set | Match points | Wins over rounds | | ------ | ------- | ------------ | ---------------- | | Ana | 83.33% | 77.78% | 66.67% | | Ben | 33.33% | 33.33% | 0% | | Cam | 66.67% | 66.67% | 66.67% | | Dee | 50% | 44.44% | 0% | | Eli | 66.67% | 66.67% | 50% | The formulas disagree most about byes and draws. Under wins over rounds, Ben's only win was his bye, so he has won 0 of his 2 rounds played; Dee drew and lost his two rounds, also 0%. A **floor** raises any opponent's match-win % below it to the floor before averaging, so a player is not punished too hard for opponents who lost every match or dropped early. The game-win floor does the same for Game win % and OGW%. Pokémon TCG uses a 25% opponent floor, and Riftbound uses 33% for both. With each game's defaults, the OMW% in the worked example becomes: | Player | Pokémon TCG | Riftbound | | ------ | ----------- | --------- | | Ana | 38.89% | 48.15% | | Cam | 47.22% | 62.96% | | Eli | 45.83% | 50% | | Dee | 66.67% | 72.22% | | Ben | 58.33% | 72.22% | For Ana under the Pokémon TCG defaults, Ben and Dee both have 0% and are raised to 25%, so her OMW% is (25% + 66.67% + 25%) / 3 = 38.89%. Without the floor it would be 22.22%. ## Free-for-all scoring Riftbound offers two free-for-all formats. Players are split into **lobbies** (pods), and every match awards **Placement points** by finishing position in the lobby. - **Cumulative Points**: every lobby plays a fixed **Number of matches**, and final standings are total points. Players on the same total share a placement. - **Threshold Win**: matches continue until a player reaches **Points to win** _and_ finishes at the **Required finish** or better in the match where they get there. If several players qualify in the same match, the one with the most points wins. Set **Maximum matches** to cap the stage; if no one has won by then, **If no one wins** decides it (**Highest points wins**). These are the Riftbound defaults for a new free-for-all stage: | Setting | Riftbound | | --- | --- | | Players per lobby | 4 | | Placement points | 1st: 3, 2nd: 2, 3rd: 1, 4th: 0 | | Tie scoring | Competition | | Cumulative Points (Free-for-All): number of matches | 3 | | Threshold Win (Free-for-All): points to win | 6 | | Threshold Win (Free-for-All): required finish | 1st or better | With those defaults, a win is worth 3 points and the threshold is 6, so a player needs at least two wins to take the stage. In this four-player pod, Ana reaches 7 points in match 3 but finishes 2nd, so she has not won yet. Ben wins match 4 with 9 points; Ana also has 9, but finished 2nd, so Ben takes the stage. | Match | Ana | Ben | Cam | Dee | Result | | ----- | --- | --- | --- | --- | ------------- | | 1 | 1st | 2nd | 3rd | 4th | No winner yet | | 2 | 2nd | 1st | 3rd | 4th | No winner yet | | 3 | 2nd | 3rd | 1st | 4th | No winner yet | | 4 | 2nd | 1st | 3rd | 4th | Ben wins | **Tie scoring** decides what tied players in a lobby earn. **Competition** (the default) gives each of them the points for the tied place: two players tied for 2nd both get 2nd-place points. **Split** shares the points of the places the tie covers: two players tied for 2nd each get the average of the 2nd- and 3rd-place points. **Elimination points**, if set, add points for each opponent a player eliminates. Lobbies are formed by **Lobby seeding** (initial seed, current standings or random) and **Lobby distribution** (snake by default), and **Reshuffle lobbies** decides whether they are redrawn between matches. --- url: https://docs.turny.gg/guide/deck-builder description: "Import and export decks in every format the deck builder reads, organize them into piles and templates, and read its validation messages." --- # Deck builder The deck builder reads the deck codes and text lists players already share, splits a deck into piles when the game has zones, and checks the deck against your rulesets as you build. This page covers every import and export format per game, how piles and templates work, and what the builder's validation messages mean. ## Importing and exporting To import, paste a deck code or a deck list into an empty deck's **Import a deck** box. The builder tries each format its game accepts, in the order listed below, and loads the first one that reads. A list line it cannot read, or a card it cannot find, is skipped and listed in a warning under the box, so nothing disappears silently. To export, open **Deck Preview** (the eye button). **Share** copies a link to the deck's page (`//decks/`) built from the game's Turny.gg deck code. Next to it are **Copy deck list** and, for Riftbound, **Copy deck code**. A shared link opens without an account, and **Open in deck builder** on the deck's page loads it back into the builder. The **Maybeboard** pile is never exported. Whether a sideboard keeps its place depends on the format; the notes under each game say which formats carry one. ### Legends of Runeterra The builder reads the standard Legends of Runeterra deck code, the same code the game client and other LoR sites use. ```text CEAQ2AIAAEDASCYMCQKRUJZNFY2TOAABAEAQABY ``` - **Share** links to the deck code, and **Copy deck list** copies the code itself. - The builder also opens a code from its address: `/en/deck-builder/`. - LoR has no sideboard. If a pile is marked **Sideboard**, its cards are added to the shared code with the rest of the deck. ### Pokémon TCG The builder reads two formats, tried in this order: 1. **Turny.gg deck code** (`PTCG1`). Pokémon TCG has no official deck code, so Turny.gg uses its own compact, link-safe one. Cards are grouped by set, and each entry is `x`, or just `` for a single copy. ```text PTCG1.me55:4x66,4x128.sv8:2x76.sv8pt5:4x105.sve:2x4,4x5 ``` 2. **Pokémon TCG Live deck list**: the plain-text list the game client exports, one ` ` line per card. ```text Pokémon: 6 4 Mew ex 30C 66 2 Latias ex SSP 76 Trainer: 8 4 Crispin PRE 105 4 Ultra Ball 30C 128 Energy: 6 4 Basic {P} Energy SVE 5 2 Basic {L} Energy SVE 4 ``` The import is forgiving. Section headers, blank lines and a `Total Cards: 60` line are ignored, and `4x` works as well as `4`. Set codes are the Pokémon TCG Live abbreviations (`PAL`, `SSP`) and are not case-sensitive. Basic Energy lines may leave out the set and number, as Pokémon TCG Live does (`4 Basic {P} Energy` or `4 Basic Psychic Energy`). A list that lost its line breaks on the way (a common phone paste problem) is split back into lines when every card in it can be found. - **Share** links to the `PTCG1` code. **Copy deck list** copies a Pokémon TCG Live list, which pastes straight into the game client. - The builder also opens a code from its address: `/en/deck-builder/`. - Neither format has a sideboard. If a pile is marked **Sideboard**, its cards are added to the shared code with the rest of the deck. ### Riftbound The builder reads four formats, tried in this order: 1. **Turny.gg deck code** (`RIFT1`). The same grammar as `PTCG1`, with upper-case set codes. The Legend, Runes and Battlefields need no zone of their own, since a card's type decides its zone. The sideboard follows an `SB` segment at the end. ```text RIFT1.OGN:6x042-298,3x043-298,3x045-298,3x064-298,6x166-298,205-298,259-298,282-298,289-298,293-298.SB.OGN:2x050-298 ``` 2. **Piltover Archive deck code**: the community code that [Piltover Archive](https://piltoverarchive.com), Rift Atlas and Tabletop Simulator read. Cards resolve by set and collector number, so any printing of a card loads it. ```text CQAAAAAAAAAACAQFAAAQEAIFAAAACAYAAAACWABNABAAAAIFAAAABTIBACBQEAE2AIAKCAQAUUBAAAIBAAAAAMQAAA ``` 3. **Piltover Archive deck list**: Piltover Archive's plain-text list, with cards named but not numbered. A `Champion:` section is read as part of the main deck, and `Sideboard:` goes to the sideboard. ```text Legend: 1 Yasuo, Unforgiven MainDeck: 3 Charm 3 Defy 3 Wind Wall 1 Yasuo, Windrider Battlefields: 1 Monastery of Hirana 1 Targon's Peak 1 The Grand Plaza Runes: 6 Calm Rune 6 Chaos Rune Sideboard: 2 Rune Prison ``` Names match with either subtitle separator, so `Yasuo, Unforgiven` and `Yasuo - Unforgiven` are the same card. 4. **Turny.gg deck list**: the ` ` list Turny.gg exported before it switched to Piltover Archive's list. It still imports, so older lists keep working, and any printing's set and number loads the card. ```text Main Deck (10) 3 Charm OGN 43 3 Defy OGN 45 3 Wind Wall OGN 64 1 Yasuo - Windrider OGN 205 Runes (12) 6 Calm Rune OGN 42 6 Chaos Rune OGN 166 Legend (1) 1 Yasuo - Unforgiven OGN 259 Battlefields (3) 1 Monastery of Hirana OGN 282 1 Targon's Peak OGN 289 1 The Grand Plaza OGN 293 ``` The four samples above are the same deck; the Turny.gg deck list leaves out the sideboard because it has no place for one. - **Share** links to the `RIFT1` code, sideboard included. - **Copy deck list** copies a Piltover Archive list, sideboard included. - **Copy deck code** copies a Piltover Archive code. For now it puts sideboard cards in the main deck, so use **Share** or **Copy deck list** to keep a sideboard. - `RIFT1`, the Piltover Archive code and the Piltover Archive list all carry a sideboard, and on import it lands in the **Sideboard** pile. In a single-deck layout (**Pile View** off) there is no sideboard pile, so the builder leaves the sideboard out and tells you how many cards it skipped. - Pasting into any pile's **Import a deck** box sorts the cards into the Legend, Rune Deck, Battlefields, Main Deck and Sideboard piles by type, so there is no need to paste into each pile. ## Piles and deck sets A simple deck is one list of cards. Turn on **Multi Pile** to split the builder into several **piles**, and **Pile View** to lay them out side by side in columns. Riftbound opens in Pile View because a Riftbound deck has zones; Legends of Runeterra and Pokémon start as a single deck. ### Pile settings Each pile's settings (the gear on the pile) hold: - **Deck name**: an optional label for the pile. - **Deck rule**: the rule that checks this pile on its own. See [Zone rules and the whole-deck rule](#zone-rules-and-the-whole-deck-rule). - **Role**: at most one of these, or none for an ordinary pile. - **Sideboard**: the pile is the side deck. Its cards are checked as the sideboard, stay out of the main deck count, and go in the deck code's sideboard where the format has one. - **Maybeboard**: a scratch pile. Its cards stay visible but never count toward any rule and are never exported. - **Command zone**: only for games with a commander. - **Placement**: the pile's column and its order in it. Dragging the pile's handle does the same. The pile's panel menu sets its **View options**: the summary strip, the curve and the footer. ### Single Deck or Multi deck With several piles, the header asks what they are: - **Single Deck**: the piles are zones of one deck, like Riftbound's Legend, Rune Deck, Battlefields and Main Deck. The rule next to the toggle checks the whole deck, and the piles save as one deck. - **Multi deck**: every pile is a separate deck, like a lineup of three decks for a best-of-three event. The rule next to the toggle is a **deck set rule** that checks the lineup, and the piles save together as a **deck set**. ### Templates A **template** is a saved builder layout: which piles exist, each pile's deck rule and role, where each pile sits, what each pile shows, which pile the builder opens on, and a card ruleset. A template never holds cards. - **Turny templates** are built in. Riftbound's **Constructed** template opens on the Legend pile, since the Legend decides which domains the rest of the deck can use: | Pile | Role | Checks | | ------------ | ---------- | ------------------------ | | Legend | Main | exactly one Legend | | Rune Deck | Main | 12 runes | | Battlefields | Main | the Battlefields | | Main Deck | Main | the 40-card main deck | | Sideboard | Sideboard | the side deck | | Maybeboard | Maybeboard | nothing; it never counts | Legends of Runeterra and Pokémon have no built-in template yet. - **Switch template** swaps in another layout. It replaces every pile, cards included, so save first if you want to keep the deck. Picking a deck set rule that a template was built for offers to switch to that template. - **Save as Template** saves your own layout. It is offered while every pile is empty, and needs a deck set rule chosen. Saving again with the same name updates that template, and **Also save the card ruleset** pins the current card ruleset to it. - Manage your templates under **Settings**, **Templates**: rename them, make them public or private, copy a link, hide Turny templates you don't use, or delete your own. Public templates appear on your profile, and a template's link opens the builder with its piles and rules. A template's card ruleset is either **Recommended** or **Pinned**. A Turny template recommends one (Riftbound's recommends Standard): the builder uses it until you pick a card ruleset for that game yourself, and after that leaves your choice alone. A template saved with **Also save the card ruleset** pins it, so that ruleset applies every time the template loads. The active pile also steers the card search. A pile's rule can suggest filters for its zone: the Legend pile shows Legends, and once a Legend is in, the Main Deck pile shows cards in that Legend's domains. You can change or clear the filters at any time. ## Zone rules and the whole-deck rule A multi-pile deck is checked at two levels: - **Zone rules**, one per pile, each checking only its own pile. Riftbound's Legend pile checks for exactly one Legend, the Rune Deck pile for 12 runes, the Main Deck pile for 40 cards. - **The whole-deck rule**, chosen next to the **Single Deck** toggle, which checks the merged deck as a unit. **Riftbound constructed** checks all the zones together, including that your champion matches your Legend and that the sideboard holds at most 10 cards. In a **Multi deck** layout this is the deck set rule instead. On top of both, the **card ruleset** decides which cards are legal at all, for example a format's legal sets and banned cards. ### Reading validation messages - **On a pile**: the pile's warning icon lists its zone rule's messages and any card-level issues in it. - **For the whole deck**: the builder's warning list repeats every pile's messages, prefixed with the pile's name (`Main Deck: The main deck must contain exactly 40 cards.`), followed by the whole-deck or deck set rule's messages without a prefix. - **On a card**: a warning marker on a card names the problem with that card. "This card is directly banned by the selected ruleset." means the card ruleset bans it. - **When a rule can't run**: if a rule fails to load, or your browser can't start the sandbox that runs rules, the builder says so rather than showing the deck as legal, and saving is turned off until rules can run. A message comes from the rule that raised it. The built-in rules explain themselves; for a custom rule, see [Custom rulesets](https://docs.turny.gg/guide/rulesets) and the [ruleset script reference](https://docs.turny.gg/reference/scripting/rulesets). ## Tips - **Calculators**: the calculator button in the builder header opens stats for the current deck, such as draw odds. See [Calculators](https://docs.turny.gg/guide/calculators). - **Search syntax**: the card search box takes a query language as well as card names, for example `t:unit cost<=3`. Each game has its own fields: [Legends of Runeterra](https://docs.turny.gg/reference/search/lor), [Pokémon TCG](https://docs.turny.gg/reference/search/pokemon), [Riftbound](https://docs.turny.gg/reference/search/riftbound). - **Saving**: **Save** needs you to be signed in. Deck names are unique on your account, so saving with an existing name replaces that deck. Private decks stay out of public areas until you share them, and the save dialog lists the rule warnings the deck still has. - **Reusing a saved deck**: in a multi-pile layout, **Use a saved deck** loads one of your saved decks into a pile. With **Sync** on, the pile stays linked to it: changes to the saved deck show up in the pile, and saving the deck set writes the pile back to the saved deck. - **Sharing a layout**: to share how you build rather than what you built, make a template public and share its link. --- url: https://docs.turny.gg/reference/ description: "Generated reference for Turny.gg scripting, card data and card search, kept in sync with the code that runs the site." --- # Reference Reference pages are generated from Turny.gg's source code, so they always match what the site runs. ## Scripting - [Ruleset script API](https://docs.turny.gg/reference/scripting/rulesets): what a custom ruleset script receives and returns. - [Script sandbox](https://docs.turny.gg/reference/scripting/sandbox): what scripts can and cannot do. - [Calculator script API](https://docs.turny.gg/reference/scripting/calculators): `compute` and every widget it can return. ## Card data The fields a script can read on each game's cards: - [Legends of Runeterra](https://docs.turny.gg/reference/card-data/lor) - [Pokémon TCG](https://docs.turny.gg/reference/card-data/pokemon) - [Riftbound](https://docs.turny.gg/reference/card-data/riftbound) ## Card search Search syntax for each game: - [Legends of Runeterra](https://docs.turny.gg/reference/search/lor) - [Pokémon TCG](https://docs.turny.gg/reference/search/pokemon) - [Riftbound](https://docs.turny.gg/reference/search/riftbound) ## Script kinds What a saved script is for. | Member | Value | Description | | --- | --- | --- | | `DeckValidation` | `"deck_validation"` | A custom ruleset's deck-settings script: validates decks and deck sets. | | `StatDashboard` | `"stat_dashboard"` | A calculator's `compute` script: turns a deck into dashboard widgets. | --- url: https://docs.turny.gg/reference/scripting/rulesets description: "Reference for custom ruleset scripts: entry points, arguments, context and return values." --- # Ruleset script API A custom ruleset can carry up to three scripts, one per scope: a **card** rule, a **deck** rule and a **deck set** rule. Each is a small JavaScript module that Turny.gg runs in the [script sandbox](https://docs.turny.gg/reference/scripting/sandbox) while a deck is built and again when it is submitted. For a walkthrough, start with the [custom rulesets guide](https://docs.turny.gg/guide/rulesets). This page is the exact contract. ## Entry points Each script provides one function. The runner looks for it in this order: 1. **The default export.** `export default function validateDeck(deck) { … }` or `export default (deck) => …`. The function's own name does not matter. 2. **The scope's named fallback.** With no default export, a top-level function (or `const`) with the name below, for example a plain `function validateDeck(deck) { … }`. Do not `export` it by name: the only named export allowed is `resolvePoolFilters`. If neither exists, the script fails with "did not export a function". The default export wins when both are present. | Scope | Named fallback | When it runs | | --- | --- | --- | | Card | `validateCard` | Called once per card, both to validate a submitted card and to filter the deck builder's card pool. | | Deck | `validateDeck` | Called once per deck (or builder pile) with every card the deck references. | | Deck set | `validateDeckset` | Called once with the whole lineup of decks a player submits. | ```ts // Card is the game's card object; see the card data reference. function validateCard(card: Card, context?: DeckSettingsScriptContext): DeckSettingsScriptResult; function validateDeck(deck: DeckSettingsScriptDeck, cardsById: Record, context?: DeckSettingsScriptContext): DeckSettingsScriptResult; function validateDeckset(decks: DeckSettingsScriptDeck[], cardsById: Record, context?: DeckSettingsScriptContext): DeckSettingsScriptResult; function resolvePoolFilters(input: DeckSettingsPoolFiltersInput, context?: DeckSettingsScriptContext): DeckSettingsPoolFiltersPatch; ``` `context` is always the last argument and always optional, so a script written as `validateDeck(deck, cardsById)` keeps working. ### Card scope Called once per card. In the deck builder the card rule also filters the card pool: a card that fails is hidden. A failing card on a submitted deck blocks the submission. ```js // Only cards from two sets are legal. const LEGAL_SETS = ["set1", "set2"]; export default function validateCard(card) { return LEGAL_SETS.includes(card.setCode); } ``` ### Deck scope Called once per deck (in the builder, once per pile) with the deck's card quantities and the catalog entries for every card it references. ```js export default function validateDeck(deck, cardsById) { const total = Object.values(deck).reduce((sum, copies) => sum + copies, 0); if (total !== 40) { return { valid: false, issues: [ { code: "deck_size", message: `Decks need exactly 40 cards (found ${total}).`, }, ], }; } return true; } ``` ### Deck set scope Called once with every deck in the player's lineup. ```js // No card may appear in more than one deck of the lineup. export default function validateDeckset(decks, cardsById) { const seen = new Set(); const issues = []; for (const deck of decks) { for (const ref of Object.keys(deck)) { if (seen.has(ref)) { const name = cardsById[ref]?.name ?? ref; issues.push({ code: "duplicate_card", message: `${name} is in more than one deck.`, }); } seen.add(ref); } } return { valid: issues.length === 0, issues }; } ``` ## Arguments Every argument is a deep copy of plain data, frozen before your script sees it. Writing to it throws, so build new objects instead (`{ ...deck }`, `Object.entries(deck)`). ### `card` and `cardsById` `card` is one catalog entry. `cardsById` maps each card ref in the deck (or lineup) to its catalog entry. Every card has `id`, `name` and `setCode`; the rest is per game: - [Legends of Runeterra card data](https://docs.turny.gg/reference/card-data/lor) - [Pokémon TCG card data](https://docs.turny.gg/reference/card-data/pokemon) - [Riftbound card data](https://docs.turny.gg/reference/card-data/riftbound) ### `deck` The `deck` a deck-scope script receives. Usually the flat quantity map; a zone-aware payload nests zones under `main` / `sideboard`. A flat map may also carry a nested `sideboard` record, so read zones defensively and never treat the literal `main` / `sideboard` keys as card refs. #### Zoned deck Zone-aware deck payload (e.g. MTG submissions): card refs nested under `main`, with an optional `sideboard`. | Property | Type | Description | | --- | --- | --- | | `main` | `DeckSettingsScriptCardQuantities` | Main-deck card refs → copies. | | `sideboard?` | `DeckSettingsScriptCardQuantities` | Sideboard card refs → copies, when the game has one. | For Legends of Runeterra, Pokémon TCG and Riftbound the deck is the flat map. A helper that reads the main deck from either shape: ```js function mainDeck(deck) { if (deck.main && typeof deck.main === "object") { return deck.main; } const main = {}; for (const [ref, copies] of Object.entries(deck)) { if (typeof copies === "number") { main[ref] = copies; } } return main; } ``` ### `context` Optional catalog context handed to every entry point as its last argument. Always additive: scripts written against the shorter signature keep working, and scripts that read it must tolerate `context` being `undefined` (no catalog globals loaded, inline validation paths). | Property | Type | Description | | --- | --- | --- | | `globals` | `unknown` | The game's catalog globals document (region / keyword / set lookup tables). Its shape is per game; see the card data reference. | The fields of `globals` for each game are on the card data pages linked above. ## Return values What an entry point may return. The runner normalizes it to `{ valid, issues }`; any other value (a number, a string, an array) counts as a failure with the scope's fallback message. | Value | Result | | --- | --- | | `true` | Passes with no issues. | | `false`, `null`, `undefined` | Fails with the scope's fallback message (e.g. "Saved deck rule rejected this deck."). A missing `return` is `undefined`, so it fails too. | | `DeckSettingsScriptResultObject` | Passes when `valid` or `allowed` is exactly `true`, keeping any `issues`. Otherwise fails with `issues`, or the fallback message when there are none. | #### Result object The object form of a result. | Property | Type | Description | | --- | --- | --- | | `valid?` | `boolean` | `true` passes. Anything else (missing, `false`, truthy non-booleans) fails. | | `allowed?` | `boolean` | Alias of `valid`: the result passes when either is exactly `true`. | | `issues?` | `DeckSettingsScriptIssue[]` | Issues to report. Non-array values are ignored; entries that are neither a string nor a usable object are dropped. | #### Issues One issue: a plain message string, or an object with a `code` and/or `message`. An issue reported as an object. At least one of `code` / `message` must be a non-empty string, or the issue is dropped. | Property | Type | Description | | --- | --- | --- | | `code?` | `string` | Stable machine-readable id, e.g. `deck_size`. Shown (underscores as spaces) when there is no `message`. | | `message?` | `string` | Human-readable text shown to the player. Trimmed. | | `metadata?` | `DeckSettingsScriptIssueMetadata` | Scalar details for the UI. Kept by the in-browser validator only; non-scalar entries are dropped. | ### How issues are shown - **In the browser**, each issue shows its `message`, or its `code` with underscores as spaces when there is no message. - **On submission**, the server prefixes each issue with where it came from: `Card : …` for the card rule, `Deck 2: …` for the deck rule (decks are numbered from 1) and `Lineup: …` for the deck set rule. - A passing result can still carry issues. They are returned alongside the pass and do not make the check fail. ```js export default function validateDeck(deck) { const issues = []; if (Object.keys(deck).length < 10) { issues.push("Use at least 10 different cards."); } if (!Object.keys(deck).some((ref) => ref.startsWith("champ_"))) { issues.push({ code: "missing_champion" }); } return { valid: issues.length === 0, issues }; } ``` ## `resolvePoolFilters` A deck rule may also `export function resolvePoolFilters(input, context)`. The deck builder calls it to suggest the card-pool filters for the pile being edited; it never runs on submission and has no effect on validity. - **Deck scope only.** The builder only asks deck rules. The export is allowed by the safety check anywhere, but card and deck set scripts never have it called. - **An omitted key means no suggestion** for that filter, and the user's current value stays. - **`[]` is a suggestion of "nothing selected"** and clears that filter. Choose between omitting and `[]` deliberately. - **Keys must be the game's own filter keys** (for Riftbound, `cardTypes` and `domains`). Unknown keys are dropped, so a typo silently does nothing. Values must match what the catalog emits, such as Riftbound's lowercase card types. - **Returning `null`** (or anything that is not a plain object) makes no suggestion at all. - The hook sees the **whole lineup**, so a rule can read another pile, for example the Legend in the Legend pile. ```js export default function validateDeck(deck) { return true; } export function resolvePoolFilters(input, context) { const active = input.lineup.find( (pile) => pile.pileId === input.activePileId, ); if (active && active.role === "sideboard") { return null; } return { cardTypes: ["unit", "spell", "gear"] }; } ``` #### Input First argument of `resolvePoolFilters`. `filters` is the game's own filter model (e.g. `RiftboundCardFiltersModel` keys for Riftbound); the hook answers with a partial patch in that same vocabulary, or `null`. | Property | Type | Description | | --- | --- | --- | | `lineup` | `DeckSettingsPoolFilterLineupPile[]` | Every pile in the builder, the active one included. | | `activePileId` | `string` | The pile whose card pool is being filtered. | | `cardsById` | `Record` | Catalog entries for every card in the lineup. | | `filters` | `Record` | The builder's current filter model. | #### Lineup pile One pile of the builder lineup handed to `resolvePoolFilters`. The hook sees the whole lineup, not just the active pile, so e.g. a Main Deck rule can find the Legend sitting in the Legend pile. | Property | Type | Description | | --- | --- | --- | | `pileId` | `string` | Builder pile id; compare with `activePileId`. | | `name` | `string` | Display name of the pile. | | `role` | `DeckSettingsPoolFilterPileRole \| null` | Builder role, or `null` for a pile with no special role. | | `deckRuleId` | `string \| null` | Null when the pile runs with no deck rule (unresolvable template rule). | | `cards` | `Record` | The pile's card refs → copies. | Pile roles: `"main"`, `"sideboard"`, `"maybeboard"`, `"commander"`. Builder role of a lineup pile as seen by `resolvePoolFilters`. `commander` is the command-zone pile, so a hook can find commanders by role rather than by the pile's deck rule id (which the user may clear or a template may leave unset). #### Patch What `resolvePoolFilters` may answer: a partial filter-model patch, or `null` for no suggestion. An omitted key leaves that filter alone; `[]` clears it. Keys outside the game's filter model are dropped; any non-object answer counts as `null`. ## See also - [Script sandbox](https://docs.turny.gg/reference/scripting/sandbox): allowed globals, rejected syntax, time limits and safety issue codes. - [Custom rulesets guide](https://docs.turny.gg/guide/rulesets) --- url: https://docs.turny.gg/reference/scripting/sandbox description: "What custom scripts can and cannot do: the sandbox, its limits and the static safety checks." --- # Script sandbox [Ruleset scripts](https://docs.turny.gg/reference/scripting/rulesets) and [calculator scripts](https://docs.turny.gg/reference/scripting/calculators) share one sandbox. Both go through the same static safety check and run in the same kind of isolated runtime, so everything on this page applies to both. ## How a script runs 1. **Static safety check.** The source is parsed as a JavaScript module and every statement and expression is checked against the allowlists below. Any issue rejects the whole script and nothing runs. Rulesets are checked when they are saved, so an unsafe ruleset cannot be saved. Calculators are checked before every run. 2. **A fresh sandbox per run.** Each run gets a new [SES](https://github.com/endojs/endo/tree/master/packages/ses) `Compartment` with no extra globals. The script sees only the frozen JavaScript built-ins and the arguments it is called with. Nothing carries over between runs, and one script cannot see another. 3. **Plain, frozen inputs.** Arguments are rebuilt as plain objects and arrays (no prototypes, no functions) and deep-frozen. Writing to them throws. 4. **Checked output.** What the script returns is normalized before anything uses it: ruleset results as described under [return values](https://docs.turny.gg/reference/scripting/rulesets#return-values), calculator dashboards by the calculator renderer's own limits. Scripts run in two places: - **In the browser**, inside a Web Worker: the deck builder, card legality checks and calculators. If the sandbox worker cannot start on a device, the site offers (only after the failure) an opt-in to run deck rules outside the sandbox on that device. The opt-in expires after 30 days. - **On the server**, when a deck is submitted: a separate worker process re-runs the card, deck and deck set rules. A tournament's banned cards are rejected before any script runs. Because a script is parsed as a module, it is always in strict mode. ## Allowed globals Globals a script may reference. Every other free identifier is rejected. | Global | `new` allowed | | --- | --- | | `Array` | | | `Boolean` | | | `Date` | Yes | | `Infinity` | | | `JSON` | | | `Map` | Yes | | `Math` | | | `NaN` | | | `Number` | | | `Object` | | | `RegExp` | Yes | | `Set` | Yes | | `String` | | | `undefined` | | The only constructors `new` may call (and only when not shadowed locally). Everything else is rejected as `unsafe_global`, including some common names: - `Error` (`new Error(…)` fails as `new_expression_unsupported`): to fail a check, return `false` or `{ valid: false, issues }` instead of throwing. - `parseInt` / `parseFloat`: use `Number.parseInt` / `Number.parseFloat`. - `console`, `Promise`, `Symbol`, `WeakMap`, `globalThis`, `eval`, `Function`, `fetch` and timers. ```js // Passes: only allowed globals, let/const and plain functions. export default function validateDeck(deck) { const copies = new Map(Object.entries(deck)); const most = Math.max(0, ...copies.values()); return most <= Number.parseInt("3", 10); } ``` ## Blocked properties Property names a script may not read or write, as `obj.name` or `obj["name"]`. They reach prototypes, constructors or the call stack. `__defineGetter__`, `__defineSetter__`, `__lookupGetter__`, `__lookupSetter__`, `__proto__`, `arguments`, `callee`, `caller`, `constructor`, `prototype` The check only sees names written literally (`card.constructor`, `card["constructor"]`). A computed key such as `card[key]` is not checked statically; the frozen runtime is what contains it. ```js // Rejected: property_access_blocked. export default function validateCard(card) { return card.constructor === Object; } ``` ## Exports A script has one default export. See [entry points](https://docs.turny.gg/reference/scripting/rulesets#entry-points) for how the runner finds the function. The only named exports a script may carry beside its default: the optional deck-scope pool-filter hook `export function resolvePoolFilters(input, context)`. Re-exports, `export { … }` lists, exported variables and any other exported function name stay rejected. Allowed named exports: `resolvePoolFilters`. ## Rejected syntax The syntax rules are written into the checker itself (`packages/game-adapters/src/tournament-create/script-safety.ts`): the statement and expression visitors (`visitStatement`, `visitExpression`) reject anything not on their list, plus dedicated checks for `var` (`visitVariableDeclaration`), `async` and generators (`visitFunctionLike`) and getters and setters (`visitObjectExpression`). Rejected: - `import` declarations, dynamic `import()` and `import.meta`. - `export * from …`, `export { … }` lists, exported variables and any named export except `resolvePoolFilters`. - Classes (declarations and expressions), `this` and `super`. - `var`. Use `let` or `const`. - `async` functions, `await`, generator functions and `yield`. - `delete`. - Labeled statements and `debugger`. - Getters and setters in object literals (`{ get total() { … } }`). - Tagged templates. Plain template literals are fine. - Assigning to anything the script did not declare, such as a global. - `new` on anything but `Date`, `Map`, `RegExp` or `Set`. - Anything the parser rejects in strict mode, such as `with` or legacy octal literals (`010`). These fail as `invalid_syntax`. ```js // Rejected: variable_kind_unsupported, unsafe_global. export default function validateDeck(deck) { var total = 0; fetch("https://example.com"); return total === 0; } ``` ```js // Rejected: new_expression_unsupported (Error is not a constructor you may call). export default function validateDeck(deck) { throw new Error("Not allowed"); } ``` ```js // Rejected: async_unsupported, syntax_unsupported. export default async function validateDeck(deck) { return this.check(deck); } ``` ## Time limits | Where | Limit | | --- | --- | | Browser: one card, deck or deck set check | 2s | | Browser: a card rule over the whole card pool | 15s | | Browser: first catalog download before any check | 45s | | Browser: one calculator run | 5s | | Server: one deck submission check (all scopes) | 15s | | Server: the queue job around that check | 30s | A script that runs past its limit (an endless loop, say) is stopped and the check reports an error instead of a pass. Keep scripts linear in the size of the deck or card pool: a card rule runs once for every card in the catalog when the builder filters the pool. ## Safety issue codes Each rejection carries one of these codes. | Member | Value | Description | | --- | --- | --- | | `AsyncUnsupported` | `"async_unsupported"` | An `async` function or an `await` expression. | | `GeneratorUnsupported` | `"generator_unsupported"` | A generator function (`function*`) or a `yield` expression. | | `InvalidSyntax` | `"invalid_syntax"` | The source is empty or does not parse as an ES module. | | `NewExpressionUnsupported` | `"new_expression_unsupported"` | `new` on anything other than an allowed constructor (`Date`, `Map`, `RegExp`, `Set`), or on a local that shadows one. | | `PropertyAccessBlocked` | `"property_access_blocked"` | Reading or writing a blocked property name such as `constructor`, `prototype` or `__proto__`. | | `SyntaxUnsupported` | `"syntax_unsupported"` | Disallowed syntax: imports, extra exports, classes, `this`, `delete`, labels, `debugger`, getters/setters, tagged templates, `import()` / `import.meta`, `super`. | | `UnsafeAssignment` | `"unsafe_assignment"` | Assigning to a name the script did not declare (a global or an undeclared variable). | | `UnsafeGlobal` | `"unsafe_global"` | Referencing a free identifier that is not on the allowed globals list (e.g. `globalThis`, `eval`, `fetch`). | | `VariableKindUnsupported` | `"variable_kind_unsupported"` | A `var` declaration. Use `let` or `const`. | At most 12 issues are reported per script. The check stops at this many issues, so fixing them can reveal more. --- url: https://docs.turny.gg/reference/scripting/calculators description: "Reference for calculator compute scripts: arguments, every dashboard widget they can return, output limits, and the default calculators as examples." --- # Calculator script API A calculator is a small JavaScript function that turns a deck into a dashboard: a mana curve, a type breakdown, opening-hand odds. It returns **data plus a widget type, never markup**. Turny.gg draws each widget with its own trusted components, which is what makes it safe to show a calculator someone else wrote. The no-code builder produces the same kind of script, so everything here also applies to a builder calculator you open as code. For a walkthrough of building and sharing calculators, see the [calculators guide](https://docs.turny.gg/guide/calculators). ## The contract A calculator script defines one function, `compute`, and returns a dashboard: ```js export default function compute(pile, cardsById, ctx) { return { widgets: [{ kind: "stat", label: "Hello", value: "world" }], }; } ``` Turny.gg finds the function in one of three ways, in this order: 1. `export default function compute(...) { ... }` (any name, or none, works here). 2. `export default `, where the expression is a function. 3. A top-level function declared as `function compute(...) { ... }`. The script runs in the same sandbox as custom rulesets: no network, no timers, no `async`, and only a short list of built-in globals. See [Script sandbox](https://docs.turny.gg/reference/scripting/sandbox) for exactly what is allowed. A script the safety check rejects never runs. ### Arguments | # | Argument | Type | Description | | --- | --- | --- | --- | | 1 | `pile` | `Record` | The deck being viewed: card ref to number of copies. Refs are the keys of `cardsById`. | | 2 | `cardsById` | `Record` | Every card in the pile, by ref. Game-specific fields live on `card.game`; see the card data reference for your game. | | 3 | `context` | `CalculatorScriptContext \| undefined` | Optional catalog context (fields below). May be `undefined`: a script that reads it must work without it. | `CalculatorScriptContext`: | Property | Type | Description | | --- | --- | --- | | `globals` | `unknown` | The game catalog's globals document (region, keyword and set lookup tables). Its shape is per game. | Things to know about the arguments: - **They are read-only.** `pile`, `cardsById` and `ctx` are frozen copies. Build new arrays and objects instead of changing them. - **A card can be missing.** Guard `cardsById[ref]` before reading from it; the default calculators treat a missing card as having no fields. - **Card fields are per game.** Everything game-specific lives on `card.game`. See the card data reference for [Legends of Runeterra](https://docs.turny.gg/reference/card-data/lor), [Pokémon TCG](https://docs.turny.gg/reference/card-data/pokemon) and [Riftbound](https://docs.turny.gg/reference/card-data/riftbound). - **`ctx` may be absent.** Any run can call `compute` without it, including the editor preview. Check it before use: ```js export default function compute(pile, cardsById, ctx) { const hasGlobals = ctx !== undefined && ctx.globals !== undefined; return { widgets: [ { kind: "stat", label: "Catalog globals", value: hasGlobals ? "Yes" : "No", }, ], }; } ``` ### Return value `compute` returns an object with a `widgets` array. Widgets are drawn in array order. Each widget is a plain object whose `kind` picks how it is drawn; the rest of its fields are the data for that kind. ## Widget examples Every widget type with a minimal working `compute`: ::: code-group ```js [stat] 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" }, ], }; } ``` ```js [histogram] export default function compute(pile) { // Distinct cards run as 1-ofs, 2-ofs and 3+-ofs. const buckets = [0, 0, 0]; Object.keys(pile).forEach((ref) => { const copies = Math.min(Math.max(pile[ref], 1), 3); buckets[copies - 1] += 1; }); return { widgets: [ { kind: "histogram", label: "Copies per card", buckets: buckets, labels: ["1", "2", "3+"], }, ], }; } ``` ```js [donut] export default function compute(pile) { let singles = 0; let multiples = 0; Object.keys(pile).forEach((ref) => { if (pile[ref] === 1) { singles += 1; } else { multiples += 1; } }); return { widgets: [ { kind: "donut", label: "Singles vs multiples", segments: [ { key: "singles", label: "1 copy", value: singles, color: "#3a78e8" }, { key: "multiples", label: "2+ copies", value: multiples, color: "" }, ], }, ], }; } ``` ```js [gauge] export default function compute(pile) { let singles = 0; let multiples = 0; Object.keys(pile).forEach((ref) => { if (pile[ref] === 1) { singles += pile[ref]; } else { multiples += pile[ref]; } }); return { widgets: [ { kind: "gauge", label: "Copies in singles vs multiples", segments: [ { key: "singles", label: "Singles", value: singles, color: "" }, { key: "multiples", label: "Multiples", value: multiples, color: "" }, ], }, ], }; } ``` ```js [table] export default function compute(pile, cardsById) { const rows = Object.keys(pile) .sort((left, right) => pile[right] - pile[left]) .slice(0, 5) .map((ref) => { const card = cardsById[ref]; const name = card && typeof card.name === "string" ? card.name : ref; return [name, pile[ref]]; }); return { widgets: [ { kind: "table", label: "Most copies", columns: ["Card", "Copies"], rows: rows, }, ], }; } ``` ```js [hypergeometric] export default function compute(pile) { // Odds of drawing at least one copy of your most-played card in 7 cards. let deckSize = 0; let mostCopies = 0; Object.keys(pile).forEach((ref) => { deckSize += pile[ref]; mostCopies = Math.max(mostCopies, pile[ref]); }); return { widgets: [ { kind: "hypergeometric", label: "Top card in opening hand", population: deckSize, successCount: mostCopies, draws: 7, exactCounts: [1, 2], }, ], }; } ``` ```js [ratio] export default function compute(pile) { let copies = 0; Object.keys(pile).forEach((ref) => { copies += pile[ref]; }); return { widgets: [ { kind: "ratio", label: "Copies per card", numerator: copies, denominator: Object.keys(pile).length, format: "multiplier", }, ], }; } ``` ::: ## Widget reference | kind | Description | | --- | --- | | `"stat"` | A single headline value in a stat tile (e.g. "Average cost: 3.4"). | | `"histogram"` | A bar chart: one bar per bucket (e.g. a mana curve). Bars pair with `labels` by index. | | `"donut"` | A donut (pie) chart of labelled, coloured segments. | | `"gauge"` | A semicircular meter: segments fill a half-circle in proportion to their values. Same payload as `donut`, drawn as a gauge. | | `"bars"` | Horizontal bars, one per segment (e.g. regions, domains, mana sources). | | `"table"` | A table of rows under column headers. | | `"hypergeometric"` | A closed-form hypergeometric draw-odds widget: the probability of drawing successes when taking `draws` cards, without replacement, from a `population` of which `successCount` match a filter (e.g. "odds of opening at least one basic in a 7-card hand"). The renderer computes and shows P(X>=1); when `exactCounts` is given it also shows P(X=k) for each listed k in a small table. | | `"ratio"` | A derived index number: `numerator / denominator`, formatted per `format`. Renders as a stat tile. Guards against a zero denominator. | ### `kind: "stat"` A single headline value in a stat tile (e.g. "Average cost: 3.4"). | Property | Type | Description | | --- | --- | --- | | `label` | `string` | Title shown on the widget. | | `labelKey?` | `string` | Optional i18n key under `calculators.widgets.*`; falls back to `label`. | | `value` | `number \| string` | The value. A finite number is formatted in the viewer's locale (`Intl.NumberFormat`); a string is shown verbatim (e.g. `"12.5%"`). | | `unit?` | `string` | Optional suffix shown after the value (e.g. `"cards"`). | ### `kind: "histogram"` A bar chart: one bar per bucket (e.g. a mana curve). Bars pair with `labels` by index. | Property | Type | Description | | --- | --- | --- | | `label` | `string` | Title shown on the widget. | | `labelKey?` | `string` | Optional i18n key under `calculators.widgets.*`; falls back to `label`. | | `buckets` | `number[]` | Bar heights, left to right. | | `labels` | `string[]` | One axis label per bucket, same order as `buckets`. | ### `kind: "donut"` A donut (pie) chart of labelled, coloured segments. | Property | Type | Description | | --- | --- | --- | | `label` | `string` | Title shown on the widget. | | `labelKey?` | `string` | Optional i18n key under `calculators.widgets.*`; falls back to `label`. | | `vocabulary?` | `string` | Optional `CalculatorVocabulary` id resolving segment labels and colors per game. | | `segments` | `DeckStatSegment[]` | Slices, drawn in order; zero-value slices are skipped. | ### `kind: "gauge"` A semicircular meter: segments fill a half-circle in proportion to their values. Same payload as `donut`, drawn as a gauge. | Property | Type | Description | | --- | --- | --- | | `label` | `string` | Title shown on the widget. | | `labelKey?` | `string` | Optional i18n key under `calculators.widgets.*`; falls back to `label`. | | `vocabulary?` | `string` | Optional `CalculatorVocabulary` id resolving segment labels and colors per game. | | `segments` | `DeckStatSegment[]` | Arcs, drawn in order; zero-value arcs are skipped. | ### `kind: "bars"` Horizontal bars, one per segment (e.g. regions, domains, mana sources). | Property | Type | Description | | --- | --- | --- | | `label` | `string` | Title shown on the widget. | | `labelKey?` | `string` | Optional i18n key under `calculators.widgets.*`; falls back to `label`. | | `vocabulary?` | `string` | Optional `CalculatorVocabulary` id resolving segment labels and colors per game. | | `segments` | `DeckStatSegment[]` | Bars, drawn in order. | ### `kind: "table"` A table of rows under column headers. | Property | Type | Description | | --- | --- | --- | | `label` | `string` | Title shown on the widget. | | `labelKey?` | `string` | Optional i18n key under `calculators.widgets.*`; falls back to `label`. | | `columns` | `string[]` | Column headers, left to right. | | `columnKeys?` | `string[]` | Optional i18n keys under `calculators.widgets.*`, parallel to `columns`. | | `rows` | `(string \| number)[][]` | Rows of cells; each row pairs with `columns` by index. | ### `kind: "hypergeometric"` A closed-form hypergeometric draw-odds widget: the probability of drawing successes when taking `draws` cards, without replacement, from a `population` of which `successCount` match a filter (e.g. "odds of opening at least one basic in a 7-card hand"). The renderer computes and shows P(X>=1); when `exactCounts` is given it also shows P(X=k) for each listed k in a small table. | Property | Type | Description | | --- | --- | --- | | `label` | `string` | Title shown on the widget. | | `labelKey?` | `string` | Optional i18n key under `calculators.widgets.*`; falls back to `label`. | | `population` | `number` | Population size: the pile / deck size drawn from. | | `successCount` | `number` | Successes in the population: cards matching the filter. | | `draws` | `number` | Number of cards drawn (e.g. the opening-hand size). | | `exactCounts?` | `number[]` | Optional exact-count targets. When present, the renderer adds a P(X=k) row per listed k alongside the headline P(X>=1) figure. | ### `kind: "ratio"` A derived index number: `numerator / denominator`, formatted per `format`. Renders as a stat tile. Guards against a zero denominator. | Property | Type | Description | | --- | --- | --- | | `label` | `string` | Title shown on the widget. | | `labelKey?` | `string` | Optional i18n key under `calculators.widgets.*`; falls back to `label`. | | `numerator` | `number` | Top of the fraction. | | `denominator` | `number` | Bottom of the fraction. A zero shows the no-data placeholder. | | `format?` | `CalculatorRatioFormat` | How to format the quotient. Defaults to `number`. | ### Segments A `donut` slice or `gauge` arc (`DeckStatSegment`): | Property | Type | Description | | --- | --- | --- | | `key` | `string` | Stable identifier for the segment (e.g. `"spells"`). | | `label` | `string` | Legend text. | | `value` | `number` | The segment's size; segments are drawn in proportion to their values. | | `color` | `string` | CSS color (e.g. `"#3a78e8"`). Empty picks a fallback palette color. | ### Ratio formats | Format | Shows | | --- | --- | | `"number"` | The raw quotient to two decimals (e.g. `1.83`). | | `"percent"` | The quotient as a percentage to one decimal (e.g. `65.0%`). | | `"multiplier"` | The quotient to two decimals with a multiplier sign (e.g. `1.83×`). | | `"ratio"` | The pair reduced to lowest terms (e.g. `11:6`). | ## How bad output is handled A calculator's return value is never trusted. Before anything is drawn it passes through a checker (`normalizeCalculatorResult`) that repairs or drops whatever does not fit the shapes above. The checker never throws, so a buggy script produces a smaller dashboard, not a crash: | Your script returns | What is drawn | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | Anything other than an object with a `widgets` array | An empty dashboard | | A widget that is not an object, or has an unknown `kind` | That widget is dropped; the rest are kept | | A missing or non-text `label` | An empty label | | A number field (`value` of a segment, `buckets`, `population`, `numerator`, ...) | Converted with `Number()`; `NaN`, `Infinity` and non-numbers become `0` | | A text field set to a number or boolean | Converted to text; any other value (object, `null`) becomes empty text | | A `stat` `value` | Kept as a number if it is one (non-finite becomes `0`), otherwise text | | A `table` cell | Kept as a number if it is one, otherwise text; a row that is not an array is dropped | | A segment that is not an object | That segment is dropped | | `unit`, `exactCounts` or `format` of the wrong type (or an unknown `format`) | That optional field is left out | | Extra properties on a widget | Dropped | | More entries or longer text than the [limits](#limits) allow | Cut to the limit | An error **thrown** by your script, a syntax error, or a safety-check rejection is different: the calculator shows an error message instead of a dashboard. A widget with nothing to show draws a "No data" placeholder instead of an empty chart: a `stat` whose value is empty text, a `histogram` or `donut`/`gauge` with no positive values, a `table` with no columns or no rows, a `hypergeometric` with a `population` of `0` or less, or a `ratio` with a `denominator` of `0`. ## Limits Hard caps `normalizeCalculatorResult` applies to guest output. Guest code is untrusted: these clamp the blast radius of a hostile or buggy script on the trusted renderer. Output past a cap is truncated (extra entries dropped, strings cut), never rejected. | Cap | Limit | Applies to | | --- | --- | --- | | `maxWidgets` | 50 | Widgets kept per dashboard; later widgets are dropped. | | `maxSegments` | 100 | Segments kept per `donut`, `gauge` or `bars` widget. | | `maxHistogramBuckets` | 200 | Entries kept in a `histogram`'s `buckets` and in its `labels`. | | `maxTableRows` | 200 | Rows kept per `table`. | | `maxTableColumns` | 20 | Entries kept in a `table`'s `columns`, and cells kept per row. | | `maxLabelLength` | 200 | Characters kept in every short string: widget labels and label keys, `stat` string values and units, segment keys, labels and colors, histogram labels and table column headers and column keys. | | `maxCellLength` | 200 | Characters kept in a string `table` cell. | | `maxExactCounts` | 50 | Entries kept in a `hypergeometric` widget's `exactCounts`. | **Time limit:** a run that takes longer than **5 seconds** is stopped and the calculator shows an error instead of a dashboard. ## Builder-generated scripts A calculator made with the no-code builder is an ordinary script whose first line is a marker comment holding the builder's settings: ```text // @calculator-spec:v1 {"widgets":[...]} ``` The marker is only a comment, so it has no effect on how the script runs. The builder uses it to re-open the calculator: - **Drop to code** switches a builder calculator to the code editor. - While the code is still exactly what the builder generates from the marker (checked by rebuilding it and comparing byte for byte), you can switch back to the builder. - Once you edit the code by hand, the rebuilt code no longer matches and the calculator is **detached** from the builder: it stays a code calculator. Undoing your edits so the code matches again re-enables switching back. To make sure hand edits are never replaced by the builder's version, **delete the marker line** before saving. A script without a readable marker always opens in the code editor. ## Examples: the default calculators Every game ships default calculators, and they are ordinary calculator scripts. The code below is pulled from the source the site runs, so it is always current. Copy one as a starting point for your own. ::: code-group ```js [Legends of Runeterra] const MANA_CURVE_LABELS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "+"]; // Card-type keys in display order, with English fallback labels. const TYPE_LABELS = { champions: "Champion", followers: "Follower", spells: "Spell", landmarks: "Landmark", equipment: "Equipment", }; export default function compute(pile, cardsById) { const deck = normalizePile(pile); const catalog = isRecord(cardsById) ? cardsById : {}; // Cards missing from the catalog can't be classified, so they count nowhere. const entries = Object.keys(deck) .filter((cardId) => isRecord(catalog[cardId])) .map((cardId) => { const card = catalog[cardId]; const game = cardGame(card); return { card, game, quantity: deck[cardId], cost: cardCost(game), regionRefs: cardRegionRefs(game) }; }) .sort(compareDeckCards); const deckRegions = detectDeckRegions(entries); const manaBuckets = Array.from({ length: 11 }, () => 0); const typeCounts = { champions: 0, followers: 0, spells: 0, landmarks: 0, equipment: 0 }; const rarityCounts = new Map(); const regionCounts = new Map(); let costTotal = 0; let cardTotal = 0; entries.forEach((entry) => { const game = entry.game; const quantity = entry.quantity; manaBuckets[Math.min(entry.cost, 10)] += quantity; costTotal += entry.cost * quantity; cardTotal += quantity; // Categorize by the canonical (English) type ref, falling back to the // localized value for catalogs published before the refs existed. const type = game.typeRef ?? game.type; const supertype = game.supertypeRef ?? game.supertype; if (type === "Unit") { if (supertype === "Champion") { typeCounts.champions += quantity; } else { typeCounts.followers += quantity; } } else if (type === "Spell") { typeCounts.spells += quantity; } else if (type === "Landmark") { typeCounts.landmarks += quantity; } else if (type === "Equipment") { typeCounts.equipment += quantity; } const rarityRef = typeof game.rarityRef === "string" ? game.rarityRef : "Unknown"; rarityCounts.set(rarityRef, (rarityCounts.get(rarityRef) ?? 0) + quantity); // A card counts toward the first deck region it carries, so counts sum to // the deck size even for multi-region cards; one fitting none is skipped. const region = deckRegions.find((candidate) => entry.regionRefs.indexOf(candidate) !== -1); if (region !== undefined) { regionCounts.set(region, (regionCounts.get(region) ?? 0) + quantity); } }); // Regions keep deck-region order; empty ones drop out. const regionSegments = deckRegions .filter((region) => (regionCounts.get(region) ?? 0) > 0) .map((region) => ({ key: region, label: region, value: regionCounts.get(region), color: "" })); const typeSegments = Object.keys(TYPE_LABELS) .filter((key) => typeCounts[key] > 0) .map((key) => ({ key, label: TYPE_LABELS[key], value: typeCounts[key], color: "" })); // Most copies first; ties keep card order. const raritySegments = []; rarityCounts.forEach((value, key) => { if (value > 0) { raritySegments.push({ key, label: key, value, color: "" }); } }); raritySegments.sort((left, right) => right.value - left.value); // Mean mana cost across the deck, weighted by copies, to one decimal. const averageCost = cardTotal > 0 ? Math.round((costTotal / cardTotal) * 10) / 10 : 0; // Units (champions + followers) against spells, as a reduced pair. const unitCount = typeCounts.champions + typeCounts.followers; const spellCount = typeCounts.spells; // Like the deck page, a breakdown with nothing to show is left out. const widgets = []; if (regionSegments.length > 0) { widgets.push({ kind: "bars", label: "Regions", labelKey: "lor.regions", vocabulary: "lor.region", segments: regionSegments }); } widgets.push({ kind: "histogram", label: "Mana Curve", labelKey: "lor.manaCurve", buckets: manaBuckets, labels: MANA_CURVE_LABELS }); if (typeSegments.length > 0) { widgets.push({ kind: "donut", label: "Card Types", labelKey: "lor.cardTypes", vocabulary: "lor.cardType", segments: typeSegments }); } if (raritySegments.length > 0) { widgets.push({ kind: "donut", label: "Rarity", labelKey: "lor.rarity", vocabulary: "lor.rarity", segments: raritySegments }); } widgets.push({ kind: "stat", label: "Average Cost", labelKey: "lor.averageCost", value: averageCost }); widgets.push({ kind: "ratio", label: "Units : Spells", labelKey: "lor.unitsToSpells", numerator: unitCount, denominator: spellCount, format: "ratio" }); return { widgets }; } function normalizePile(pile) { if (!isRecord(pile)) { return {}; } return Object.entries(pile).reduce((result, [cardId, quantity]) => { const normalizedQuantity = normalizeQuantity(quantity); if (normalizedQuantity > 0) { result[cardId] = normalizedQuantity; } return result; }, {}); } function normalizeQuantity(value) { return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; } function cardGame(card) { return isRecord(card) && isRecord(card.game) ? card.game : {}; } function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function cardName(card) { return isRecord(card) && typeof card.name === "string" ? card.name : ""; } function cardCost(game) { return Number.isFinite(game.cost) ? Math.max(0, Math.floor(game.cost)) : 0; } function cardRegionRefs(game) { return Array.isArray(game.regionRefs) ? game.regionRefs.filter((ref) => typeof ref === "string" && ref.length > 0) : []; } // The deck page's card order: by cost, then by name. function compareDeckCards(left, right) { return left.cost - right.cost || cardName(left.card).localeCompare(cardName(right.card)); } // The deck's regions, as the deck page derives them: every single-region // card's region in card order, then (only if some card still fits none) the // other regions of those unaccounted cards, never a Runeterra champion's // card-code ref. function detectDeckRegions(entries) { const regions = []; entries.forEach((entry) => { if (entry.regionRefs.length === 1 && regions.indexOf(entry.regionRefs[0]) === -1) { regions.push(entry.regionRefs[0]); } }); entries .filter((entry) => !entry.regionRefs.some((ref) => regions.indexOf(ref) !== -1)) .forEach((entry) => { entry.regionRefs.forEach((ref) => { if (!/^\d/.test(ref) && regions.indexOf(ref) === -1) { regions.push(ref); } }); }); return regions; } ``` ```js [Pokémon TCG] // Canonical Pokémon energy types, matched against spelled-out energy names. const POKEMON_ENERGY_TYPES = ["Grass", "Fire", "Water", "Lightning", "Psychic", "Fighting", "Darkness", "Metal", "Fairy", "Dragon", "Colorless"]; // PTCGO/Live single-letter energy glyph codes -> canonical type. Basic and typed // Special Energy print a {X} token ("Basic {L} Energy") rather than the spelled // type, so the element is read off the glyph: {R} is Fire, {N} is Dragon. const POKEMON_ENERGY_GLYPH_TYPES = { G: "Grass", R: "Fire", W: "Water", L: "Lightning", P: "Psychic", F: "Fighting", D: "Darkness", M: "Metal", Y: "Fairy", N: "Dragon", C: "Colorless" }; const RETREAT_LABELS = ["0", "1", "2", "3", "4+"]; export default function compute(pile, cardsById) { const deck = normalizePile(pile); const catalog = isRecord(cardsById) ? cardsById : {}; let pokemon = 0; let trainer = 0; let energy = 0; let total = 0; let basicPokemon = 0; const energyTypeCounts = new Map(); const retreatBuckets = Array.from({ length: 5 }, () => 0); const regulationCounts = new Map(); Object.keys(deck).forEach((cardId) => { const quantity = deck[cardId]; const card = catalog[cardId]; const game = cardGame(card); const supertype = typeof game.supertype === "string" ? game.supertype : ""; const subtypes = Array.isArray(game.subtypes) ? game.subtypes : []; total += quantity; // The catalog ships the accented "Pokémon" token; tolerate the ASCII form. if (supertype === "Pokémon" || supertype === "Pokemon") { pokemon += quantity; if (subtypes.indexOf("Basic") !== -1) { basicPokemon += quantity; } const retreat = Number.isFinite(game.convertedRetreatCost) ? Math.max(0, Math.floor(game.convertedRetreatCost)) : 0; retreatBuckets[Math.min(retreat, 4)] += quantity; } else if (supertype === "Trainer") { trainer += quantity; } else if (supertype === "Energy") { energy += quantity; // Energy-type donut counts ONLY Energy-supertype cards, so Pokémon (which // carry their own game.types) never leak into the energy breakdown. A basic // Energy derives its element from its identity id (or name); a special // Energy that declares an element uses it, and one with no element falls // into a "Special" bucket so the donut still sums to the deck's energy count. const energyTypes = pokemonCardEnergyTypes(card); const elements = Array.isArray(energyTypes) ? energyTypes.filter((type) => typeof type === "string" && type.length > 0) : []; if (elements.length > 0) { elements.forEach((type) => { energyTypeCounts.set(type, (energyTypeCounts.get(type) ?? 0) + quantity); }); } else { energyTypeCounts.set("Special", (energyTypeCounts.get("Special") ?? 0) + quantity); } } // Regulation mark (A–J), when the card carries one. Basic energy is // format-evergreen (not rotation-relevant), so exclude it from the gauge; // Pokémon, Trainers, and special (non-basic) energy still count. const isBasicEnergy = supertype === "Energy" && subtypes.indexOf("Basic") !== -1; const mark = typeof game.regulationMark === "string" ? game.regulationMark : ""; if (mark.length > 0 && !isBasicEnergy) { regulationCounts.set(mark, (regulationCounts.get(mark) ?? 0) + quantity); } }); const breakdown = [ { key: "pokemon", label: "Pokémon", value: pokemon, color: "" }, { key: "trainer", label: "Trainer", value: trainer, color: "" }, { key: "energy", label: "Energy", value: energy, color: "" }, ].filter((segment) => segment.value > 0); // Mulligan: P(no Basic Pokémon in the opening 7). Running product of draw // ratios (no factorial). 0 when a basic can't be avoided (too few non-basics). const handSize = 7; const drawCount = Math.min(handSize, total); let mulligan = 0; if (total > 0 && total - basicPokemon >= drawCount) { let probability = 1; for (let i = 0; i < drawCount; i = i + 1) { probability = (probability * (total - basicPokemon - i)) / (total - i); } mulligan = probability; } const widgets = []; widgets.push({ kind: "donut", label: "Deck breakdown", labelKey: "pokemon.deckBreakdown", vocabulary: "pokemon.supertype", segments: breakdown }); widgets.push({ kind: "stat", label: "Total cards", labelKey: "pokemon.totalCards", value: total }); widgets.push({ kind: "donut", label: "Energy types", labelKey: "pokemon.energyTypes", vocabulary: "pokemon.energyType", segments: energyTypeSegments(energyTypeCounts) }); widgets.push({ kind: "stat", label: "Mulligan chance", labelKey: "pokemon.mulliganChance", value: formatPercentValue(mulligan) }); { // Distinct-card table: one row per distinct card (keyed by name) in the // match set, with the configured metric columns. All combinatorics are // inline — the sandbox cannot import a helper. const containsValue = (haystack, needle) => { if (Array.isArray(haystack)) { return haystack.indexOf(needle) !== -1; } if (haystack === undefined || haystack === null) { return false; } return String(haystack).indexOf(String(needle)) !== -1; }; const lineCopies = new Map(); const linePrintings = new Map(); const lineCards = new Map(); const lineOrder = []; let deckSize = 0; let matchTotal = 0; Object.keys(pile).forEach((ref) => { const quantity = Number(pile[ref]) || 0; if (quantity <= 0) { return; } deckSize = deckSize + quantity; const card = cardsById[ref]; const row = { quantity: quantity, card: card, ref: ref }; if (!(["Pokémon", "Pokemon"].indexOf(row.card?.game?.supertype) !== -1 && containsValue(row.card?.game?.subtypes, "Basic"))) { return; } matchTotal = matchTotal + quantity; const key = String(row.card !== null && row.card !== undefined && typeof row.card.name === "string" && row.card.name.length > 0 ? row.card.name : row.ref); if (!lineCopies.has(key)) { lineCopies.set(key, 0); linePrintings.set(key, 0); lineCards.set(key, row); lineOrder.push(key); } lineCopies.set(key, lineCopies.get(key) + quantity); linePrintings.set(key, linePrintings.get(key) + 1); }); // P(the opening hand contains none of a pool of `excluded` cards), drawing // `drawCount` without replacement — a running product of draw ratios so a // 60-card deck never overflows a factorial. 0 when the pool leaves too few // safe cards to fill the hand. const probNoneOf = (excluded, drawCount) => { if (deckSize - excluded < drawCount) { return 0; } let probability = 1; for (let i = 0; i < drawCount; i = i + 1) { probability = (probability * (deckSize - excluded - i)) / (deckSize - i); } return probability; }; // Clamp to [0,1] and render as a 2-decimal percentage string. const formatPercent = (value) => { const clamped = value < 0 ? 0 : value > 1 ? 1 : value; return (Math.round(clamped * 10000) / 100).toFixed(2) + "%"; }; const entries = lineOrder.map((key) => { const copies = lineCopies.get(key); return { key: key, copies: copies, printings: linePrintings.get(key), row: lineCards.get(key), otherMatches: matchTotal - copies, }; }); // Highest copy count first (== highest possible-starter odds for fixed // draws); ties broken by key. entries.sort((left, right) => { if (right.copies !== left.copies) { return right.copies - left.copies; } return left.key < right.key ? -1 : left.key > right.key ? 1 : 0; }); widgets.push({ kind: "table", label: "Opening Hand Starters", columns: ["Pokémon", "Possible Starter", "Forced Starter"], rows: entries.map((entry) => { const row = entry.row; return [entry.key, formatPercent(1 - probNoneOf(entry.copies, Math.min(7, deckSize))), formatPercent(probNoneOf(entry.otherMatches, Math.min(7, deckSize)) - probNoneOf(entry.otherMatches + entry.copies, Math.min(7, deckSize)))]; }), }); const starterTable = widgets[widgets.length - 1]; starterTable.labelKey = "pokemon.openingHandStarters"; starterTable.columnKeys = [ "pokemon.starterTable.pokemon", "pokemon.starterTable.possibleStarter", "pokemon.starterTable.forcedStarter", ]; } widgets.push({ kind: "histogram", label: "Retreat cost", labelKey: "pokemon.retreatCost", buckets: retreatBuckets, labels: RETREAT_LABELS }); widgets.push({ kind: "gauge", label: "Regulation marks", labelKey: "pokemon.regulationMarks", segments: regulationSegments(regulationCounts) }); return { widgets: widgets }; } function normalizePile(pile) { if (!isRecord(pile)) { return {}; } return Object.entries(pile).reduce((result, [cardId, quantity]) => { const normalizedQuantity = normalizeQuantity(quantity); if (normalizedQuantity > 0) { result[cardId] = normalizedQuantity; } return result; }, {}); } function normalizeQuantity(value) { return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; } function cardGame(card) { return isRecord(card) && isRecord(card.game) ? card.game : {}; } function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } // Clamp to [0,1] and render as a 2-decimal percentage string. function formatPercentValue(value) { const clamped = value < 0 ? 0 : value > 1 ? 1 : value; return (Math.round(clamped * 10000) / 100).toFixed(2) + "%"; } // Energy-type donut segments (canonical English type keys, or "Special"), // highest count first. function energyTypeSegments(counts) { const entries = []; counts.forEach((value, key) => { if (value > 0) { entries.push({ key: key, value: value }); } }); return entries .sort((left, right) => right.value - left.value) .map((entry) => ({ key: entry.key, label: entry.key, value: entry.value, color: "" })); } // Regulation-mark gauge segments, in alphabetical (A–J) mark order. Colors are // left empty; the renderer fills them from its fallback palette. function regulationSegments(counts) { const entries = []; counts.forEach((value, key) => { if (value > 0) { entries.push({ key: key, value: value }); } }); return entries .sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0)) .map((entry) => ({ key: entry.key, label: entry.key, value: entry.value, color: "" })); } // The energy element an Energy card provides, read from its identity id. The // catalog slugs every identity from its canonical English name in every locale // ("basic-d-energy--935d278dfee1", "bubbly-w-energy--6fce5d2069c1"), while // card.name is localized ("基本悪エネルギー" under /ja/), so the id is the // locale-independent source. function pokemonEnergyTypesFromId(id) { const slug = String(id === undefined || id === null ? "" : id).split("--")[0].toLowerCase(); const glyph = slug.match(/(?:^|-)([a-z])-energy$/); if (glyph) { const type = POKEMON_ENERGY_GLYPH_TYPES[glyph[1].toUpperCase()]; return type ? [type] : []; } const base = slug.replace(/^basic-/, "").replace(/-energy$/, ""); return POKEMON_ENERGY_TYPES.filter((type) => type.toLowerCase() === base); } // The energy element(s) an Energy card provides, read from its NAME ("Fire // Energy" -> Fire; "Basic {L} Energy" -> Lightning) — the fallback for ids that // carry no element (printing-keyed or JP-only identities, multi-glyph names). // {X} glyph tokens win; failing that, a spelled-out Basic Energy name is // matched against the canonical types. function pokemonEnergyTypesFromName(name) { const text = String(name === undefined || name === null ? "" : name); const glyphTypes = []; const glyphTokens = text.match(/\{[A-Za-z]\}/g) || []; glyphTokens.forEach((token) => { const type = POKEMON_ENERGY_GLYPH_TYPES[token.slice(1, 2).toUpperCase()]; if (type && glyphTypes.indexOf(type) === -1) { glyphTypes.push(type); } }); if (glyphTypes.length > 0) { return glyphTypes; } const base = text.replace(/^basic\s+/i, "").replace(/\s+energy$/i, "").trim().toLowerCase(); return POKEMON_ENERGY_TYPES.filter((type) => type.toLowerCase() === base); } // The energy type(s) to group a card by. A Pokémon carries a declared game.types // array, used as-is. An Energy card carries none, so its element is derived from // its identity id, then its name. Any other card keeps its raw game.types // (undefined for Trainers). function pokemonCardEnergyTypes(card) { const game = cardGame(card); const declared = game.types; if (Array.isArray(declared) && declared.length > 0) { return declared; } if (game.supertype === "Energy") { const fromId = pokemonEnergyTypesFromId(isRecord(card) ? card.id : ""); return fromId.length > 0 ? fromId : pokemonEnergyTypesFromName(isRecord(card) ? card.name : ""); } return declared; } ``` ```js [Riftbound] const ENERGY_CURVE_LABELS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "+"]; // Might shares the 0–9 + overflow bucket shape of the energy curve. const MIGHT_LABELS = ENERGY_CURVE_LABELS; // Card types that sit in a separate zone, not the main deck; excluded from the // energy curve, domain pips, and card-type breakdown. Runes are handled first // (their own domain split) before this main-deck exclusion. const EXCLUDED_ZONE_TYPES = ["legend", "rune", "battlefield"]; // Main-deck card types that are Units (Champions are Units) — the population of // the Might distribution. const UNIT_TYPES = ["unit", "champion"]; // Known domains in display order; unknown domain refs are not charted. const DOMAIN_ORDER = ["body", "calm", "chaos", "fury", "mind", "order", "colorless", "neutral"]; // Card-type display order; unexpected types trail by count. const TYPE_ORDER = ["champion", "unit", "spell", "gear"]; export default function compute(pile, cardsById) { const deck = normalizePile(pile); const catalog = isRecord(cardsById) ? cardsById : {}; const energyBuckets = Array.from({ length: 11 }, () => 0); const mightBuckets = Array.from({ length: 11 }, () => 0); const domainCounts = new Map(); const runeDomainCounts = new Map(); const typeCounts = new Map(); let energyTotal = 0; let mainCount = 0; Object.keys(deck).forEach((cardId) => { if (!isRecord(catalog[cardId])) { return; } const quantity = deck[cardId]; const game = cardGame(catalog[cardId]); const type = typeof game.type === "string" && game.type.length > 0 ? game.type : "unknown"; // The Rune deck has its own domain split (the mana base — 6/6 vs 8/4), read // off the rune zone alone and kept out of the main-deck counts. if (type === "rune") { addDomains(runeDomainCounts, game.domains, quantity); return; } // Legend / battlefield cards are separate zones, not the main deck. if (EXCLUDED_ZONE_TYPES.indexOf(type) !== -1) { return; } // Riftbound uses energy as its cost. const energy = Number.isFinite(game.energy) ? Math.max(0, Math.floor(game.energy)) : 0; energyBuckets[Math.min(energy, 10)] += quantity; energyTotal += energy * quantity; mainCount += quantity; typeCounts.set(type, (typeCounts.get(type) ?? 0) + quantity); // Might distribution over Units (Champions are Units). Units with no numeric // might are skipped so missing data never piles into the 0 bucket. if (UNIT_TYPES.indexOf(type) !== -1 && Number.isFinite(game.might)) { const might = Math.max(0, Math.floor(game.might)); mightBuckets[Math.min(might, 10)] += quantity; } addDomains(domainCounts, game.domains, quantity); }); // Mean energy over the main deck (zone-excluded), to one decimal. const averageEnergy = mainCount > 0 ? Math.round((energyTotal / mainCount) * 10) / 10 : 0; // The balance of the three main card types. A ratio widget holds only two // values, so the three-way split renders as a small donut. const typeBalance = ["unit", "spell", "gear"].map((key) => ({ key, label: titleCase(key), value: typeCounts.get(key) ?? 0, color: "", })); const runeDomains = orderedSegments(runeDomainCounts, DOMAIN_ORDER); const hasMight = mightBuckets.some((count) => count > 0); // Like the panel, the rune split and Might chart only appear when they have data. return { widgets: [ { kind: "bars", label: "Domains", labelKey: "riftbound.domains", vocabulary: "riftbound.domain", segments: orderedSegments(domainCounts, DOMAIN_ORDER) }, ...(runeDomains.length > 0 ? [{ kind: "bars", label: "Rune domains", labelKey: "riftbound.runeDomains", vocabulary: "riftbound.domain", segments: runeDomains }] : []), { kind: "histogram", label: "Energy curve", labelKey: "riftbound.energyCurve", buckets: energyBuckets, labels: ENERGY_CURVE_LABELS }, ...(hasMight ? [{ kind: "histogram", label: "Might", labelKey: "riftbound.might", buckets: mightBuckets, labels: MIGHT_LABELS }] : []), { kind: "donut", label: "Card types", labelKey: "riftbound.cardTypes", vocabulary: "riftbound.cardType", segments: orderedSegments(typeCounts, TYPE_ORDER) }, { kind: "stat", label: "Average energy", labelKey: "riftbound.averageEnergy", value: averageEnergy }, { kind: "donut", label: "Unit / Spell / Gear", labelKey: "riftbound.typeBalance", vocabulary: "riftbound.cardType", segments: typeBalance }, ], }; } function normalizePile(pile) { if (!isRecord(pile)) { return {}; } return Object.entries(pile).reduce((result, [cardId, quantity]) => { const normalizedQuantity = normalizeQuantity(quantity); if (normalizedQuantity > 0) { result[cardId] = normalizedQuantity; } return result; }, {}); } function normalizeQuantity(value) { return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; } function cardGame(card) { return isRecord(card) && isRecord(card.game) ? card.game : {}; } function isRecord(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function titleCase(value) { return value.length > 0 ? value.slice(0, 1).toUpperCase() + value.slice(1) : value; } // Segments in a fixed display order, then any unlisted key by count (highest // first). Labels and colors are English/empty fallbacks: the widget's // vocabulary lets the web domain supply both. function orderedSegments(map, order) { const rank = (key) => { const index = order.indexOf(key); return index === -1 ? order.length : index; }; const entries = []; map.forEach((value, key) => { if (value > 0) { entries.push({ key, value }); } }); return entries .sort((left, right) => rank(left.key) - rank(right.key) || right.value - left.value) .map((entry) => ({ key: entry.key, label: titleCase(entry.key), value: entry.value, color: "", })); } function addDomains(counts, domains, quantity) { (Array.isArray(domains) ? domains : []).forEach((domain) => { if (DOMAIN_ORDER.indexOf(domain) !== -1) { counts.set(domain, (counts.get(domain) ?? 0) + quantity); } }); } ``` ::: --- url: https://docs.turny.gg/reference/card-data/lor description: "Every field a script can read on a Legends of Runeterra card and in its game globals." --- # Legends of Runeterra card data Ruleset and calculator scripts get Legends of Runeterra cards as plain objects straight from the card catalog. This page lists every field on those objects and in the catalog globals, with a real example and a few recipes. Where the cards show up: - **Card rules** receive one card as their first argument. - **Deck and deck set rules** receive `cardsById`, an object keyed by card ref that holds every card in the deck (it can hold more). - **Calculators** receive `cardsById` as their second argument. See the [ruleset script API](https://docs.turny.gg/reference/scripting/rulesets) and the [calculator script API](https://docs.turny.gg/reference/scripting/calculators) for the full function signatures. ## Card keys and deck refs Every Legends of Runeterra card is keyed by its **card code**, the same code the game client and deck codes use: - `01DE012` is set `01`, region `DE` (Demacia), card `012`. - Tokens and level-ups add a suffix: `01DE012T1` is Garen's level-up. A deck is a map of card code to copies (`{ "01DE012": 3 }`), and `cardsById` uses the same card codes as keys, so `cardsById[ref]` is always the card for a deck entry. Legends of Runeterra has no reprints, so cards have no `printings` list. Guard against a missing card before reading from it: `cardsById[ref]` can be `undefined` when the catalog doesn't know a ref. ## Card fields Every card, in every game, has these top-level fields: | Property | Type | Description | | --- | --- | --- | | `id` | `string` | Catalog id of the card identity (`"01DE012"` in LoR, `"pikachu-ex--1f29b66b68bc"` in Pokémon). | | `name` | `string` | Display name in the catalog locale. | | `setCode` | `string` | Set of the card, or of its default printing (`"set1"`, `"sv2"`, `"OGN"`). | | `collectible?` | `boolean` | False for cards that can't go in a deck (LoR tokens and level-ups). | | `assets?` | `CardCatalogCardAssetContract[]` | Images of the card's default printing. | | `printings?` | `CardPrintingContract[]` | Every printing of the identity, default printing first. Absent in LoR. | | `hasImage?` | `boolean` | False when none of the card's printings has an image in this language. Absent means it has one. | | `game?` | `TGameData` | Game-specific fields; see the per-game `*CardGameData` type. | ## Game fields (`card.game`) Everything specific to Legends of Runeterra lives on `card.game`. Text fields and `type` / `supertype` are in the viewer's language, so compare the `*Ref` fields in logic: `typeRef` is `"Unit"` in every language, while `type` is `"Einheit"` in German. | Property | Type | Description | | --- | --- | --- | | `associatedCardRefs?` | `string[]` | Card codes of related cards: level-ups, spawned tokens, spells a champion creates (`["01DE012T1"]`). | | `formatRefs?` | `string[]` | Formats the card is legal in, as `globals.formats` keys (`"client_Formats_Standard_name"`). | | `keywordRefs?` | `string[]` | Keywords on the card, as `globals.keywords` keys (`"QuickStrike"`, `"Regeneration"`). | | `rarityRef?` | `string` | Rarity key into `globals.rarities`: `"Common"`, `"Rare"`, `"Epic"`, `"Champion"` or `"None"` (tokens, spells a card creates). | | `regionRefs?` | `string[]` | Regions, as `globals.regions` keys (`"Demacia"`, `"PiltoverZaun"`). A Runeterra champion's own cards also carry its card code (`"06RU001"`), matching a `globals.runeterraChampions` key. | | `spellSpeedRef?` | `string` | Spell speed key into `globals.spellSpeeds` (`"Burst"`, `"Fast"`, `"Slow"`); spells only. | | `vocabTerms?` | `string[]` | Glossary terms linked from the card text, as `globals.vocabTerms` keys (`"Strike"`, `"Allegiance"`). | | `supertype?` | `string` | Localized supertype; `"Champion"` on champions, absent otherwise. Use `supertypeRef` in logic. | | `type?` | `string` | Localized card type (`"Unit"`, `"Einheit"` in German). Use `typeRef` in logic. | | `typeRef?` | `string` | English card type in every locale: `"Unit"`, `"Spell"`, `"Landmark"`, `"Equipment"`, `"Ability"` or `"Trap"`. | | `supertypeRef?` | `string` | English supertype in every locale: `"Champion"` on champions, absent otherwise. | | `cost?` | `number` | Mana cost. | | `attack?` | `number` | Power (attack); usually `0` on non-units. | | `health?` | `number` | Health; usually `0` on non-units. | | `description?` | `string` | Rules text with the game client's inline markup (``, `` tags). | | `descriptionRaw?` | `string` | Rules text as plain text, markup removed. | | `levelupDescription?` | `string` | Champion level-up condition, with inline markup. | | `levelupDescriptionRaw?` | `string` | Champion level-up condition as plain text. | | `flavorText?` | `string` | Flavor text. | | `subtypes?` | `string[]` | Unit subtypes in upper case (`["ELITE"]`, `["YETI"]`). | | `artistName?` | `string` | Card artist's name. | ## Globals (`ctx.globals`) The catalog's globals are lookup tables that turn the refs on a card into display names, icons and descriptions: `regionRefs: ["Demacia"]` resolves through `ctx.globals.regions.Demacia`. ::: warning When globals are available Globals are passed to ruleset scripts as `ctx.globals` (the last argument). Calculators may not receive `ctx` at all, and a ruleset can run without it too. Always check `ctx && ctx.globals` before using them and fall back to the raw ref. ::: | Property | Type | Description | | --- | --- | --- | | `formats?` | `Record` | Formats by `formatRefs` key (`"client_Formats_Standard_name"` → Standard). | | `keywords?` | `Record` | Keywords by `keywordRefs` key (`"QuickStrike"` → Quick Attack), with reminder text. | | `rarities?` | `Record` | Rarities by `rarityRef` key (`"Common"`, `"Rare"`, `"Epic"`, `"Champion"`, `"None"`). | | `regions?` | `Record` | Regions by `regionRefs` key (`"Demacia"`, `"PiltoverZaun"`, `"Runeterra"`). | | `runeterraChampions?` | `Record` | Runeterra champions by card code (`"06RU001"`): the champion refs that can appear in `regionRefs`. | | `sets?` | `Record` | Sets by the card's `setCode` (`"set1"`, `"set7b"`, `"setevent"`). | | `spellSpeeds?` | `Record` | Spell speeds by `spellSpeedRef` key (`"Burst"`, `"Fast"`, `"Slow"`). | | `types?` | `Record` | Card types by English type (`"Unit"`, `"Spell"`, `"Champion"`, `"Follower"`, …). | | `vocabTerms?` | `Record` | Glossary terms by `vocabTerms` key (`"Allegiance"`), with descriptions. | ### Lookup entries Every globals table maps a key to an entry with these fields: | Property | Type | Description | | --- | --- | --- | | `color?` | `string` | Display color: a hex value (`"#10C26E"`) or a CSS variable name (`"--color-Demacia"`). | | `icon?` | `string \| null` | Icon image URL; empty or `null` when there is none. | | `label` | `string` | Display name in the catalog locale (`"Piltover & Zaun"`). | | `metadataRef?` | `LorCatalogMetadataReference` | Anchor an operator override is matched on. | | `order?` | `number` | Sort position in filters and lists, lowest first. | | `showInFilter?` | `boolean` | Whether the entry is offered as a filter option in the card browser. | ### Keyword and vocab term entries `keywords` and `vocabTerms` entries also have: | Property | Type | Description | | --- | --- | --- | | `description?` | `string` | Reminder text for the keyword, in the catalog locale. | ### Runeterra champion entries `runeterraChampions` entries also have: | Property | Type | Description | | --- | --- | --- | | `cardCode` | `string` | The champion's card code (`"06RU001"` for Bard), also its key in `runeterraChampions`. | ## Example card Garen (`01DE012`), trimmed to one image: ```json { "id": "01DE012", "name": "Garen", "setCode": "set1", "collectible": true, "assets": [ { "kind": "card", "url": "https://cdn.turny.gg/cards/lor/assets/01DE012-card-8a863d8a21d1029ab31bd8454e9456e89c2de96072d27705e2f11883752034f5.webp" } ], "game": { "associatedCardRefs": ["01DE012T1", "01DE012T2"], "formatRefs": [ "client_Formats_Eternal_name", "client_Formats_Standard_name" ], "keywordRefs": ["Regeneration"], "rarityRef": "Champion", "regionRefs": ["Demacia"], "vocabTerms": ["Strike"], "supertype": "Champion", "supertypeRef": "Champion", "type": "Unit", "typeRef": "Unit", "cost": 5, "attack": 5, "health": 5, "description": "When I'm summoned, give other allies +1|+1 this round.", "descriptionRaw": "When I'm summoned, give other allies +1|+1 this round.", "levelupDescription": "I've struck twice.", "levelupDescriptionRaw": "I've struck twice.", "flavorText": "Training and resilience are the way of a soldier. Even in the face of great peril, we will not falter.", "subtypes": ["ELITE"], "artistName": "SIXMOREVODKA" } } ``` ## Common recipes ::: code-group ```js [Count by type] // Calculator: cards per type, with champions split out. export default function compute(pile, cardsById) { const counts = {}; Object.keys(pile).forEach((ref) => { const game = (cardsById[ref] && cardsById[ref].game) || {}; const type = game.supertypeRef === "Champion" ? "Champion" : game.typeRef || "Other"; counts[type] = (counts[type] || 0) + pile[ref]; }); return { widgets: [ { kind: "donut", label: "Card types", segments: Object.keys(counts).map((type) => ({ label: type, value: counts[type], })), }, ], }; } ``` ```js [Check a rarity] // Card rule: commons only, plus champions. export default function validateCard(card) { const rarity = card.game && card.game.rarityRef; if (rarity === "Common" || rarity === "Champion") { return true; } return { valid: false, issues: [{ message: card.name + " is not a common." }], }; } ``` ```js [Region with globals] // Card rule: Demacia only. Uses the region's display name when globals exist. export default function validateCard(card, ctx) { const regions = (card.game && card.game.regionRefs) || []; if (regions.indexOf("Demacia") !== -1) { return true; } const globals = ctx && ctx.globals; const region = globals && globals.regions && globals.regions.Demacia ? globals.regions.Demacia.label : "Demacia"; return { valid: false, issues: [{ message: card.name + " is not from " + region + "." }], }; } ``` ```js [Mana curve] // Calculator: copies at each mana cost, 7+ grouped. export default function compute(pile, cardsById) { const buckets = [0, 0, 0, 0, 0, 0, 0, 0]; Object.keys(pile).forEach((ref) => { const game = (cardsById[ref] && cardsById[ref].game) || {}; const cost = Math.min(Math.max(game.cost || 0, 0), 7); buckets[cost] += pile[ref]; }); return { widgets: [ { kind: "histogram", label: "Mana curve", buckets: buckets, labels: ["0", "1", "2", "3", "4", "5", "6", "7+"], }, ], }; } ``` ::: --- url: https://docs.turny.gg/reference/card-data/pokemon description: "Every field a script can read on a Pokémon TCG card and in its game globals." --- # Pokémon TCG card data Ruleset and calculator scripts get Pokémon TCG cards as plain objects straight from the card catalog. This page lists every field on those objects and in the catalog globals, with a real example and a few recipes. Where the cards show up: - **Card rules** receive one card as their first argument. - **Deck and deck set rules** receive `cardsById`, an object keyed by card ref that holds every card in the deck (it can hold more). - **Calculators** receive `cardsById` as their second argument. See the [ruleset script API](https://docs.turny.gg/reference/scripting/rulesets) and the [calculator script API](https://docs.turny.gg/reference/scripting/calculators) for the full function signatures. ## Card keys and deck refs A Pokémon card in the catalog is a **card identity**: one playable card with all of its printings (reprints, alternate arts, promos) listed under `printings`. Two ids matter: - The **identity id**, `card.id`: a name slug plus a hash, like `pikachu-ex--1f29b66b68bc`. - A **printing id**: `-`, like `sv2-11` or `svp-106`. Decks store **printing ids**, so a deck is a map like `{ "me2pt5-276": 2, "svp-106": 2 }`. `cardsById` is keyed by those same printing ids, and each one points to its **identity**. In that example both refs are Pikachu ex, so `cardsById["me2pt5-276"]` and `cardsById["svp-106"]` are the same card and share one `card.id`: - Count copies of "the same card" by `card.id` (or `card.name`), not by ref. The deck above runs 4 Pikachu ex. - To read the printing a ref points to, find it in the list: `card.printings.find((p) => p.id === ref)`. - `ctx.globals.printings[ref]` gives the identity id for any printing id, when globals are available. A card rule gets the identity card itself, so `card.id` is always the identity id, never a printing id. Guard against a missing card before reading from it: `cardsById[ref]` can be `undefined` when the catalog doesn't know a ref. ## Card fields Every card, in every game, has these top-level fields: | Property | Type | Description | | --- | --- | --- | | `id` | `string` | Catalog id of the card identity (`"01DE012"` in LoR, `"pikachu-ex--1f29b66b68bc"` in Pokémon). | | `name` | `string` | Display name in the catalog locale. | | `setCode` | `string` | Set of the card, or of its default printing (`"set1"`, `"sv2"`, `"OGN"`). | | `collectible?` | `boolean` | False for cards that can't go in a deck (LoR tokens and level-ups). | | `assets?` | `CardCatalogCardAssetContract[]` | Images of the card's default printing. | | `printings?` | `CardPrintingContract[]` | Every printing of the identity, default printing first. Absent in LoR. | | `hasImage?` | `boolean` | False when none of the card's printings has an image in this language. Absent means it has one. | | `game?` | `TGameData` | Game-specific fields; see the per-game `*CardGameData` type. | ### Printings Each entry of `printings` (`CardPrintingContract`): | Property | Type | Description | | --- | --- | --- | | `id` | `string` | Printing id; deck refs use it (`"sv2-11"` for Pokémon, `"ogn-030-298"` for Riftbound). | | `setCode` | `string` | Set the printing belongs to (`"sv2"`, `"OGN"`). | | `number?` | `string` | Collector number within the set. | | `language?` | `string` | Language of the printing, when it is not the catalog's own. | | `releaseDate?` | `string` | Release date as the source gives it (`"2023/06/09"`). | | `assets?` | `CardCatalogCardAssetContract[]` | This printing's own images. | | `variants?` | `CardVariantContract[]` | Treatments of this printing (foil, alternate art, …). | | `metadata?` | `JsonObject` | Extra per-printing data (rarity, artist, store ids); game-specific. | Each entry of a printing's `variants`: | Property | Type | Description | | --- | --- | --- | | `id` | `string` | Variant id, usually `:` (`"ogn-030-298:normal"`). | | `kind?` | `string` | Treatment kind (`"normal"`, `"foil"`, `"alternate_art"`); values are per game. | | `label?` | `string` | Display label, when the kind alone is not enough. | | `metadata?` | `JsonObject` | Extra per-variant data; game-specific. | Each entry of `assets` (on the card and on each printing): | Property | Type | Description | | --- | --- | --- | | `kind` | `CardAssetKind` | What the image shows: `"card"` (framed render), `"full"` (full art), `"icon"`, `"banner"`, `"slice"`, `"portrait"`, … | | `url` | `string` | Image URL. | | `format?` | `CardAssetFormat` | Image format (`"webp"`), when known. | | `width?` | `number` | Width in pixels, when known. | | `height?` | `number` | Height in pixels, when known. | | `metadata?` | `JsonObject` | Extra per-asset data; game-specific. | ## Game fields (`card.game`) Everything specific to the Pokémon TCG lives on `card.game`. The type tokens (`supertype`, `subtypes`, `types`, Energy costs, weakness types) are English in every language, so they are safe to compare. Names and text follow the viewer's language. Watch for two things: - `supertype` is `"Pokemon"`, without the accent. Compare against both spellings if in doubt. - `hp`, `damage` and `level` are strings, as printed (`"60+"`). Convert with `Number()` before doing math. | Property | Type | Description | | --- | --- | --- | | `associatedCardRefs?` | `string[]` | Reserved for related-card ids; not emitted by the catalog today. | | `abilities?` | `PokemonAbilityPayload[]` | Abilities, in printed order. | | `artist?` | `string` | Illustrator of the default printing. | | `attacks?` | `PokemonAttackPayload[]` | Attacks, in printed order. | | `clientName?` | `string` | Pokémon TCG Live display name when it differs from the printed name. | | `convertedRetreatCost?` | `number` | Number of Energy in the retreat cost (length of `retreatCost`). | | `defaultPrintingId?` | `string` | Printing id of the identity's representative (default art) printing. | | `digitalOnly?` | `boolean` | True when every printing exists only in Pokémon TCG Live. | | `evolvesFrom?` | `string` | Printed "Evolves from" name (`"Pikachu"`). | | `evolvesFromIds?` | `string[]` | Catalog ids of the identities this card evolves from. | | `evolvesTo?` | `string[]` | Printed "Evolves to" names. | | `evolvesToIds?` | `string[]` | Catalog ids of the identities this card evolves into. | | `flavorText?` | `string` | Flavor text, markup removed. | | `hp?` | `string` | Printed HP as a string (`"120"`); convert with `Number()` before comparing. | | `identityAliases?` | `string[]` | Older identity ids that now point to this card (ids it had before its Western release), so saved refs to them keep working. | | `inMarket?` | `boolean` | False when the card has no printing in this language's market (for example a Japan-only card in the English catalog). Absent means in-market. | | `isProvisional?` | `boolean` | True while the card is an unofficial pre-release entry, read off an official reveal before card data is published. Cleared when the official data lands. | | `isLiveAvailable?` | `boolean` | True when at least one printing is playable in Pokémon TCG Live. | | `legalities?` | `JsonObject` | Format key (`standard`, `expanded`, `unlimited`, `standard-jp`, `expanded-jp`, `standard-future`) to `"Legal"`, `"Illegal"` or `"Banned"`. Compare case-insensitively. | | `level?` | `string` | Printed level on older cards (`"45"`). | | `liveCardId?` | `string` | Pokémon TCG Live card id. | | `nationalPokedexNumbers?` | `number[]` | National Pokédex numbers of the Pokémon on the card (`[25]`); several for tag teams. | | `number` | `string` | Collector number of the default printing (`"276"`); the set is the card's `setCode`. | | `rarity?` | `string` | Rarity of the default printing as printed (`"Rare"`, `"Special Illustration Rare"`). | | `regulationMark?` | `string` | Regulation mark letter (`"G"`, `"H"`), which drives Standard rotation. | | `resistances?` | `PokemonResistancePayload[]` | Resistances, usually zero or one. | | `retreatCost?` | `string[]` | Energy type per retreat-cost Energy (`["Colorless", "Colorless"]`). | | `rules?` | `string[]` | Rule-box lines (Pokémon ex / V / Tera rules, Trainer rules). | | `subtypes?` | `string[]` | Stage and kind tokens (`["Basic", "ex"]`, `["Stage 1"]`, `["Item"]`, `["Supporter"]`). | | `supertype` | `string` | `"Pokemon"` (unaccented), `"Trainer"` or `"Energy"`. | | `types?` | `string[]` | Energy types of a Pokémon (`["Psychic"]`); absent on Trainers and Energy. | | `weaknesses?` | `PokemonWeaknessPayload[]` | Weaknesses, usually zero or one. | ### Abilities Each entry of `abilities`: | Property | Type | Description | | --- | --- | --- | | `name` | `string` | Ability name (`"Quick Search"`). | | `text` | `string` | Effect text. | | `type` | `string` | `"Ability"`, `"Poké-Power"`, `"Poké-Body"` etc., as printed. | ### Attacks Each entry of `attacks`: | Property | Type | Description | | --- | --- | --- | | `convertedEnergyCost?` | `number` | Number of Energy the attack needs (length of `cost`). | | `cost?` | `string[]` | Energy type per required Energy (`["Lightning", "Colorless"]`). | | `damage?` | `string` | Printed damage as text (`"30"`, `"60+"`, `"×"`); absent when the attack does no printed damage. | | `name` | `string` | Attack name. | | `text?` | `string` | Effect text. | ### Weaknesses Each entry of `weaknesses`: | Property | Type | Description | | --- | --- | --- | | `type` | `string` | Energy type the Pokémon is weak to (`"Fighting"`). | | `value` | `string` | Printed modifier (`"×2"`, `"+20"`). | ### Resistances Each entry of `resistances`: | Property | Type | Description | | --- | --- | --- | | `type` | `string` | Energy type the Pokémon resists (`"Metal"`). | | `value` | `string` | Printed modifier (`"-30"`). | ## Globals (`ctx.globals`) The catalog's globals hold set details and the printing index that maps every printing id to its identity. ::: warning When globals are available Globals are passed to ruleset scripts as `ctx.globals` (the last argument). Calculators may not receive `ctx` at all, and a ruleset can run without it too. Always check `ctx && ctx.globals` before using them. ::: | Property | Type | Description | | --- | --- | --- | | `printings?` | `Record` | Printing id → owning card identity id, covering every emitted printing. | | `sets?` | `Record` | Sets by set code (`"sv2"`, `"me2pt5"`). | ### Set entries Each entry of `sets`: | Property | Type | Description | | --- | --- | --- | | `images?` | `PokemonSetImagesPayload` | Set logo and symbol image URLs. | | `label` | `string` | Set name in the catalog locale (`"Paldea Evolved"`). | | `printedTotal?` | `number` | Card count printed on the cards (the `/193` in `001/193`). | | `ptcgoCode?` | `string` | PTCGO/PTCG Live set abbreviation (e.g. "PAL") used in text deck lists. | | `releaseDate?` | `string` | Release date as `YYYY/MM/DD`. | | `series?` | `string` | Series (`"Scarlet & Violet"`). | | `total?` | `number` | Total cards in the set, secret rares included. | ### Set images A set entry's `images`: | Property | Type | Description | | --- | --- | --- | | `logo?` | `string` | Set logo image URL. | | `symbol?` | `string` | Set symbol image URL. | ## Example card Pikachu ex, trimmed to two printings, one image each and two identity aliases: ```json { "id": "pikachu-ex--1f29b66b68bc", "name": "Pikachu ex", "setCode": "me2pt5", "collectible": true, "assets": [ { "kind": "card", "url": "https://cdn.turny.gg/cards/pokemon/assets/me2pt5-276-en-card-685c27781bd507cac386d4a882d063dedea2430e20472857fe1881360eae4777.webp" } ], "printings": [ { "id": "me2pt5-276", "setCode": "me2pt5", "number": "276", "releaseDate": "2026/01/30", "assets": [ { "kind": "card", "url": "https://cdn.turny.gg/cards/pokemon/assets/me2pt5-276-en-card-685c27781bd507cac386d4a882d063dedea2430e20472857fe1881360eae4777.webp" } ], "variants": [], "metadata": { "rarity": "Special Illustration Rare", "regulationMark": "H", "artist": "booota" } }, { "id": "svp-106", "setCode": "svp", "number": "106", "releaseDate": "2023/01/01", "assets": [ { "kind": "card", "url": "https://cdn.turny.gg/cards/pokemon/assets/svp-106-en-card-6834a96f03cf2394d164d4c948035eddc63428fcfd0c08d201e14064fe9b21dd.webp" } ], "variants": [], "metadata": { "rarity": "Promo", "regulationMark": "H", "artist": "takuyoa" } } ], "game": { "supertype": "Pokemon", "subtypes": ["Basic", "ex"], "hp": "200", "types": ["Lightning"], "rules": [ "Pokémon ex rule: When your Pokémon ex is Knocked Out, your opponent takes 2 Prize cards." ], "attacks": [ { "name": "Thunderbolt", "cost": ["Lightning", "Lightning", "Colorless"], "convertedEnergyCost": 3, "damage": "120" } ], "weaknesses": [{ "type": "Fighting", "value": "×2" }], "retreatCost": ["Colorless"], "convertedRetreatCost": 1, "number": "276", "nationalPokedexNumbers": [25], "rarity": "Special Illustration Rare", "regulationMark": "H", "artist": "booota", "legalities": { "expanded": "Legal", "expanded-jp": "Legal", "standard": "Legal", "standard-future": "Legal", "standard-jp": "Legal", "unlimited": "Legal" }, "identityAliases": ["pikachu-ex--1f4e7709a44c", "pikachu-ex--3f6f59171eb0"], "defaultPrintingId": "me2pt5-276" } } ``` ## Common recipes ::: code-group ```js [Count by supertype] // Calculator: Pokémon, Trainer and Energy counts. export default function compute(pile, cardsById) { const counts = { Pokémon: 0, Trainer: 0, Energy: 0 }; Object.keys(pile).forEach((ref) => { const game = (cardsById[ref] && cardsById[ref].game) || {}; const supertype = game.supertype === "Pokemon" ? "Pokémon" : game.supertype; if (counts[supertype] !== undefined) { counts[supertype] += pile[ref]; } }); return { widgets: [ { kind: "donut", label: "Supertypes", segments: Object.keys(counts).map((name) => ({ label: name, value: counts[name], })), }, ], }; } ``` ```js [Standard legal] // Card rule: legal in Standard. Legality text is compared case-insensitively. export default function validateCard(card) { const legalities = (card.game && card.game.legalities) || {}; const standard = String(legalities.standard || "").toLowerCase(); if (standard === "legal") { return true; } return { valid: false, issues: [{ message: card.name + " is not legal in Standard." }], }; } ``` ```js [Check a rarity] // Card rule: no Special Illustration Rares or Hyper Rares. export default function validateCard(card) { const rarity = (card.game && card.game.rarity) || ""; if (rarity !== "Special Illustration Rare" && rarity !== "Hyper Rare") { return true; } return { valid: false, issues: [{ message: card.name + " is a " + rarity + "." }], }; } ``` ```js [Copies across printings] // Calculator: copies per card identity, so reprints count together. export default function compute(pile, cardsById) { const copies = {}; const names = {}; Object.keys(pile).forEach((ref) => { const card = cardsById[ref]; const id = card ? card.id : ref; copies[id] = (copies[id] || 0) + pile[ref]; names[id] = card ? card.name : ref; }); return { widgets: [ { kind: "table", label: "Copies", columns: ["Card", "Copies"], rows: Object.keys(copies).map((id) => [names[id], copies[id]]), }, ], }; } ``` ```js [Average HP] // Calculator: average HP of the Pokémon in the deck. export default function compute(pile, cardsById) { let total = 0; let count = 0; Object.keys(pile).forEach((ref) => { const game = (cardsById[ref] && cardsById[ref].game) || {}; const hp = Number(game.hp); if (hp > 0) { total += hp * pile[ref]; count += pile[ref]; } }); return { widgets: [ { kind: "stat", label: "Average HP", value: count > 0 ? Math.round(total / count) : 0, }, ], }; } ``` ::: --- url: https://docs.turny.gg/reference/card-data/riftbound description: "Every field a script can read on a Riftbound card and in its game globals." --- # Riftbound card data Ruleset and calculator scripts get Riftbound cards as plain objects straight from the card catalog. This page lists every field on those objects and in the catalog globals, with a real example and a few recipes. Where the cards show up: - **Card rules** receive one card as their first argument. - **Deck and deck set rules** receive `cardsById`, an object keyed by card ref that holds every card in the deck (it can hold more). - **Calculators** receive `cardsById` as their second argument. See the [ruleset script API](https://docs.turny.gg/reference/scripting/rulesets) and the [calculator script API](https://docs.turny.gg/reference/scripting/calculators) for the full function signatures. ## Card keys and deck refs A Riftbound card in the catalog is a **card identity**: one playable card with all of its printings (showcase, signature and alternate-art prints, reprints in later sets) listed under `printings`. Two kinds of id matter: - The **identity id**, `card.id`: `rb-` plus a hash, like `rb-34ece2975dc9e4adcb5e`. - A **printing id**: set, collector number and set size, like `ogn-030-298`. Treatments add a suffix: `ogn-030a-298` is the alternate art. Decks store **printing ids** (decks saved before printing refs existed may still use identity ids). `cardsById` is keyed by the deck's refs, and each one points to its **identity**, so two printings of the same card in one deck resolve to the same object: - Count copies of "the same card" by `card.id`, not by ref. - To read the printing a ref points to, find it in the list: `card.printings.find((p) => p.id === ref)`. - `ctx.globals.aliases[ref]` gives the identity id for any printing or variant id, when globals are available. A card rule gets the identity card itself, so `card.id` is always the identity id. The `game` fields describe the card's **default printing**, the first entry of `printings` (`game.defaultPrintingId`). That matters for per-printing fields such as `rarity`, `variant` and `artistName`. Guard against a missing card before reading from it: `cardsById[ref]` can be `undefined` when the catalog doesn't know a ref. ## Card fields Every card, in every game, has these top-level fields: | Property | Type | Description | | --- | --- | --- | | `id` | `string` | Catalog id of the card identity (`"01DE012"` in LoR, `"pikachu-ex--1f29b66b68bc"` in Pokémon). | | `name` | `string` | Display name in the catalog locale. | | `setCode` | `string` | Set of the card, or of its default printing (`"set1"`, `"sv2"`, `"OGN"`). | | `collectible?` | `boolean` | False for cards that can't go in a deck (LoR tokens and level-ups). | | `assets?` | `CardCatalogCardAssetContract[]` | Images of the card's default printing. | | `printings?` | `CardPrintingContract[]` | Every printing of the identity, default printing first. Absent in LoR. | | `hasImage?` | `boolean` | False when none of the card's printings has an image in this language. Absent means it has one. | | `game?` | `TGameData` | Game-specific fields; see the per-game `*CardGameData` type. | ### Printings Each entry of `printings` (`CardPrintingContract`): | Property | Type | Description | | --- | --- | --- | | `id` | `string` | Printing id; deck refs use it (`"sv2-11"` for Pokémon, `"ogn-030-298"` for Riftbound). | | `setCode` | `string` | Set the printing belongs to (`"sv2"`, `"OGN"`). | | `number?` | `string` | Collector number within the set. | | `language?` | `string` | Language of the printing, when it is not the catalog's own. | | `releaseDate?` | `string` | Release date as the source gives it (`"2023/06/09"`). | | `assets?` | `CardCatalogCardAssetContract[]` | This printing's own images. | | `variants?` | `CardVariantContract[]` | Treatments of this printing (foil, alternate art, …). | | `metadata?` | `JsonObject` | Extra per-printing data (rarity, artist, store ids); game-specific. | Each entry of a printing's `variants`: | Property | Type | Description | | --- | --- | --- | | `id` | `string` | Variant id, usually `:` (`"ogn-030-298:normal"`). | | `kind?` | `string` | Treatment kind (`"normal"`, `"foil"`, `"alternate_art"`); values are per game. | | `label?` | `string` | Display label, when the kind alone is not enough. | | `metadata?` | `JsonObject` | Extra per-variant data; game-specific. | Each entry of `assets` (on the card and on each printing): | Property | Type | Description | | --- | --- | --- | | `kind` | `CardAssetKind` | What the image shows: `"card"` (framed render), `"full"` (full art), `"icon"`, `"banner"`, `"slice"`, `"portrait"`, … | | `url` | `string` | Image URL. | | `format?` | `CardAssetFormat` | Image format (`"webp"`), when known. | | `width?` | `number` | Width in pixels, when known. | | `height?` | `number` | Height in pixels, when known. | | `metadata?` | `JsonObject` | Extra per-asset data; game-specific. | ## Game fields (`card.game`) Everything specific to Riftbound lives on `card.game`. Riftbound is English only, so classification values (`type`, `rarity`, `domains`) are lower-case tokens you can compare directly; look them up in the globals for display names. | Property | Type | Description | | --- | --- | --- | | `type?` | `RiftboundCardType` | Card type: `"unit"`, `"spell"`, `"gear"`, `"battlefield"`, `"rune"` or `"legend"`. Champions are units with `supertype: "Champion"`. | | `supertype?` | `string` | Printed supertype when there is one: `"Champion"`, `"Signature"`, `"Basic"`, `"Token"`. | | `rarity?` | `RiftboundRarity` | Rarity of the default printing: `"common"`, `"uncommon"`, `"rare"`, `"epic"`, `"showcase"`, … | | `domains?` | `RiftboundDomain[]` | Domains (colors): `["fury"]`, `["calm", "mind"]`. A card with no domain carries `["colorless"]`. | | `tags?` | `string[]` | Printed tags such as champion names and factions (`["Jinx", "Zaun"]`). | | `energy?` | `number` | Energy cost. | | `might?` | `number` | Might (combat strength); units only. | | `power?` | `number` | Power cost: the domain-rune part of the cost, paid on top of energy. | | `collectorNumber?` | `string` | Collector number of the default printing within its set, without leading zeros (`"30"`). | | `defaultPrintingId?` | `string` | Id of the default printing (`"ogn-030-298"`): the first regular printing, from the earliest set, with the `normal` treatment. Always the first entry of `printings`. | | `legalities?` | `RiftboundCardLegalities` | Legality per format (`{ standard: "legal" }` or `"banned"`). A missing format counts as legal. | | `text?` | `string` | Rules text as HTML, with `[Keyword]` markers and `:rb_…:` symbol tokens. | | `textPlain?` | `string` | Rules text without HTML tags; `[Keyword]` markers and `:rb_…:` tokens stay. | | `flavorText?` | `string` | Flavor text. | | `keywordRefs?` | `string[]` | Keywords in the rules text, as `globals.keywords` keys (`["accelerate", "assault"]`); `[Assault 2]` becomes `"assault"`. | | `orientation?` | `RiftboundCardOrientation` | `"portrait"`, or `"landscape"` for battlefields. | | `variant?` | `string` | Treatment of the default printing: `"normal"`, `"foil"`, `"showcase"`, `"ultimate"`, `"alternate_art"` or `"signature"`. | | `foil?` | `boolean` | True when the default printing is foil. | | `alternateArt?` | `boolean` | True when the default printing is an alternate-art print. | | `signature?` | `boolean` | True when the default printing is a signature print. | | `artistName?` | `string` | Artist of the default printing. | ## Globals (`ctx.globals`) The catalog's globals are lookup tables for the values on a card (`domains: ["fury"]` resolves through `ctx.globals.domains.fury`), the keyword glossary, the rules-text symbols and the printing alias map. ::: warning When globals are available Globals are passed to ruleset scripts as `ctx.globals` (the last argument). Calculators may not receive `ctx` at all, and a ruleset can run without it too. Always check `ctx && ctx.globals` before using them and fall back to the raw value. ::: | Property | Type | Description | | --- | --- | --- | | `domains?` | `Record` | Domains by `domains` value (`"fury"` → Fury). | | `rarities?` | `Record` | Rarities by `rarity` value (`"epic"` → Epic). | | `types?` | `Record` | Card types by `type` value (`"unit"` → Unit). | | `sets?` | `Record` | Sets by the card's `setCode` (`"OGN"` → Origins). | | `formats?` | `Record` | Formats by `legalities` key (`"standard"`). | | `keywords?` | `Record` | Keyword glossary, keyed by normalized keyword id; filtered to keywords the ingested catalog actually references. | | `symbols?` | `Record` | Inline symbol-token lexicon (`:rb_...:`), keyed by token key. | | `aliases?` | `Record` | Printing or variant id → owning card identity id (`"ogn-030-298"` → `"rb-34ece2975dc9e4adcb5e"`); identity ids map to themselves. Deck refs may be either. | ### Lookup entries `domains`, `rarities`, `types`, `sets`, `formats` and `keywords` map a key to an entry with these fields: | Property | Type | Description | | --- | --- | --- | | `label` | `string` | Display name (`"Fury"`, `"Origins"`). | | `color?` | `string` | Display color set by an operator override; usually absent. | | `icon?` | `string \| null` | Icon image URL set by an operator override; usually absent. | | `order?` | `number` | Sort position in filters and lists, lowest first. | | `showInFilter?` | `boolean` | Whether the entry is offered as a filter option; absent means shown. | | `metadataRef?` | `RiftboundCatalogMetadataReference` | Anchor an operator override is matched on. | ### Keyword entries `keywords` entries also have: | Property | Type | Description | | --- | --- | --- | | `description?` | `string` | Reminder text; may contain `:rb_…:` symbol tokens. | ### Symbol entries Each entry of `symbols`: | Property | Type | Description | | --- | --- | --- | | `token` | `string` | The full inline token as it appears in rich text, e.g. `":rb_energy_1:"`. | ## Example card Jinx, Demolitionist, trimmed to two printings with one image each: ```json { "id": "rb-34ece2975dc9e4adcb5e", "name": "Jinx - Demolitionist", "setCode": "OGN", "collectible": true, "assets": [ { "kind": "card", "url": "https://cdn.turny.gg/cards/riftbound/assets/ogn-030-298-card-80a26a1b25235660a480721f141f212244485ad006bbd2602247425901e8b11e.webp" } ], "printings": [ { "id": "ogn-030-298", "setCode": "OGN", "number": "30", "assets": [ { "kind": "card", "url": "https://cdn.turny.gg/cards/riftbound/assets/ogn-030-298-card-80a26a1b25235660a480721f141f212244485ad006bbd2602247425901e8b11e.webp" } ], "variants": [{ "id": "ogn-030-298:normal", "kind": "normal" }], "metadata": { "tcgplayerId": "652802" } }, { "id": "ogn-030a-298", "setCode": "OGN", "number": "30", "assets": [ { "kind": "card", "url": "https://cdn.turny.gg/cards/riftbound/assets/ogn-030a-298-card-03360e2876cef6388860f6cd8445bd352c6a95eea244e015019869cd5d693365.webp" } ], "variants": [ { "id": "ogn-030a-298:alternate_art", "kind": "alternate_art" } ], "metadata": { "tcgplayerId": "652803" } } ], "hasImage": true, "game": { "type": "unit", "supertype": "Champion", "rarity": "rare", "domains": ["fury"], "tags": ["Jinx", "Zaun"], "energy": 3, "might": 4, "power": 1, "collectorNumber": "30", "defaultPrintingId": "ogn-030-298", "legalities": { "standard": "legal" }, "text": "

[Accelerate] (You may pay :rb_energy_1::rb_rune_fury: as an additional cost to have me enter ready.)
[Assault 2] (+2 :rb_might: while I'm an attacker.)
When you play me, discard 2.

", "textPlain": "[Accelerate] (You may pay :rb_energy_1::rb_rune_fury: as an additional cost to have me enter ready.)[Assault 2] (+2 :rb_might: while I'm an attacker.)When you play me, discard 2.", "keywordRefs": ["accelerate", "assault"], "flavorText": "I really need a new gun. But don't tell my other guns.", "orientation": "portrait", "variant": "normal", "artistName": "Kudos Productions" } } ``` ## Common recipes ::: code-group ```js [Count by type] // Calculator: cards per type, with champions split out. export default function compute(pile, cardsById) { const counts = {}; Object.keys(pile).forEach((ref) => { const game = (cardsById[ref] && cardsById[ref].game) || {}; const type = game.supertype === "Champion" ? "champion" : game.type || "other"; counts[type] = (counts[type] || 0) + pile[ref]; }); return { widgets: [ { kind: "donut", label: "Card types", segments: Object.keys(counts).map((type) => ({ label: type, value: counts[type], })), }, ], }; } ``` ```js [Energy curve] // Calculator: main-deck copies at each energy cost, 7+ grouped. export default function compute(pile, cardsById) { const buckets = [0, 0, 0, 0, 0, 0, 0, 0]; Object.keys(pile).forEach((ref) => { const game = (cardsById[ref] && cardsById[ref].game) || {}; if (typeof game.energy !== "number") { return; // Legends, runes and battlefields have no energy cost. } buckets[Math.min(game.energy, 7)] += pile[ref]; }); return { widgets: [ { kind: "histogram", label: "Energy curve", buckets: buckets, labels: ["0", "1", "2", "3", "4", "5", "6", "7+"], }, ], }; } ``` ```js [Check a rarity] // Card rule: commons and uncommons only. Legends, runes and battlefields pass. export default function validateCard(card) { const game = card.game || {}; const exempt = ["legend", "rune", "battlefield"]; if (exempt.indexOf(game.type) !== -1) { return true; } if (game.rarity === "common" || game.rarity === "uncommon") { return true; } return { valid: false, issues: [{ message: card.name + " is " + game.rarity + "." }], }; } ``` ```js [Standard legal] // Card rule: not banned in Standard. A card with no entry counts as legal. export default function validateCard(card) { const legalities = (card.game && card.game.legalities) || {}; if (legalities.standard !== "banned") { return true; } return { valid: false, issues: [{ message: card.name + " is banned in Standard." }], }; } ``` ```js [Domain with globals] // Card rule: Fury cards only. Uses the domain's display name when globals exist. export default function validateCard(card, ctx) { const domains = (card.game && card.game.domains) || []; if (domains.indexOf("fury") !== -1) { return true; } const globals = ctx && ctx.globals; const fury = globals && globals.domains && globals.domains.fury ? globals.domains.fury.label : "fury"; return { valid: false, issues: [{ message: card.name + " is not a " + fury + " card." }], }; } ``` ::: --- url: https://docs.turny.gg/reference/search/lor description: "Card search syntax for Legends of Runeterra: every field, is: shortcut and operator, with example queries." --- # Legends of Runeterra card search The search box on the Legends of Runeterra Cards page and in the deck builder understands a small query language. A plain word is enough to find a card by name, and fields let you ask for things like "Demacia units that cost 2 or less" in one line: `t:unit cost<=2 region:demacia`. ## How search works Type a query into the search box on the Cards page or in the deck builder. A query is one or more terms separated by spaces, and a card has to match every term. - **A plain word** searches card names and rules text: `draw`. - **`field:value`** searches one field: `t:spell`. Every field has a full name and usually a short alias, so `type:spell` and `t:spell` are the same search. Field names and values ignore upper and lower case unless this page says otherwise. The fields, shortcuts and examples for this game are further down the page. ## Matching text How you write a value decides how strictly it has to match. | You type | It matches | | ------------------- | --------------------------------------------------------------------------------------------------------------- | | `word` | Names close to `word`, typos allowed, plus any card whose name or rules text contains `word`. | | `n:word` | Names close to `word`, typos allowed. | | `"two words"` | Names or rules text containing exactly `two words`. Quotes turn typo tolerance off and let a value hold spaces. | | `field:"two words"` | That field contains exactly `two words`. | | `field:!value` | That field is exactly `value`, not just containing it. | | `field:/pattern/` | That field matches a regular expression, ignoring case: `n:/^the /` finds names starting with "The". | Some details: - **Only names are typo-tolerant.** Every other field matches when its value contains what you typed, so `o:draw` finds "draws" and "Draw 1". - **Best matches come first.** When a query has a plain word or a `n:` term, the closest names are listed first. A query with only fields keeps the page's normal order, and a sort you pick yourself always wins. - **Use `!` and `/…/` with a field.** On a plain word they compare against a card's name and rules text all at once, which rarely matches. - **The word `or` is special** (see below). To search for it, quote it: `"or"`. ## Combining terms | You type | It means | | ------------ | ------------------------------------------------------------------------------------------- | | `a b` | Both `a` and `b` must match. | | `a or b` | Either `a` or `b` must match. `OR` works too. | | `-a` | Leave out cards that match `a`. Works before any term or group: `-t:spell`, `-(a or b)`. | | `(a or b) c` | Parentheses group terms: `c` plus either `a` or `b`. | | `a b or c` | Terms next to each other are joined first, so this is `(a b) or c`. Add parentheses if not. | ## Comparing numbers and ranks Number fields such as cost take a comparison: | Operator | Meaning | Example | | --- | --- | --- | | `:` | Has this value: contains for text and values, equal for numbers | `cost:3` | | `=` | Equal to | `cost=3` | | `!=` | Not equal to | `cost!=3` | | `<` | Less than | `cost<3` | | `<=` | Less than or equal to | `cost<=3` | | `>` | Greater than | `cost>3` | | `>=` | Greater than or equal to | `cost>=3` | A field whose allowed values are listed in order, such as rarity, can be compared the same way: `r>=rare` finds Rare and everything ranked above it in the list shown in the fields table. A value that is not in that list never matches a comparison, though it still matches with `:`. ## Shortcut syntax `is:name` finds a whole kind of card in one term. `not:name` is the same as `-is:name`. If a shortcut's name has a space, quote it: `is:"two words"`. Each game's shortcuts are listed below. A field or shortcut the game doesn't have matches no cards, so check the spelling if a search comes back empty. ## Field kinds | Kind | How it matches | | --- | --- | | Text | Free text, such as a name or rules text. `:` means contains (names also allow typos). | | Value | One of a set of values, such as a type or region. `:` means contains, so part of a value is enough. When the allowed values are listed, comparisons follow their order. | | Number | A number. `:` means equal, and every comparison operator works. | | Cost symbols | A cost written as a number (the total) or as symbols (the exact mix). See the cost section on this page. | ## Fields | Field | Aliases | Kind | Description | Values | | --- | --- | --- | --- | --- | | `name` | `n` | Text | The card's name. Unquoted text is typo-tolerant. | Any text | | `text` | `o`, `oracle` | Text | Rules text, including a champion's level-up text. | Any text | | `type` | `t` | Value | Card type: Unit, Spell, Landmark, Ability, Equipment and so on. | `Unit`, `Spell`, `Landmark`, `Ability`, `Equipment` | | `rarity` | `r` | Value | Rarity. Comparisons use the order Common, Rare, Epic, Champion. | `Common`, `Rare`, `Epic`, `Champion` | | `set` | `e` | Value | Set code, such as `set1` or `set6cde`. | Values come from the card catalog. | | `cost` | `mv`, `cmc` | Number | Mana cost. | A number | | `region` | `c`, `color` | Value | Region, such as `Demacia` or `ShadowIsles`. | `Demacia`, `Freljord`, `Ionia`, `Noxus`, `PiltoverZaun`, `ShadowIsles`, `Bilgewater`, `Shurima`, `Targon`, `BandleCity`, `Runeterra` | | `keyword` | `kw` | Value | Keyword, written as one word with no spaces, such as `QuickStrike` or `LastBreath`. | Values come from the card catalog. | | `supertype` | | Value | Supertype. `Champion` is the only one in use. | `Champion` | Regions and keywords use their English names written as one word, the same in every language: `region:piltoverzaun`, `region:shadowisles`, `kw:quickstrike`. Because `:` means contains, `region:shadow` is enough. ## Shortcuts | Shortcut | Finds | | --- | --- | | `is:champion` | Champion units (not their spells or abilities). | | `is:landmark` | Landmarks. | | `is:unit` | Units, champions included. | | `is:spell` | Spells. | | `is:equipment` | Equipment. | | `is:collectible` | Cards you can put in a deck (leaves out tokens, abilities and level-ups). | | `is:runeterra` | Cards whose region is Runeterra. | ## Examples | Query | Finds | | --- | --- | | `t:unit cost<=2 region:demacia` | Demacia units that cost 2 or less. | | `is:champion (region:freljord or region:ionia)` | Freljord or Ionia champions. | | `kw:elusive -is:champion` | Elusive units that are not champions. | | `t:spell o:"draw 1"` | Spells whose text says "draw 1". | | `r>=epic e:set1` | Epic and Champion rarity cards from the first set. | | `jnx` | Jinx, despite the typo. | | `n:!vi` | Only the card named exactly Vi. | | `kw:strike` | Quick Attack and Double Attack cards (`QuickStrike`, `DoubleStrike`). | | `is:landmark region:shurima` | Shurima landmarks. | | `region:bandle t:spell` | Bandle City spells. | --- url: https://docs.turny.gg/reference/search/pokemon description: "Card search syntax for the Pokémon TCG: every field, is: shortcut and operator, attack costs with energy symbols, and example queries." --- # Pokémon TCG card search The search box on the Pokémon TCG Cards page and in the deck builder understands a small query language. A plain word is enough to find a card by name, and fields let you ask for things like "Water Pokémon with 120 HP or more" in one line: `t:water hp>=120`. ## How search works Type a query into the search box on the Cards page or in the deck builder. A query is one or more terms separated by spaces, and a card has to match every term. - **A plain word** searches card names and rules text: `draw`. - **`field:value`** searches one field: `t:spell`. Every field has a full name and usually a short alias, so `type:spell` and `t:spell` are the same search. Field names and values ignore upper and lower case unless this page says otherwise. The fields, shortcuts and examples for this game are further down the page. ## Matching text How you write a value decides how strictly it has to match. | You type | It matches | | ------------------- | --------------------------------------------------------------------------------------------------------------- | | `word` | Names close to `word`, typos allowed, plus any card whose name or rules text contains `word`. | | `n:word` | Names close to `word`, typos allowed. | | `"two words"` | Names or rules text containing exactly `two words`. Quotes turn typo tolerance off and let a value hold spaces. | | `field:"two words"` | That field contains exactly `two words`. | | `field:!value` | That field is exactly `value`, not just containing it. | | `field:/pattern/` | That field matches a regular expression, ignoring case: `n:/^the /` finds names starting with "The". | Some details: - **Only names are typo-tolerant.** Every other field matches when its value contains what you typed, so `o:draw` finds "draws" and "Draw 1". - **Best matches come first.** When a query has a plain word or a `n:` term, the closest names are listed first. A query with only fields keeps the page's normal order, and a sort you pick yourself always wins. - **Use `!` and `/…/` with a field.** On a plain word they compare against a card's name and rules text all at once, which rarely matches. - **The word `or` is special** (see below). To search for it, quote it: `"or"`. ## Combining terms | You type | It means | | ------------ | ------------------------------------------------------------------------------------------- | | `a b` | Both `a` and `b` must match. | | `a or b` | Either `a` or `b` must match. `OR` works too. | | `-a` | Leave out cards that match `a`. Works before any term or group: `-t:spell`, `-(a or b)`. | | `(a or b) c` | Parentheses group terms: `c` plus either `a` or `b`. | | `a b or c` | Terms next to each other are joined first, so this is `(a b) or c`. Add parentheses if not. | ## Comparing numbers and ranks Number fields such as cost take a comparison: | Operator | Meaning | Example | | --- | --- | --- | | `:` | Has this value: contains for text and values, equal for numbers | `cost:3` | | `=` | Equal to | `cost=3` | | `!=` | Not equal to | `cost!=3` | | `<` | Less than | `cost<3` | | `<=` | Less than or equal to | `cost<=3` | | `>` | Greater than | `cost>3` | | `>=` | Greater than or equal to | `cost>=3` | A field whose allowed values are listed in order, such as rarity, can be compared the same way: `r>=rare` finds Rare and everything ranked above it in the list shown in the fields table. A value that is not in that list never matches a comparison, though it still matches with `:`. ## Shortcut syntax `is:name` finds a whole kind of card in one term. `not:name` is the same as `-is:name`. If a shortcut's name has a space, quote it: `is:"two words"`. Each game's shortcuts are listed below. A field or shortcut the game doesn't have matches no cards, so check the spelling if a search comes back empty. ## Field kinds | Kind | How it matches | | --- | --- | | Text | Free text, such as a name or rules text. `:` means contains (names also allow typos). | | Value | One of a set of values, such as a type or region. `:` means contains, so part of a value is enough. When the allowed values are listed, comparisons follow their order. | | Number | A number. `:` means equal, and every comparison operator works. | | Cost symbols | A cost written as a number (the total) or as symbols (the exact mix). See the cost section on this page. | ## Fields | Field | Aliases | Kind | Description | Values | | --- | --- | --- | --- | --- | | `name` | `n` | Text | The card's name. Unquoted text is typo-tolerant. | Any text | | `text` | `o`, `oracle` | Text | Rules text, plus the names and text of abilities and attacks. | Any text | | `type` | `t` | Value | A Pokémon's energy type. Trainers have none. | `Grass`, `Fire`, `Water`, `Lightning`, `Psychic`, `Fighting`, `Darkness`, `Metal`, `Fairy`, `Dragon`, `Colorless` | | `rarity` | `r` | Value | Printed rarity. Comparisons use the order of the allowed values; any other rarity never matches a comparison. | `Common`, `Uncommon`, `Rare`, `Double Rare`, `Ultra Rare`, `Illustration Rare`, `Special Illustration Rare`, `Hyper Rare` | | `set` | `e` | Value | Set code, such as `sv1` or `sm11`. | Values come from the card catalog. | | `cost` | `attackcost`, `energy`, `mv`, `cmc` | Cost symbols | Attack cost. A number compares the total energy of the card's cheapest attack; energy symbols match the exact typed cost of any one attack. | A number, or cost symbols | | `hp` | | Number | Hit points. | A number | | `damage` | `dmg` | Number | Highest printed base damage among the card's attacks. A `+` or `×` after the number is ignored. | A number | | `stage` | | Value | Evolution stage. `final` matches Pokémon that do not evolve any further, on pages that load the whole card pool. | `Basic`, `Stage 1`, `Stage 2`, `Final` | | `retreat` | | Number | Retreat cost, as a number of energy. | A number | | `weakness` | `weak` | Value | The energy type the card is weak to. | `Grass`, `Fire`, `Water`, `Lightning`, `Psychic`, `Fighting`, `Darkness`, `Metal`, `Fairy`, `Dragon`, `Colorless` | | `regmark` | `reg` | Value | Regulation mark letter. Comparisons use the order of the allowed values. | `A`, `B`, `C`, `D`, `E`, `F`, `G`, `H`, `I`, `J`, `K`, `L`, `M`, `N`, `O`, `P`, `Q`, `R`, `S`, `T`, `U`, `V`, `W`, `X`, `Y`, `Z` | | `subtype` | | Text | Any subtype, ignoring case: `Supporter`, `Item`, `Stadium`, `Pokémon Tool`, `ex`, `V` and so on. | Any text | `t:` is a Pokémon's energy type, not its card type. To find Trainers by kind, search the subtype: `subtype:supporter`, `subtype:item`, `subtype:stadium`, `subtype:"pokémon tool"`. ## Shortcuts | Shortcut | Finds | | --- | --- | | `is:ex` | Pokémon ex (the modern, lowercase mechanic). | | `is:v` | Pokémon V. | | `is:vmax` | Pokémon VMAX. | | `is:vstar` | Pokémon VSTAR. | | `is:v-union` | Pokémon V-UNION. | | `is:gx` | Pokémon-GX. | | `is:EX` | Pokémon-EX (the older, uppercase mechanic). Type it in capitals: `is:ex` finds the modern mechanic. | | `is:break` | Pokémon BREAK. | | `is:radiant` | Radiant Pokémon. | | `is:"prism star"` | Prism Star cards. | | `is:"ace spec"` | ACE SPEC cards. | | `is:mega` | Mega Evolution Pokémon. | | `is:tera` | Tera Pokémon. | | `is:ancient` | Ancient cards. | | `is:future` | Future cards. | | `is:"fusion strike"` | Fusion Strike cards. | | `is:"single strike"` | Single Strike cards. | | `is:"rapid strike"` | Rapid Strike cards. | | `is:"tag team"` | TAG TEAM cards. | | `is:"ultra beast"` | Ultra Beasts. | | `is:"team plasma"` | Team Plasma cards. | | `is:sp` | Pokémon SP. | | `is:"delta species"` | Delta Species Pokémon. | `is:ex` and `is:EX` are different shortcuts: lowercase finds the modern Pokémon ex, capitals find the older Pokémon-EX. The `subtype` field ignores case, so `subtype:ex` finds both. ## Attack cost `cost` (also `energy`, `attackcost`, `mv` or `cmc`) reads a card's attacks in two ways, depending on what you type. **A number** compares the total energy of the card's cheapest attack, of any type: `cost:1` finds cards whose cheapest attack costs one energy, and `cost<=2` finds cards with an attack for two or less. **Energy symbols** describe the exact mix of energy an attack costs. Write each symbol once per energy (`RCC`) or put a count in front (`1R2C`); both mean one Fire and two Colorless. Symbols are capital letters, so `cost:rcc` finds nothing. For a full energy name, use braces: `cost:{Fire}{Colorless}`. With symbols, `cost:` matches any one cost on the card that is exactly the listed mix. | Symbol | Meaning | | --- | --- | | `R` | Fire | | `W` | Water | | `L` | Lightning | | `G` | Grass | | `P` | Psychic | | `F` | Fighting | | `D` | Darkness | | `M` | Metal | | `Y` | Fairy | | `N` | Dragon | | `C` | Colorless | A `*` stands for one energy of any type, so `cost:RR*` finds attacks costing two Fire plus any one more energy. With symbols, the comparison operators compare the mix rather than a number: | You type | It finds an attack whose cost is | | ----------- | ---------------------------------------------------------------------- | | `cost:RCC` | Exactly one Fire and two Colorless. | | `cost=RCC` | The same: exactly one Fire and two Colorless. | | `cost>=RCC` | At least one Fire and two Colorless, possibly with more energy. | | `cost>RCC` | At least one Fire and two Colorless, with at least one more energy. | | `cost<=RCC` | Made only of energy from the list: `R`, `C`, `RC` and `RCC` all count. | | `cost=R` leaves out every card with an attack that needs Fire. ## Examples | Query | Finds | | --- | --- | | `t:water hp>=120` | Water Pokémon with 120 HP or more. | | `is:ex t:fire` | Fire Pokémon ex. | | `subtype:supporter o:"search your deck"` | Supporters that search your deck. | | `cost:RCC` | Pokémon with an attack that costs exactly one Fire and two Colorless. | | `cost>=RR damage>=200` | Pokémon with an attack needing at least two Fire, and an attack printed for 200 or more (not necessarily the same one). | | `cost:1 damage>=60` | Pokémon whose cheapest attack costs one energy and that have an attack printed for 60 or more. | | `retreat=0 stage:basic` | Basic Pokémon with free retreat. | | `stage:2 weakness:fighting` | Stage 2 Pokémon weak to Fighting. | | `pkachu` | Pikachu cards, despite the typo. | | `is:"ace spec"` | ACE SPEC cards. | | `not:ex t:psychic stage:basic reg:h` | Basic Psychic Pokémon with regulation mark H that are not Pokémon ex. | --- url: https://docs.turny.gg/reference/search/riftbound description: "Card search syntax for Riftbound: every field, is: shortcut and operator, with example queries." --- # Riftbound card search The search box on the Riftbound Cards page and in the deck builder understands a small query language. A plain word is enough to find a card by name, and fields let you ask for things like "Fury units that cost 3 or less" in one line: `domain:fury t:unit cost<=3`. ## How search works Type a query into the search box on the Cards page or in the deck builder. A query is one or more terms separated by spaces, and a card has to match every term. - **A plain word** searches card names and rules text: `draw`. - **`field:value`** searches one field: `t:spell`. Every field has a full name and usually a short alias, so `type:spell` and `t:spell` are the same search. Field names and values ignore upper and lower case unless this page says otherwise. The fields, shortcuts and examples for this game are further down the page. ## Matching text How you write a value decides how strictly it has to match. | You type | It matches | | ------------------- | --------------------------------------------------------------------------------------------------------------- | | `word` | Names close to `word`, typos allowed, plus any card whose name or rules text contains `word`. | | `n:word` | Names close to `word`, typos allowed. | | `"two words"` | Names or rules text containing exactly `two words`. Quotes turn typo tolerance off and let a value hold spaces. | | `field:"two words"` | That field contains exactly `two words`. | | `field:!value` | That field is exactly `value`, not just containing it. | | `field:/pattern/` | That field matches a regular expression, ignoring case: `n:/^the /` finds names starting with "The". | Some details: - **Only names are typo-tolerant.** Every other field matches when its value contains what you typed, so `o:draw` finds "draws" and "Draw 1". - **Best matches come first.** When a query has a plain word or a `n:` term, the closest names are listed first. A query with only fields keeps the page's normal order, and a sort you pick yourself always wins. - **Use `!` and `/…/` with a field.** On a plain word they compare against a card's name and rules text all at once, which rarely matches. - **The word `or` is special** (see below). To search for it, quote it: `"or"`. ## Combining terms | You type | It means | | ------------ | ------------------------------------------------------------------------------------------- | | `a b` | Both `a` and `b` must match. | | `a or b` | Either `a` or `b` must match. `OR` works too. | | `-a` | Leave out cards that match `a`. Works before any term or group: `-t:spell`, `-(a or b)`. | | `(a or b) c` | Parentheses group terms: `c` plus either `a` or `b`. | | `a b or c` | Terms next to each other are joined first, so this is `(a b) or c`. Add parentheses if not. | ## Comparing numbers and ranks Number fields such as cost take a comparison: | Operator | Meaning | Example | | --- | --- | --- | | `:` | Has this value: contains for text and values, equal for numbers | `cost:3` | | `=` | Equal to | `cost=3` | | `!=` | Not equal to | `cost!=3` | | `<` | Less than | `cost<3` | | `<=` | Less than or equal to | `cost<=3` | | `>` | Greater than | `cost>3` | | `>=` | Greater than or equal to | `cost>=3` | A field whose allowed values are listed in order, such as rarity, can be compared the same way: `r>=rare` finds Rare and everything ranked above it in the list shown in the fields table. A value that is not in that list never matches a comparison, though it still matches with `:`. ## Shortcut syntax `is:name` finds a whole kind of card in one term. `not:name` is the same as `-is:name`. If a shortcut's name has a space, quote it: `is:"two words"`. Each game's shortcuts are listed below. A field or shortcut the game doesn't have matches no cards, so check the spelling if a search comes back empty. ## Field kinds | Kind | How it matches | | --- | --- | | Text | Free text, such as a name or rules text. `:` means contains (names also allow typos). | | Value | One of a set of values, such as a type or region. `:` means contains, so part of a value is enough. When the allowed values are listed, comparisons follow their order. | | Number | A number. `:` means equal, and every comparison operator works. | | Cost symbols | A cost written as a number (the total) or as symbols (the exact mix). See the cost section on this page. | ## Fields | Field | Aliases | Kind | Description | Values | | --- | --- | --- | --- | --- | | `name` | `n` | Text | The card's name. Unquoted text is typo-tolerant. | Any text | | `text` | `o`, `oracle` | Text | Rules text. | Any text | | `type` | `t` | Value | Card type: unit, spell, gear, rune, legend or battlefield. | `unit`, `champion`, `spell`, `gear`, `battlefield`, `rune`, `legend` | | `rarity` | `r` | Value | Rarity. Comparisons use the order of the allowed values. | `common`, `uncommon`, `rare`, `epic`, `legendary`, `showcase`, `ultimate` | | `set` | `e` | Value | Set code, such as `OGN` or `SFD`. | Values come from the card catalog. | | `cost` | `mv`, `cmc`, `energy` | Number | Energy cost. | A number | | `domain` | | Value | Domain. A card with two domains matches both. | `body`, `calm`, `chaos`, `fury`, `mind`, `order`, `colorless`, `neutral` | | `supertype` | | Text | Supertype, such as `Champion`, `Signature`, `Basic` or `Token`. | Any text | | `tag` | | Value | Tags, such as a region (`Demacia`) or a champion (`Yasuo`). | Values come from the card catalog. | | `keyword` | `kw` | Value | Keyword, in lowercase with `_` for spaces, such as `deflect` or `quick_draw`. | Values come from the card catalog. | | `might` | | Number | Might, a unit's combat strength. | A number | | `power` | `pow` | Number | Power cost: the domain-colored part of a card's cost, paid on top of its Energy cost. | A number | Riftbound's cost is a plain number: `cost` (or `energy`) is the Energy cost and `power` is the Power cost. Keywords are written in lowercase with `_` in place of spaces, such as `kw:quick_draw`. ## Shortcuts | Shortcut | Finds | | --- | --- | | `is:champion` | Cards with the Champion supertype: champion units and legends. | ## Examples | Query | Finds | | --- | --- | | `domain:fury t:unit cost<=3` | Fury units that cost 3 Energy or less. | | `is:champion domain:calm` | Calm champion units and legends. | | `t:legend` | Every legend. | | `kw:deflect might>=4` | Cards with Deflect and 4 Might or more. | | `t:spell o:"draw 1"` | Spells whose text says "draw 1". | | `tag:demacia` | Cards tagged Demacia. | | `r>=epic e:OGN` | Epic or rarer cards from Origins (`OGN`). | | `domain:mind domain:order` | Cards in both Mind and Order. | | `t:gear -kw:equip` | Gear without Equip. | | `power>=2` | Cards with a Power cost of 2 or more. |