Skip to content

Custom rulesets ​

A custom ruleset is a short JavaScript function that decides whether a card, a deck or a whole lineup is legal. Turny.gg runs it in the deck builder while players build, and again on the server when a deck is submitted to a tournament. If your community plays a format the built-in rules don't cover, a ruleset is how you enforce it.

This guide explains when to use each kind of rule, walks through writing your first one, and covers sharing and troubleshooting. You need a Turny.gg account and a little JavaScript. For the exact contract (every argument, return value and limit), see the ruleset script API and the script sandbox.

What a ruleset is ​

Every ruleset has a scope, which decides what it looks at and when it runs:

ScopeRunsUse it for
Card ruleOnce per cardWhich cards are legal at all: sets, formats, rarities, a ban list. In the deck builder it also hides illegal cards.
Deck ruleOnce per deckAnything about a single deck: deck size, copy limits, champion limits, bans that depend on how many copies.
Deck set ruleOnce per lineup, with every deck the player bringsAnything that compares decks: no shared champions, no repeated region pairs, one deck per archetype.

Pick the smallest scope that can answer the question. "Is this card allowed?" is a card rule. "Is this deck allowed?" is a deck rule. Only reach for a deck set rule when the answer depends on more than one deck.

A tournament always uses exactly one rule of each scope, and so does the deck builder. Each game ships built-in rules (for Legends of Runeterra, for example: the Standard and Eternal card rules, the Riot constructed deck rule and the Riot lock deck set rule).

Your rule replaces the built-in one

Choosing a custom rule for a scope replaces the built-in rule for that scope; it does not add to it. If your deck rule doesn't check deck size, nothing does. The quickest start is often to Duplicate a built-in ruleset and edit the copy.

Just banning cards?

In Legends of Runeterra and Pokémon, a card rule has a Direct bans picker. Pick cards from a list and they are banned on top of whatever the script allows, no code needed.

Create a ruleset and use it ​

Rulesets belong to a game, so create them on that game's site (for example lor.turny.gg for Legends of Runeterra).

  1. Open Settings → Custom rulesets and click New ruleset.
  2. Enter a Ruleset name and pick a Rule scope. The Source code editor fills in a starter function for that scope.
  3. Write your rule, then click Create. The script is checked for unsafe code when you save, and an unsafe script cannot be saved.

The list shows your rulesets next to the built-in ones. Built-in and downloaded rulesets are read-only, but you can View code or Duplicate them. The switch next to each ruleset hides it from every rule picker and legality check without deleting it. Script API opens the ruleset script API reference in a new tab, and the Card fields link under each code editor opens the card data reference for the current game.

Your enabled rulesets then show up wherever rules are chosen:

  • Deck builder: deck rules in the rules dropdown above the deck, card rules in the Card ruleset filter, and deck set rules in Multi Pile mode.
  • Tournament create: the Deck settings section has one dropdown per scope (Deck sets, Decks, Cards). View Decks code (and its siblings) shows the selected script, read-only.

Tournaments keep a copy

When you save a tournament, it stores a copy of each selected script. Editing the ruleset later does not change tournaments that already use it. To move a tournament to your latest version, open it (Administration → Edit tournament) and save it again: the form reloads the current code of every saved ruleset it uses.

Tutorial: your first ruleset ​

This tutorial builds a small Legends of Runeterra format called Two-of Standard: at most two copies of any card, two banned cards, and no champion shared between a player's decks. Every step works the same way in the other games; only the card fields change (see the card data pages for Legends of Runeterra, Pokémon and Riftbound).

1. Write a deck rule ​

Go to Settings → Custom rulesets → New ruleset, name it Two-of Standard, set Rule scope to Deck rule, and replace the starter code with:

js
// Deck rule: "Two-of Standard".
const BANNED = ["01IO049", "01SI001"]; // Deny, Vengeance
const MAX_COPIES = 2;
const DECK_SIZE = 40;

