Appearance
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 while a deck is built and again when it is submitted. For a walkthrough, start with the custom rulesets guide. This page is the exact contract.
Entry points
Each script provides one function. The runner looks for it in this order:
- The default export.
export default function validateDeck(deck) { … }orexport default (deck) => …. The function's own name does not matter. - The scope's named fallback. With no default export, a top-level function (or
const) with the name below, for example a plainfunction validateDeck(deck) { … }. Do notexportit by name: the only named export allowed isresolvePoolFilters.
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<string, Card>, context?: DeckSettingsScriptContext): DeckSettingsScriptResult;
function validateDeckset(decks: DeckSettingsScriptDeck[], cardsById: Record<string, Card>, context?: DeckSettingsScriptContext): DeckSettingsScriptResult;
function resolvePoolFilters(input: DeckSettingsPoolFiltersInput<Card>, 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:
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 itscodewith underscores as spaces when there is no message. - On submission, the server prefixes each issue with where it came from:
Card <name>: …for the card rule,Deck 2: …for the deck rule (decks are numbered from 1) andLineup: …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,
cardTypesanddomains). 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<string, Card> | Catalog entries for every card in the lineup. |
filters | Record<string, unknown> | 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<string, number> | 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: allowed globals, rejected syntax, time limits and safety issue codes.
- Custom rulesets guide