# 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.
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.
`.
- 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 (``, ` 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. |