export default function validateDeck(deck, cardsById) {
  const issues = [];
  let total = 0;

  for (const [ref, copies] of Object.entries(deck)) {
    const name = cardsById[ref]?.name ?? ref;
    total += copies;

    if (BANNED.includes(ref)) {
      issues.push({ code: "banned_card", message: `${name} is banned.` });
    }

    if (copies > MAX_COPIES) {
      issues.push({
        code: "copy_limit",
        message: `${name}: at most ${MAX_COPIES} copies (found ${copies}).`,
      });
    }
  }

  if (total !== DECK_SIZE) {
    issues.push({
      code: "deck_size",
      message: `Decks need exactly ${DECK_SIZE} cards (found ${total}).`,
    });
  }

  return { valid: issues.length === 0, issues };
}

What it does:

  • deck maps each card's ref to its number of copies ({ "01IO009": 3 }). For Legends of Runeterra the ref is the card code.
  • cardsById holds the catalog entry for every card in the deck, so cardsById[ref].name is the card's name. The ?. guards against a ref the catalog doesn't know.
  • It collects every problem instead of stopping at the first, then returns valid plus the list.
  • It checks the deck size because this rule replaces the built-in deck rule, which used to check it.

Click Create.

2. Try it in the deck builder ​

Open Deck Builder and pick Two-of Standard in the rules dropdown above the deck. Paste this deck code into Import deck code and click Import:

text
CEAQCAICBEAREAICAUDAQDAQCEJBKFY2DMOCIJRKFQYTEAIBAEBDI

It is 40 Ionia cards with three copies of Zed and two copies of Deny. A warning icon appears next to the card count; hover it to see your messages:

  • Zed: at most 2 copies (found 3).
  • Deny is banned.

The deck uses cards that are no longer in Standard, so the builder's default Standard card rule adds "Only Standard-legal cards can be submitted" lines too. Switch the Card ruleset filter to Eternal to see only your rule's issues.

3. Add a deck set rule ​

Formats with several decks per player usually need a rule across the lineup. Create a second ruleset named No shared champions, with Rule scope set to Deck set rule:

js
// Deck set rule: "No shared champions".
export default function validateDeckset(decks, cardsById) {
  const firstDeckByChampion = new Map();
  const issues = [];

  decks.forEach((deck, index) => {
    const deckNumber = index + 1;

    for (const ref of Object.keys(deck)) {
      const card = cardsById[ref];

      if (card?.game?.supertypeRef !== "Champion") {
        continue;
      }

      const firstDeck = firstDeckByChampion.get(ref);

      if (firstDeck === undefined) {
        firstDeckByChampion.set(ref, deckNumber);
      } else {
        issues.push({
          code: "shared_champion",
          message: `${card.name} is in deck ${firstDeck} and deck ${deckNumber}.`,
        });
      }
    }
  });

  return { valid: issues.length === 0, issues };
}

decks is an array with one deck per entry, in the same shape as the deck rule's deck. Champions are found through card.game.supertypeRef, which is "Champion" in every language (the plain supertype is translated, so don't compare against it).

To try it, click Multi Pile in the deck builder, pick No shared champions in the deck set dropdown, click Add deck for a second deck, and import one of these codes into each deck (both run two copies of Zed):

text
CEAACFABAICQMCAJBQIBCEQVC4NBWHBEEYVCYMRUGUAA
CEAAEAIBAIERGAIEAEBAGBAFAYDQUDANBYHRAEISCMKBKFYA

The lineup warning reads "Zed is in deck 1 and deck 2."

This rule replaces the built-in Riot lock deck set rule, so the lineup is no longer checked for repeated region pairs. Add that check here if your format needs it. The number of decks a player brings is a stage setting (Decks to bring), not part of the ruleset.

4. Attach it to a tournament ​

  1. Go to Tournaments → Create tournament and fill in the basics.
  2. In Deck settings, set Deck sets to No shared champions, Decks to Two-of Standard and Cards to Eternal.
  3. Click Save as draft (or create the tournament).

The tournament's Rules tab now lists both scripts under Deck rules, marked Unverified code because they are yours rather than built in. Its Deck Builder button opens the builder with exactly this tournament's rules, which is the fastest way to check a deck against them. When players submit decks, the server runs the same three rules again and rejects a lineup that fails any of them.

Reporting good errors ​

Players only see what your rule returns, so the messages are the user interface of your format.

  • Return every issue, with a message. The message is shown to the player as written. code is a stable, machine-readable id (copy_limit, banned_card); it is shown only when there is no message, with underscores turned into spaces.
  • Name the card and the numbers. "Zed: at most 2 copies (found 3)." tells the player exactly what to change; "Deck is illegal." does not.
  • Don't return a bare false. It fails with a generic fallback ("Saved deck rule rejected this deck.") that doesn't say why.
  • Don't number decks in a deck rule. On submission the server already prefixes each issue with where it came from (Deck 2: …, Lineup: …, Card <name>: …). A deck set rule, which sees every deck at once, should name the decks itself, as the tutorial does.
  • Don't throw. A thrown error is shown as an error instead of your issues, and the script can't create an Error object anyway. Return { valid: false, issues } instead.

A deck rule written this way reads like a checklist:

js
// Every problem at once, each with a code and a message.
export default function validateDeck(deck, cardsById) {
  const issues = [];
  const refs = Object.keys(deck);

  if (refs.length < 10) {
    issues.push({
      code: "too_few_cards",
      message: `Use at least 10 different cards (found ${refs.length}).`,
    });
  }

  for (const ref of refs) {
    if (!cardsById[ref]) {
      issues.push({ code: "unknown_card", message: `Unknown card ${ref}.` });
    }
  }

  return { valid: issues.length === 0, issues };
}

The full list of accepted return values is on the ruleset script API page.

Pool filters ​

A deck rule can also help players build: an optional resolvePoolFilters export sets the deck builder's card filters for the deck being edited. The Riftbound built-in rules use it to show only the domains of the Legend you picked. It never runs on submission and never changes whether a deck is legal.

Add this below validateDeck in the tutorial's deck rule. Once the deck has cards from exactly two regions, the builder's card pool narrows to those two regions:

js
// Builder only: once the deck has two regions, filter the pool to them.
export function resolvePoolFilters(input) {
  const pile = input.lineup.find(
    (entry) => entry.pileId === input.activePileId,
  );

  if (!pile) {
    return null;
  }

  const regions = new Set();

  for (const ref of Object.keys(pile.cards)) {
    for (const region of input.cardsById[ref]?.game?.regionRefs ?? []) {
      // Skip Runeterra champion codes such as "06RU006".
      if (!/^\d/.test(region)) {
        regions.add(region);
      }
    }
  }

  return regions.size === 2 ? { regionRefs: [...regions] } : null;
}

Import the second deck code from step 3 (Zed plus Piltover & Zaun cards) with this rule selected and the card pool switches to Ionia and Piltover & Zaun.

How the builder treats the answer:

  • It suggests, it doesn't lock. The player can change the filters afterwards. The builder only reapplies your suggestion when it changes (a card changes the deck's regions) or when the player switches to another deck.

  • Return only the filters you mean to set. A key you leave out keeps the player's current value. [] clears that filter. null changes nothing.

  • Use the game's filter keys and values. A key the builder doesn't know is dropped silently, so a typo does nothing. The list filters are:

    GameKeys
    Legends of RuneterraregionRefs, typeRefs, rarityRefs, setCodes, keywordVocabRefs
    Pokémon TCGcardTypes, types, stages, rarities, setCodes
    RiftboundcardTypes, domains, rarities, sets, supertypes, tags

    Values are the same refs the cards carry: "Ionia" and "PiltoverZaun" for regions, lowercase "unit" for a Riftbound card type.

  • It sees the whole lineup. input.lineup has every deck (pile) in the builder, so a rule can read another pile, such as the Legend pile in Riftbound. Only deck rules are asked; card and deck set rules never are.

  • Watch for extra refs. In Legends of Runeterra some cards also list a Runeterra champion's card code (such as "06RU006") in regionRefs, next to their real region. That is why the example skips refs that start with a digit.

The input fields are listed under resolvePoolFilters in the reference.

Sharing rulesets ​

Public and private ​

New rulesets are private: only you can see them. Tick Make public in the create or edit dialog to share one. A public ruleset appears on the Rulesets tab of your profile, grouped into deck set, deck and card rules, where anyone can read its code. Untick it to take it off your profile; copies other players already saved keep working.

Saving someone else's ruleset ​

There are two places to pick up another player's ruleset:

  • A profile's Rulesets tab: Save to my account adds a copy to your custom rulesets, and Open in deck builder tries it without saving.
  • A tournament's Rules tab: Add to saved rulesets next to each custom rule adds a copy of the exact version that tournament uses.

A saved copy is marked Downloaded. It is read-only: you can view its code, Duplicate it into an editable ruleset of your own, or Remove it from your account, which leaves the original untouched.

Read before you save

Custom rules are code written by other players, and Turny.gg does not review it (the Rules tab labels it Unverified code). The sandbox keeps a script away from your account and the page, but a rule can still be wrong or unfair. Read it first.

Pinned copies and updates ​

A downloaded copy is pinned to the version you saved. When the author edits their ruleset, your copy keeps running the code you saved; nothing changes until you choose to update. That way a rule can't change under a tournament or a deck you already checked.

When a copy saved from a tournament's Rules tab falls behind the author's latest version, it shows Update available in your custom rulesets. Click Update to switch it to the latest version; the copy also takes the author's current name. Copies saved from a profile don't show updates yet: save the ruleset again from the author's profile to get the newest version.

Troubleshooting ​

"This ruleset script uses APIs or syntax that are not allowed." ​

The safety check rejected the script when you saved it. The message doesn't say which line, so look for the usual causes:

You wroteWrite instead
var total = 0let total = 0 or const
console.log(…)Remove it, or report the value as an issue while debugging
throw new Error("…")return { valid: false, issues: ["…"] }
parseInt(x)Number.parseInt(x, 10)
async / await, classes, thisPlain synchronous functions
import … or a second named exportOne default export (plus resolvePoolFilters in a deck rule)
delete obj.keyBuild a new object without the key
js
// Rejected: variable_kind_unsupported, unsafe_global.
export default function validateDeck(deck) {
  var total = 0;
  console.log(deck);
  return total === 0;
}

The script sandbox lists everything the check allows and every issue code.

Every deck fails with "Saved deck rule rejected this deck." ​

Your function returned something that isn't a pass: false, undefined (a missing return), or a result object with no valid: true. Return true or { valid: true } for a legal deck. See return values.

The builder shows an error instead of issues ​

The script threw while it ran. The most common cause is reading a field of a card that isn't there, like cardsById[ref].game.cost when the ref is unknown. Guard with ?. and ??. Writing to the arguments throws too, since they are frozen: build new objects ({ ...deck }) instead of changing deck.

It's slow, or stops with a timeout ​

Each check has a time limit (see time limits). A card rule runs once for every card in the catalog when the builder filters the pool, so keep per-card work small and avoid loops inside loops over the whole deck.

My ruleset isn't in the dropdown ​

Check that it is switched on in Settings → Custom rulesets, that it has the scope that dropdown asks for, and that you're on the site of the game you created it for.

A tournament still runs my old code ​

Tournaments keep the version they were saved with. Edit the tournament and save it to pick up your latest version (see Create a ruleset and use it).

"Run rules without the sandbox…" ​

Your browser couldn't start the sandbox that runs rule scripts, so the builder can't check legality and saving is disabled. The builder then offers to run deck rules directly in the page instead, with no isolation. It is an explicit opt-in: it applies only to that browser, expires after 30 days, and never affects the server, which always checks submissions in its own sandbox. Only accept it if you trust the rules you use.

Turny.gg documentation