Untap Script In development

A small language for automating the fiddly parts of your board. Grammar 3.

Reading this to write a script for someone? Everything needed is on this page and no other context is required. Start here, then skip to the cheat sheet.

What this is. Untap.in is a virtual tabletop for physical card games — Magic, Lorcana, One Piece and about forty others. Players drag real cards around a board; the site enforces no rules, because the players do that themselves. Untap Script automates the repetitive parts of that: marking summoning sickness, untapping at end of turn, moving tokens into a pile, counting power.

Where it runs. Entirely in the author's own browser, as the author. It is not sent to a server, it never runs in anyone else's client, and every action it takes is one the author could have taken by hand. There is no rules engine to hook into and no game state to query beyond what is described here.

What it is not. It is not JavaScript, and it is not a subset of JavaScript. It borrows C-family syntax and stops there — see what does not exist before writing anything, because that list is where most first attempts go wrong.

Getting started

The cards in this guide are made up. Untap runs about forty games and none of them is the one in these examples — Emberling, Stone Sentinel and Ashfen Grove do not exist anywhere. That is on purpose: an example written in one game's vocabulary reads as instructions to everyone playing it and as noise to everyone else, and the words that matter here — play-1, pile-discard, card.meta.type — are the same in every game.

So take the shape from an example and fill in your own game's words. Where an example says it.meta.type == "unit", yours might be "character", "digimon" or "pokemon": the key comes from what your game declares, and the editor lists them when you get one wrong.

Scripts live in a board helper. A board helper is a whole board saved under one name — its snap boxes, counter trays, dice, counters, piles and the scripts written against them. Loading one replaces whatever was on the board before it, which is usually what you want: a script that says box("Lands") is no use on a board with no Lands box. You can also load up to three at once, side by side — two helpers that each declare a piece of furniture with the same name cannot both be loaded, and the second one says so rather than quietly taking the first one's box.

Open a game and press </> on the game bar. The window's header says which board helper is on the board, or no board helper if none is.

Across the top is every board helper you have for that game, one tab each, with the Devlog first. The one on the board is marked ▸; the rest are dimmed. You can open, read and edit any of them without loading it — a helper you are not playing on is still yours to work on.

Press + for a new board helper. It starts from an empty board, loads, and opens on a blank script — unless you have no helper loaded and have already built a board, in which case it saves that board into the new helper rather than clearing it. You can keep three per game, or ten with Power Up.

Along the bottom is what you can do with the tab you are on. On the helper that is the board: Apply arms what you typed, Disable turns the script off without losing it, Unload takes the whole helper off the board, and Save writes both halves — your text, and wherever you have dragged the furniture to — back into it. On a helper that is not the board there are two: Load onto the board, which replaces everything in front of you with that helper, and Save, which files your edit where it lives.

Nothing runs until you Apply, on purpose: a script that ran while you were still typing would act out of a half-written handler, and its actions are real game actions. Everything is on your account, so it is there on your next machine and after a refresh. Copying another player's board never brings their scripts: those are in their browser, not on the board.

The board menu reaches all of this from the board side with one item, Board Helpers. It opens a screen with two tabs — yours, and everyone's: the ones other people have published, which you can read, load or copy. Rename, duplicate, delete, publish and Developer Mode live on your own tab; the same screen is in your account under Board Helpers, so you can tidy up without being at a table.

Along the bottom of that screen is your board, right now, and it is the only thing there that acts on the table rather than on the list: what is loaded, with an × on each to take that one off, Unload all, and Save — which writes both halves of the helper you are playing on, your text and wherever you have dragged the furniture to. On somebody else's published helper the offer is Save a copy… instead, because a publication is not yours to write into. With nothing loaded and furniture standing on the table, the offer is Save board as helper…: until you take it, that board belongs to no helper and goes when the game does. There is no "new, empty helper" — you furnish a board and then keep it.

Start a game with an empty board and the bottom left offers up to six to load without opening anything: the ones we have featured, then any you have starred, then your own. Any of them can be set to load itself every time you start that game.

Under the editor is a list of problems, recompiled as you type — on every tab, whether that helper is on the board or not. Errors stop a script arming; warnings do not. The Devlog shows what your scripts actually did — every event, every effect, every refusal, and why. The rest of this page calls it the script console or the script log; they are the same tab, and it is the first place to look when a script seems to do nothing.

The editor helps in three ways as you type, and all three are the compiler talking rather than a guess about what you meant:

Ctrl+S saves. Cmd+S on a Mac. It does whatever the Save button under the editor does, so it is the same save and the same rules — a script that will not compile is not saved by either.

The templates are the quickest way to learn a shape you have not used before: what askCard needs, that onPivot watches a transition rather than a state, that find takes an options object. Every one of them is a line that compiles.

And along the bottom edge is the line that answers what do I call this? While the editor is open, hovering anything on the table names it the way a script would: a zone becomes zone("pile-discard"), a snap box becomes box("Attacking"), a card gives its title, and an empty patch of the play area gives the position a card would need to land there — [30, 60, 200]. Dice and counter trays say what they are. It keeps the last thing you hovered rather than clearing as you move away, because you are moving away in order to type it.

Alt+click anything on the table and the Devlog prints the whole of it. Hovering answers what do I call this?; alt+clicking answers and what is in it? — one row per read, each spelled exactly as you would type it, next to the value it has right now. On a card that is its zone, its tags, its counters, its playedTurn beside the current turn, and every meta key your game declares — including the ones this card has nothing in, because a key that is empty and a key you misspelled both read null and that is the only place the two are told apart. On a snap box it is what a script can read off one, plus the declaration that would rebuild it. On a die or a counter tray it says plainly that a script cannot read one, which is otherwise invisible: they sit on the table looking exactly as addressable as the box beside them.

Nothing else gets the click — the card does not move and no menu opens — and everything it prints compiles. That is the point of it: the row is there to be selected and pasted into the script beside it.

If alt+click is awkward where you are — it drags the window on some desktops, and on a Mac the system takes it first — press Inspect click in the Devlog's bar instead. The next thing you click is dumped, and then the button lets go: it is one click, not a mode to remember to turn off. Clicking a bare patch of table keeps it armed, because that is not an answer.

Some of what a script reads is not on the table at all, so there is nothing to click: Log turn, Log me and Log game in the same bar print them. turn is which turn it is and whose. me is the seat you are playing — its name, both player counters, how many cards are in your hand and what is on top of your deck. game is the table: the game's code, its format and play style, and everyone at it with their seat, turn order and counters. They are the same rows, from the same board a handler would see, so turn.count here is the number your card.playedTurn is about to be compared against.

Log zones is the list of every zone this game has — the names zone() will accept, what your game calls each one on the board, and how many cards are in yours right now. Worth pressing once in a game you have not scripted before: the key is the same in every game and the title is not, so this is what connects pile-discard to the pile you are looking at. A pile or a group you opened on the board is listed too, as not one of them — those look exactly like zones and zone() will not take them.

A script can only do what you could do by hand. Every effect maps to an action the game already has, performed as you. Nobody else runs your code, and your opponent sees an ordinary sequence of moves — because that is what it is.

The shape of a script

A script is a set of event handlers, and above them the two things a script can state rather than do: the board it declares, and the values it remembers. There is no code that runs "when the script loads".

#grammar 1

// Mark creatures as they arrive, clear the marks when my turn starts.
onEnterZone("play-1") {
  tag(card, "sick");
}

onTurnStart() {
  untag(zone("play-1").cards, "sick");
}

The #grammar 1 line is a version stamp. It travels with the text, so a script written today keeps working when the language grows — and if it ever needs a newer grammar than your client has, you get a clear message instead of a mystery.

Statements

Form Notes
let x = … A new name. Assign to it later with x = ….
if (…) { } else { } else if chains as you would expect.
for x of <list> The only loop. Every list is already capped, so it always ends.
return Stop this handler.

There is deliberately no while, no defining your own functions, and therefore no recursion. That is what makes a script guaranteed to finish rather than merely likely to.

Expressions

Arithmetic (+ - * / %), comparison (== != < > <= >=), &&, ||, !, member access, indexing, calls, and string joining with +. Single & and | are errors that tell you what you probably meant — there is no bitwise arithmetic here.

Missing data is normal, and it is never an error. Reading something that is not there gives null, and <, >, <= and >= against null are always false. So a card with no power never accidentally satisfies power > 2, and it never satisfies power < 2 either. In arithmetic null counts as 0.

== and != are ordinary: x == null is how you ask whether something is missing, and — the one to watch — x != "unit" is true of a card that has no type at all. An exclusion filter over cards you cannot read selects all of them; see looking through a deck for where that bites.

Remembering a value

Handlers do not share their lets — each one starts empty, every time it runs. When a script needs to remember something between them, declare it at the top of the file with state:

#grammar 3

state uses = 0

onReady() {
  addButton({ text: "Channel", at: "board", when: uses < 3, emit: "channel" });
}

onEmit("channel") {
  uses = uses + 1;
  tip("channelled " + uses + " of 3");
}

onGameStart() {
  uses = 0;
}

A state line is readable and writable from every handler in the script, and from a button's when: — which is what makes "only three times a game" something you can actually write, rather than something you have to remember yourself.

What a value can be

Four kinds of thing, and nothing else. These are values a script remembers, not values it works out, so the list is deliberately short enough that every one of them survives being written down and read back unchanged.

Type Declare it Notes
Number state uses = 0 The common case by a distance — a count, a limit, a turn number. Whole or fractional; both round-trip exactly.
Text state who = "nobody" Up to 200 characters. Anything longer is cut to 200 when it is stored, without an error.
True / false state greeted = false A flag: has this happened yet, is this mode on.
Nothing state last = null "I have not got one of these yet." if (last == "x") is false before anything has been stored, and if (last == null) is how you ask whether it has been.

The starting value has to be written out in full. It is read before any handler has run, when there is no board to work anything out from, so it must be one of the four literals above and not an expression:

state n = 0                            ok
state n = "none"                       ok
state n = zone("play-1").cards.length   ✗ must start at a plain value
state n = 1 + 1                         ✗ must start at a plain value

Assigning anything else stores null. Nothing stops a handler writing last = zone("play-1").cards — that is ordinary code, and the language will not complain — but a list is not something this can remember, so it becomes null straight away. Not a frame later: what the next when: reads and what survives a refresh are the same thing, always.

If a handler assigns What is remembered
a list — zone("play-1").cards, me.hand(), [1, 2] null
a card, a box, a zone, me, self null
text longer than 200 characters the first 200 characters
a number that is not a number (0 / 0) 0

To remember a card, remember something that names it. last = card.title works and keeps working; the card itself does not, and could not — the card you stored might be in someone's discard pile, or gone, by the time you read it back. If what you want is a mark that travels with the card, that is a tag on the card, not state in your script.

How long it lasts

For the game. Refresh the page, close the tab, come back tomorrow — the count is still there. Start a different game and it starts again at the value you wrote.

This is your browser's memory, not the table's. Nobody else can see it, it is not in the replay, and on your phone it starts from scratch. If a number matters to the game — how many charges are on a permanent, how much is in a pool — put it on the board instead, as a counter on the card or a resource on a box. Those are shared, and they last because the game holds them rather than because your browser does.

Nothing clears it for you. A count that should reset each game wants an onGameStart(); one that should reset each turn wants an onTurnStart() — the same rule as a tag you put on a card. A script whose counter only ever goes up is usually a script missing one of those.

On a card script, each copy of the card remembers its own. Two of the same creature on the board count their own charges, and neither can see the other's.

Limits

A script may declare 16 state lines, a remembered piece of text may be 200 characters, and one game may keep 8KB across every script in it. The fifty most recently played games are kept and older ones are forgotten, so a year of games does not accumulate in your browser.

Adding a new state line to a script you are mid-game with is safe: the new name starts at the value you wrote and the ones already counting keep their counts. Renaming one is not — the old name's value is simply left behind, and the new name starts fresh.

Handlers

Each handler puts different names in scope. Using one that is not there is a compile error naming the problem, not a silent null.

Handler Fires when In scope
onGameStart() a round begins — once, genuinely —
onReady() the script arms, and after every refresh —
onTurnStart([who]) a turn begins turn
onTurnEnd([who]) a turn ends turn
onEnterZone(z[, scope]) a card arrives in a zone card, zone, event, turn
onLeaveZone(z[, scope]) a card leaves a zone card, zone, event, turn
onEnterBox(t[, scope]) a card joins a snap box card, box, event, turn
onLeaveBox(t[, scope]) a card leaves a snap box card, box, event, turn
onPivot("0->90"[, scope]) a card is tapped or untapped card, event, turn
onTag(t[, scope]) a tag is added card, event, turn
onUntag(t[, scope]) a tag is removed card, event, turn
onAttr(name[, scope]) a card counter changes card, event, turn
onResource(label) a snap box counter changes value box, resource, event, turn
onEmit(name[, channel]) a button is pressed, or a script emits event, card, box, turn

turn is in scope in every handler except the two at the top — onGameStart and onReady — because neither fires in response to anything on the board: one runs once per round, the other every time the script arms. A turn reading taken in either would never update, so it is a compile error rather than a script that works today and is quietly wrong two turns later. Everywhere else, self.playedTurn == turn.count is how a card asks did this arrive this turn, and the same test over a list — zone("play-1").cards.where(it.playedTurn == turn.count) — is how it asks about all of them.

Filters

The first argument narrows what the handler hears. onEnterZone("play-1") only fires for that zone. A trailing * matches a prefix on any filter: onTag("counter-*"), onEnterZone("pile-*") for every pile, onEnterBox("Exiled*").

onTurnStart and onTurnEnd have nothing to filter by, so their one argument says whose turns: "mine", the default, is your turns only, and "any" is everyone's. A card that does something at the beginning of each opponent's turn wants "any" with a !turn.mine inside; a script that only ever sees its own turns is usually missing the word.

Writing the same handler twice is legal, and both run. Two onReady() blocks in one script both fire, in the order they are written, so their buttons add up rather than one replacing the other — the runtime asks which handlers match an event and runs every one of them. It is rarely what you meant, and a reader cannot tell an accident from an intent, so prefer one block per handler; but nothing is lost if you end up with two.

Zone names are the game's own zone ids, from the game type you are playing — play-1, hand-1, deck-1, pile-discard, sideboard-1 and so on. Not graveyard, not trash, not drop area — whatever your game calls the place, the id is the id. Get one wrong and the editor tells you what the real ones are.

onPivot takes a transition, written as two rotations with an arrow: "0->90", "90->0", or "*->90" for any tap.

The rotations a card can be in are 0, 90, 180 and 270 — upright, tapped, upside down, and tapped the other way. Either side of the arrow may be *, so "180->*" is "stopped being upside down". They read as tags too: hasTag(self, "pivot-180").

What event carries

Every handler that reacts to something gets event, and what is on it depends on what happened. event.from and event.to always mean previous and next — the zone left and the zone entered, the old counter value and the new one.

In Field Is
onEnterZone / onLeaveZone event.from, event.to The zone id it came from and the one it went to; null when there was none, as for a card that has just been created.
onPivot event.from, event.to The rotation before and after, as numbers.
onTag / onUntag event.tag The tag that was added or removed — the one your "counter-*" filter actually matched.
onAttr event.from, event.to The counter's old and new value.
onResource event.from, event.to The counter's old and new value. resource.value is the same number as event.to — the handler runs against the board after the change. See Reacting to a counter.
onEmit event.source, event.data Who emitted — the card, for a card script — and whatever emit()'s third argument carried. See emit / onEmit.
onEmit, answering a prompt or an arrow event.ok, event.choice, event.data Whether it was answered, what with, and what you put in data:. See Asking and Targeting.

Whose cards — scope

Every card-carrying handler takes a scope as its last argument.

Scope Means
"mine" The default. Every card I control.
"any" Everyone's cards, including my opponents'.
"self" Only the card this script is attached to. Card scripts only.
#grammar 1

// Only my creatures.
onEnterZone("play-1") {
  tag(card, "sick");
}

// Anyone's discard pile — useful for watching an opponent.
onEnterZone("pile-discard", "any") {
  log(card.title + " was discarded");
}

You may watch anything you can see. You may only act on your own cards. Widening what a script observes never widens what it can do — the game enforces that separately, exactly as it does when you click.

Reading the board

A card

Field Is
card.title Its printed name.
card.zone The zone id it is in.
card.home The zone it started in, stamped when the deck was loaded and never changed again. card.zone is where it is now; this is where it came from, which is a different question — card.home == "inserted" is how you tell a token, a conjured card or a piece of furniture from something that was really on the decklist, and "sideboard-1" marks a card that came in from the side.
card.box The title of the snap box it is in, or null.
card.tags A list of its tags, rotation included (pivot-90). A few of them already mean something to the board.
card.attr1 … attr3 Its three counters.
card.stats The stat counter in its corner, as a list — card.stats[0] is power. An empty list for a card that has none, so card.stats.length > 0 is how you ask. Only in play; for the printed number wherever the card is, that is card.meta.power. See +1/+1 counters.
card.playedTurn The turn it last entered the zone it is in; 0 if it was never stamped. See Only while it was played this turn.
card.notes Its note text, or null.
card.mine Whether I control it.
card.guid Its unique id — useful for "every creature except this one".
card.meta.<field> Card data for this game type — type, colours, cost, power. See Card data.

Collections

zone(z).cards, box(t).cards and me.hand() all give lists. Narrow one with .where(…), which binds each card to it:

#grammar 1

onEmit("count") {
  let tapped = zone("play-1").cards.where(hasTag(it, "pivot-90"));
  log("tapped: " + tapped.length);
}

.where() gives back a list, so it chains, feeds a for … of, and can be handed straight to an effect. Lists have .length.

hasTag(card, "x") tests one tag, and takes a trailing *: hasTag(it, "pivot-*") is "is it turned at all". Without a wildcard you can just compare — card.tags == "sick" — because a list on one side of == is a membership test. See Card data.

Text

There are no string methods. Joining is +, comparing is == — which ignores case, so card.meta.type == "Unit" matches a card whose type is stored lowercase — and text.length is how long it is.

One thing shortens text: cut(text, n), the first n characters. It is here for the one place text meets a hard edge — a counter label is 40 characters and the board cuts a longer one, so a label built out of a card name may not survive whole:

#grammar 3

onEnterBox("Attacking") {
  box("Attacking").createResource(cut(card.title, 32) + " hits");
}

You do not have to do this to be correct: a label too long is cut to the same 40 characters everywhere it is used, so the counter it makes is the counter addResource and resource() then find. cut is for deciding which 40 characters, and for leaving room for the part you are adding.

Card data

card.meta carries what the game type knows about the card itself — that is whatever the game declares — often type, cost, traits, cmc, power and toughness; for One Piece type, color, cost, power, traits, keyword. Every game declares its own set, and the editor knows which: name a field the game does not declare and you get a warning rather than a script that silently matches nothing.

Compare it with ==, like anything else:

#grammar 1

onEmit("tap-creatures") {
  tap(zone("play-1").cards.where(it.meta.type == "unit"));
}

That works even though a card can be more than one thing at once. When one side of == is a list, it asks whether the value is in it — so an artifact creature, whose type is ["relic", "unit"], matches == "unit" and == "relic" alike. The same rule covers card.meta.colors == "blue", card.meta.subtypes == "goblin" and card.tags == "sick".

Text compares case-insensitively, so == "Unit" works too. Numbers stay numbers and compare the usual way: zone("play-1").cards.where(it.meta.cmc >= 4).

Card data arrives with the card's image. It is read from the image bus rather than sent with the deck, so a card whose art has not loaded yet has no meta — every field of it reads as null, so it.meta.type == "unit" is false and it.meta.type != "unit" is true. Nothing errors. In practice everything on the board has loaded; a card buried face-down in your deck may not have. That is the ordinary "missing data is normal" rule, and it is a reason to prefer a button you press over a trigger that fires the instant a card appears.

Zones, boxes, me, turn

Expression Gives
zone("play-1") My zone of that name. .cards lists what is in it.
box("Lands") My snap box with that title. .cards lists what is in it; .exists says whether there is one, since naming a box that is not on the table is legal and everything about it reads empty; .resources is its counters as a list, and .resource(x) is one of them, so the tally on a counter is box("Command").resource("Tax").value. See Boxes & resources.
box(…) by itself is always true. It answers with the box's handle whether or not the board has that box — the handle is what carries .cards and addResource, so it has to exist for a box that does not. if (box("Lands")) therefore takes the branch every single time, and a script that creates a box "if it is missing" creates it always. box("Lands").exists is the question; the editor warns if you write the other one. The same goes for zone(…), where what you mean is zone("play-1").cards.length > 0.
me.name / me.life / me.otherCounter My seat name and my two player counters — the big one and the small one beside it. Written with life(n) and otherCounter(n).
me.hand() My hand, as a list.
me.deck.top(n) The top n cards of my deck.
turn.count / turn.player / turn.mine Which turn it is, the name of the seat whose turn it is, and whether that seat is mine. Not in onGameStart or onReady — see Handlers.
game.players / game.opponents Everyone at the table, and everyone who is not me. See The table.
game.type / game.format / game.playstyle What this game was made as. See The table.
self The card this script rides on. Card scripts only.

The table

game has always been a place to send things — log(t, { to: game }) means everybody. It also answers questions about the table itself.

Field Is
game.players Everyone in the game, in seating order, as a list.
game.opponents The same list without the seat you are playing. Seats, not people — if you are playing several decks at once, your other decks are opponents, which is what the table looks like and what you want when you are putting one counter on the board per opponent. Flip to another of your seats and it follows, the same way me and zone(…) do.
game.type Which card game this is, as its short code. Compare it against whatever your own games use.
game.format The format the game was created with, or null for a game with no format set.
game.playstyle The play style on the game — "beginner", "janky", "relaxed", "optimized", "Competitive".

Each player in those lists is:

Field Is
p.name Their seat name — the name on their plate, and what the game log calls them.
p.mine Whether that seat is the one I am playing — the same seat me is, and the same sense card.mine uses.
p.color Their seat colour, as a hex you can hand to anything that takes a colour. Seat colours are yours, not the table's — every player sees themselves in the same green, and everyone else by seating order. So a counter you colour this way is right on every screen except that player's own, where they are used to being green. p.seat is the number behind it, if you would rather tell players apart by something other than a colour.
p.turnOrder Their place in the turn order, counting from 0.
p.life / p.otherCounter Their two player counters — the same two me.life and me.otherCounter are for me.
p.afk Whether they are marked away.

Nothing raises an event when somebody joins or leaves. A script hears about cards, not about people, so a handler that reads this list reads it whenever it happens to run — onReady fires every time the script is armed, which includes every refresh and rejoin, and onTurnStart fires every turn. Between those two, anything built out of game.players catches up within a turn. See A counter for each opponent, which is the thing everybody wants this for and is built to be re-run.

Effects

An effect is something a script does. Each one is an action the game already has.

An effect only sends the part that would actually change something: tag(cards, "sick") over ten cards where four already carry the tag sends six. When nothing would change, nothing is sent at all — which is what stops a script that marks cards as they arrive from marking them again forever. The script console reports what went out rather than what you asked for, so untag 5 of 14 cards and untag 14 cards — nothing to do are both telling you about the table rather than about your handler.

Effect Does
tag(cards, "x") Add a tag. Most tags are yours to invent, but a few already mean something to the board.
untag(cards, "x") Remove a tag. Takes "x-*".
tap(cards) Turn them sideways. Clears any other rotation, so a card that was upside down ends up tapped rather than both.
untap(cards) Straighten them — shorthand for removing every rotation.
rotate(cards, 180) Turn them to a rotation — 0, 90, 180 or 270. rotate(c, 90) is tap(c) and rotate(c, 0) is untap(c); this is for the other two. Clears the rest, which a bare tag does not.
attr(cards, "counter1", n) Set a counter. A card carries three — counter1, counter2, counter3, read back as card.attr1, card.attr2, card.attr3. Any other name is a compile error, because it would be written onto the card and read by nothing. Absolute, not a change — use card.attr1 + 1.
addStats(cards, [1, 1]) Change the stat counter — a +1/+1 counter, and [-1, -1] takes it away. A change, not a size, and applied to each card from whatever it is now. See +1/+1 counters.
setStats(cards, [3, 3]) Set the stat counter to a size, and setStats(cards, null) takes the counter away. A size, not a change — and the only way to give a card a stat counter it does not already have. See +1/+1 counters.
note(cards, "text") Write the card's note.
moveCard(cards, dest, options?) Move them. dest is a zone() or a box(). { near: self } lands them beside a card instead of at the default spot (see Where a card lands); { bottom: true } puts them under a deck or pile rather than on top of it. Moving a card to a zone() also takes it out of whatever box it was in — that is how a card leaves one.
attach(card, target) / detach(card) Attach a card to another, or free it.
createCard("Title (set)") Put a token into play. See Tokens.
createBox({ title: "…" }) Make a snap box if it is not there already.
fx(card, "file") Play a visual effect on the card — fx(self, "quantum_slash_10.webp"). The name is an animation file, and the card menu's own animation picker lists every one; misspell it and the editor names the nearest.
log(anything) Write a line in the game log — and in the script console, where it stands out from the events around it. See Logging. A second argument puts a button on the line for another player: see offers.
tip(text, card?) Tell yourself something — a line only you see, and a gold ring on the card you name. See Logging.
chat(text) Say something in the Chat tab, as you. A button only, and one per press. See chat().
addButton({…}) Put a button on screen. See Buttons.
emit("name"[, target[, data]]) Announce something for onEmit handlers to answer. See emit / onEmit.
ask({…}) Ask a question and carry on. The answer arrives as an onEmit. See Asking.
askCard({…}) Ask which card. The eligible ones light up on the board; the pick arrives as an onEmit.

Some effects only work behind a button

The effects below are legal only in a button's do: body, or in an onEmit a button emits to. Everything above can go in any handler.

These are the ones you would be angry about if they happened on their own. Nothing should shuffle your deck, draw you a card or move your life total except as the direct result of you pressing something. Put them behind a button and they are yours again. The editor refuses them anywhere else.

Effect Does
draw(n, opts?) Draw n from the top of your deck. { fromBottom: true } takes from the bottom instead — the deck menu's Bottom Draw, for the cards that say so.
peek(zone, n, opts?) Look at the top n cards of a zone — a private look. Only you see them. { emit: "name" } runs that handler once they are face up. See Looking through a deck.
walk(zone, n, opts?) Turn the top n face up for everyone — what a card means by reveal. Takes the same { emit }. See Looking through a deck.
flipTop(zone) Turn the top card of a zone face up for everyone, where it lies — the deck menu's Flip Top. No panel opens and nothing moves; it is walk for exactly one card, for the cards that say reveal the top card of your deck and leave it there.
revealZone(zone, who?) / concealZone(zone, who?) Stand a whole zone open — your hand, a pile — to one player or to the table, and close it again. See Looking through a deck.
find(zone, options?) Open that zone so you can search it and drag a card out. { type, text } opens it already narrowed. See Looking through a deck.
shuffle(zone | cards) A zone shuffles the whole thing; a list shuffles just those cards among the places they already sit. See Looking through a deck.
randomCard(zone | cards) Have the engine pick one at random and shake it. Nothing comes back — see below.
target(card, options?) Start the targeting arrow from that card. You pick what it points at; emit: tells the script what it hit. See Targeting.
roll(sides[, count]) Throw the board's own dice — roll(6) for one d6, roll(6, 2) for two. The engine rolls them, so the table sees the same alert and the same log line it sees when a person rolls. See Rolling dice.
life(n) / otherCounter(n) Set your two player counters. Absolute, so a change is life(me.life - 3).

life(n) sets your total and can never reach anyone else's, whatever you write. The board draws a second counter beside it — the small one, which each game uses for its own thing — and that is otherCounter(n), read back as me.otherCounter. Both are absolute, so a change is otherCounter(me.otherCounter + 1).

randomCard(zone) is what a card means by at random. Hand it a zone or a list you narrowed to; the engine picks, writes a log line naming every card it was choosing between, and shakes the one it landed on. Nothing comes back — like a die roll, the answer belongs to the table rather than to the script, so pair it with a tip() if there is something to do next.

#grammar 1

// Discard a card at random.
onReady() {
  addButton({ text: "Random", at: self, zones: ["play-1"], color: "red", when: me.hand().length > 0, emit: "pick" });
}

onEmit("pick") {
  randomCard(me.hand());
  tip("Discard the card that shook.", self);
}

Asking

ask puts a question on screen and carries straight on. The handler finishes. When you answer, the answer arrives as an ordinary onEmit:

#grammar 1

onReady() {
  addButton({
    text: "Sacrifice",
    at: "hand",
    do: () => {
      askCard({
        text: "Sacrifice which creature?",
        from: zone("play-1").cards.where(it.meta.type == "unit"),
        emit: "sacrificed"
      });
    }
  });
}

onEmit("sacrificed") {
  if (event.ok) {
    moveCard(card, zone("pile-discard"));
    log(event.choice.title + " was sacrificed");
  }
}

This is the one part of the language that is not read top to bottom, and it is worth being clear about why. Everything here runs to completion — there is no waiting, no await, nothing that can pause halfway. A question you can answer is, by definition, a wait. So a prompt is not a value you get back; it is an event, the same as a button press, which is also a person answering a question.

In the answering handler Is
event.ok Whether it was answered at all. false if it was cancelled or dismissed — always check it.
event.choice The card picked, or the option chosen, or — with number: true — a whole number. null for a plain yes/no.
card The picked card, for askCard — the same card as event.choice, bound the way every other handler binds it.
event.data Whatever you put in data:, carried through untouched. This is how a prompt remembers what it was about.

ask can offer a list instead of yes/no, and the pick comes back as event.choice:

#grammar 1

onReady() {
  addButton({ text: "Charge", at: "hand", do: () => {
    ask({ text: "Which mode?", options: ["Draw a card", "Deal 2"], emit: "mode" });
  }});
}

onEmit("mode") {
  if (event.ok) { log("chose: " + event.choice); }
}

ask can also take a number, which is what an X costs:

#grammar 1

// {red:}{grey:X}: Deal X damage. The board has no damage, but the count is
// still the thing you have to work out and then remember.
onReady() {
  addButton({ text: "X", at: self, zones: ["play-1"], color: "red", emit: "pay" });
}

onEmit("pay") {
  ask({ text: "X is?", number: true, emit: "paid" });
}

onEmit("paid") {
  if (event.ok) {
    attr(self, "counter1", event.choice);
  }
}

number: true is what makes the answer arithmetic. Without it event.choice is the text of what you typed, so self.attr1 + event.choice on a card at 2 answered with 3 gives "23" — a real value, which sends a real action, and is wrong. With it you get a whole number you can add, compare and count with.

A number is not a list of options — a prompt is one or the other, and the editor refuses both together. An empty box is a cancel, and so is anything that is not a number; zero is an answer, and the two never collapse into each other. The answer is capped at 9999 either way, the same bound a stat counter has, so a slipped keypress cannot ask for a million.

value: fills the box in. The number arrives selected, so Enter accepts it and typing replaces it:

#grammar 1

// Remove target unit from the game. Its controller gains life equal to its power.
onEmit("hit") {
  if (event.ok) {
    moveCard(event.choice, zone("pile-expel"));
    ask({ text: "Life gained", number: true, value: event.choice.stats[0], emit: "life" });
  }
}

onEmit("life") {
  if (event.ok) { life(me.life + event.choice); }
}

Offer a number off a card; never take it. A creature's stat counter is what somebody remembered to put there — a card can be bigger than it says, and a card with no counter at all reads as zero. Reading stats[0] and acting on it asserts the board is right about the card. Filling it in and letting the player press Enter costs one keypress and is never wrong.

For askCard, from: is the cards that light up, and its ZONES are what the question is limited to. Say from: zone("pile-discard").cards.where(it.meta.type == "unit") and the creatures in your discard pile ring — but anything else in that pile can be picked too. What is outside the pile cannot.

That split is on purpose. Where a card is is structural: a question about your discard pile is not answerable by something in play, and letting it be would move a card out of a zone you never meant. What a card is is your reading of it, and Untap does not adjudicate — no stack, no legality check — so a prompt that refused the card the player meant would stop the game with nothing to click but Cancel. Write the filter so the right cards are obvious, and let the person at the table be right when you were not. from: may include your opponent's cards: looking is unrestricted, and what you do with the pick stays bounded by the same rules as everything else.

Both appear on the board's own notice line — the same strip that says "Select a card to attach to." — so the board stays visible and usable while you answer. The question sits in a purple box, and for askCard the cards you may pick are ringed in the same purple, so it is clear at a glance which ones the question is about. Escape gets you out of either.

While an askCard is open the rest of the board goes quiet: quick-action buttons are hidden, a whole card is a single click target so a counter or a stat cannot take the click instead, and the cursor is a pointer over the cards from: named and not-allowed over the rest. Inside those cards' zones that cursor means "not what was asked for" rather than "this does nothing" — the click is still taken. Clicking a card answers the question and nothing else — its menu does not open.

Telling the table what was chosen

A question is yours; its answer is sometimes everybody's. log: true puts what was chosen in the game log:

#grammar 1

// Remove a charge counter from Ember Blade: Choose one —
onEmit("spend") {
  attr(self, "counter1", self.attr1 - 1);
  ask({
    text: "Which mode?",
    options: ["Untap a resource", "Can't block", "+1/+1 counter"],
    emit: "mode",
    log: true
  });
}

onEmit("mode") {
  if (event.ok && event.choice == "+1/+1 counter") { addStats(self, [1, 1]); }
}

The log then reads “you chose Can't block — Which mode?”, with the usual S mark saying which card asked. A card picked with askCard is logged as the card itself, so the line is clickable like any other.

Off unless you ask for it, and it belongs on the questions a card poses — Choose one —, a mode, a kicker taken or not. A question you asked yourself while counting your own board is nobody else's business, and a cancelled question logs nothing at all.

Asking again, and again

A prompt can lead to another one — "discard down to five" asks once per card. The thing to know is that an effect does not reach the board straight away. When you move a card, the board still shows it where it was until the server answers, which is long after your handler has finished.

So a chain must not re-read what it just changed. data: is how it keeps count instead: put the list in, and it comes back in event.data as the same cards.

#grammar 1

onReady() {
  addButton({
    text: "Discard to 5",
    at: "hand",
    color: "red",
    when: me.hand().length > 5,
    do: () => {
      let hand = me.hand();
      askCard({ text: "Discard down to 5", from: hand, emit: "discarded", data: hand });
    }
  });
}

onEmit("discarded") {
  if (event.ok) {
    moveCard(card, zone("pile-discard"));
    let left = event.data.where(it.guid != card.guid);
    if (left.length > 5) {
      askCard({ text: "Discard down to 5", from: left, emit: "discarded", data: left });
    }
  }
}

Written the obvious way instead — asking again from me.hand() after each discard — every prompt would offer the card you just discarded, because as far as your script can see it is still in your hand.

One question at a time, per script — a second ask while one is open is dropped with a line in the script console. An askCard with an empty from: never opens at all (it would leave you clicking at nothing) but still answers, with event.ok false. And the editor refuses a prompt whose emit: no handler in the same script answers — a prompt's answer comes back to the script that asked and to no other, so that one is always a dead end.

+1/+1 counters

A card's size is drawn in its bottom-right corner — the stat counter, two numbers in most games — and it is not one of the numbered counters. A +1/+1 counter changes that:

#grammar 1

// Whenever you play a non-unit card, put a +1/+1 counter on this unit.
onReady() {
  addButton({ text: "+1/+1", at: self, zones: ["play-1"], color: "lime", emit: "grow" });
}

onEmit("grow") {
  addStats(self, [1, 1]);
}

addStats adds; attr sets. That difference is not a convenience — it is the only shape that works. Every creature starts from its own printed size, so one value cannot be right for a list of them: addStats(zone("play-1").cards, [1, 1]) gives each card one more than it had, which is what a card that pumps your team means. Reach for attr(card, "counter1", n) and you get the numbered counter chips instead — a different thing, drawn somewhere else, and not what anyone reads as a creature's size.

[-1, -1] takes it away. The list is as long as the game's stat counter — two numbers in most games, and [1, 0] is +1/+0.

Nothing takes it back for you. A counter is permanent, so addStats is the whole of it; something that lasts until end of turn needs an onTurnEnd that adds the opposite, and that handler is yours to write.

A card with no stat counter is left alone by addStats and the script console says so — adding to a card that has no size is not a thing that can be done. That is more common than it sounds: it happens when the game's auto stat counters setting is off, which is per player, and when the card database holds no printed size for the card — every resource card, for one.

A stat counter only exists in play. The engine clears it when a card leaves the play area and seeds it again on the way back in, so a card in your hand, your deck or a pile has stats of [] — always, whatever the card is printed as. So stats[0] on a card in hand is null every time, which is a prompt opening with an empty box rather than the number you meant to offer.

The two are a pair, and which one you want depends on where the card is:

Is Where it exists
card.meta.power the printed number, from the card database everywhere — hand, deck, pile, play
card.stats[0] the current number, counters included play only, and only where it has a counter

A card asking for the power of that card therefore takes the printed one and lets a live counter override it. That is right in all three cases: a card in hand, a creature carrying +1/+1 counters, and a board with auto stat counters switched off.

#grammar 1

onEmit("chosen") {
  let power = event.choice.meta.power;
  if (event.choice.stats.length > 0) {
    power = event.choice.stats[0];
  }
  ask({ text: "Damage", number: true, value: power, emit: "dmg" });
}

onEmit("dmg") {
  if (event.ok) {
    tip("Deal " + event.choice + " damage.");
  }
}

setStats(card, [3, 3]) is the answer to both. It sets a size rather than changing one, so it works on a card with no counter at all: it is how a resource card becomes a 3/3 unit, and how a script that means to add can give itself something to add to.

#grammar 1

// Until end of turn, this resource becomes a 3/3 unit.
onReady() {
  addButton({ text: "3/3", at: self, zones: ["play-1"], color: "lime", emit: "animate" });
}

onEmit("animate") {
  setStats(self, [3, 3]);
}

onTurnEnd("any") {
  setStats(self, null);
}

The two are easy to swap and the compiler cannot tell. [1, 1] is a legal size as well as a legal change, so setStats(zone("play-1").cards, [1, 1]) compiles and makes your whole board 1/1. If you mean a +1/+1 counter, it is addStats, always. setStats is for the cards whose text names a size: becomes a 3/3, is a 1/1 creature, base power and toughness 2/2.

setStats(card, null) removes the counter, which is the card menu's Remove Stat Counter. A card that is only temporarily a creature needs that on the way out, the same way any other mark does.

Tags the board already understands

A tag is a free-text label and most of them are yours to invent — sick, blocking, saved mean whatever your script decides. But seven tags are already wired to something the board draws, and they are how a script reaches things that look like they would need an effect of their own. They are the card menu's own items, written as tags:

Tag The card menu calls it What it does
pivot-90 Tap / Untap Turns the card sideways. Use tap() and untap() rather than the tag — they clear the other rotations, which a bare tag does not.
flip-face Alt Face / Default Face Shows the card's other face. This is transform: a double-faced card flips by gaining this tag and flips back by losing it.
face-down Face Down / Face Up Turns the card over. Nobody can read it, including you.
invert Invert Draws the card upside down — the table's convention for a card that belongs to the other end of it.
highlight Highlight Card Rings the card on everyone's board. The shared cousin of tip()'s gold ring, which only you see.
locked Lock / Unlock Freezes the card: it cannot be dragged, its menu collapses to Unlock, and its counters and symbols stop taking clicks. Not a decoration — check hasTag(card, "locked") before a script expects a card to move.
revealed:<name> Reveal to ▸ Shows this card to one player, wherever it is. The whole-zone version is revealZone. A bare revealed is everyone, which is what walk writes.

The last three are the ones nobody guesses, so they are worth a line each. highlight is how a script points at a card for the table rather than for you. locked is the only one that changes what a person can do rather than how a card looks. And revealed:<name> takes a player's name, which a script gets from a target() answer — so it joins with +, the same as any other text:

#grammar 1

onEmit("hit") {
  if (event.ok) {
    tag(self, "revealed:" + event.choice);
  }
}

So transforming a card needs no new effect. It is a tag, and the pair of buttons follow from reading it back:

#grammar 1

// A double-faced card: one button turns it over, the other turns it back.
onReady() {
  addButton({
    text: "Transform",
    at: self,
    zones: ["play-1"],
    when: !hasTag(self, "flip-face"),
    emit: "flip"
  });
  addButton({
    text: "Back",
    at: self,
    zones: ["play-1"],
    when: hasTag(self, "flip-face"),
    emit: "unflip"
  });
}

onEmit("flip") {
  tag(self, "flip-face");
}

onEmit("unflip") {
  untag(self, "flip-face");
}

The face flips; the card data does not. card.meta belongs to the card, not to the face you are looking at, so after a flip card.meta.type still reads the front. A back face that is a different card type cannot be tested for — write the script's conditions against hasTag(self, "flip-face"), which is the thing that actually changed.

The other three are ordinary marks with a picture attached, and the rule for clearing them is the rule for any mark: whatever turns a card face down owes it an untag somewhere.

Looking through a deck

peek(zone, n), walk(zone, n) and find(zone, options?) are the three buttons that show you something rather than change the board. They are the deck menu's Peep, Walk and Find Card, and they open exactly the same views.

#grammar 1

onReady() {
  addButton({ text: "Look 4",   at: "hand", do: () => { peek(zone("deck-1"), 4); } });
  addButton({ text: "Reveal 5", at: "hand", do: () => { walk(zone("deck-1"), 5); } });
  addButton({ text: "Search",   at: "hand", do: () => { find(zone("deck-1")); } });
  addButton({ text: "Land",     at: "hand", do: () => { find(zone("deck-1"), { type: "resource" }); } });
  addButton({ text: "Bolt",     at: "hand", do: () => { find(zone("deck-1"), { text: "bolt" }); } });
}

peek puts the top n in front of you and marks them as seen by you only — your opponent sees a log line saying you looked, never the cards. Reorder them or send them to the bottom by dragging, the same as if you had peeped by hand. find opens the whole zone with a search box.

peek is private; walk is public. When a card says reveal, it means the table sees it — that is walk(zone, n), which turns the top n face up for everyone and opens the shared panel. peek is what a card means by look at: you see them, nobody else does. They are one word apart in a card's text and opposite in what they do, and picking the wrong one is not visible from your own screen — the cards look exactly the same to you either way.

You cannot read what you just revealed. A reveal is a round trip: the effect leaves when your handler finishes, the server turns the cards over, and the board comes back after that. So for the rest of the handler that asked, those cards are still face down — and a face-down card has no name and no card fields at all, not even wrong ones.

onEmit("dig") {
  walk(zone("deck-1"), 4);
  let units = me.deck.top(4).where(it.meta.type == "unit");   // refused
}

That line is refused rather than left to fail quietly, because it fails backwards: it.meta.type != "unit" is true of a card with no meta, so an exclusion filter selects every hidden card in the zone instead of none of them. A pick built that way rings four card backs and asks you to choose between them.

Give the reveal a continuation instead. peek and walk take a third argument, { emit: "name" }, and that handler runs once the cards are actually face up — the same idea as target({ emit }), on the other effect with a moment worth hearing about. It is still one button.

Look at four, take one, put the rest back

The whole ability, from one press. Three handlers, because there are two waits in it — one for the server to turn the cards over, one for you to choose — and neither of those is a button.

#grammar 1

onReady() {
  addButton({ text: "Look 4", at: self, zones: ["play-1"], color: "blue", emit: "look" });
}

onEmit("look") {
  peek(zone("deck-1"), 4, { emit: "looked" });
}

// Runs when the four are face up, and not before.
onEmit("looked") {
  let looked = me.deck.top(4).where(hasTag(it, "revealed:*"));
  askCard({ text: "Take which?", from: looked.where(it.meta.type != "unit"), emit: "took", data: looked });
}

onEmit("took") {
  if (event.ok) {
    moveCard(card, zone("hand-1"));
    let rest = event.data.where(it.guid != card.guid);
    moveCard(rest, zone("deck-1"), { bottom: true });
    shuffle(rest);
  }
}

looked is filtered on the tag as well as the count, and that is not belt and braces. peek marks each card it turns over revealed:<you> and walk marks it bare revealed, so the cards you are looking at are exactly the cards carrying the tag. Take one out of the panel by hand first and the fourth slot is a card nobody has looked at — which “isn't a unit” is silently true of, for the same reason as above.

shuffle takes either a zone or a list, and they are different jobs. shuffle(zone("deck-1")) shuffles the whole deck; shuffle(rest) shuffles just those cards among the places they already sit and leaves everything else where it is — the pile menu's Shuffle N Cards. That is what “in a random order” means on a card like this one: three cards going back to the bottom, in an order nobody knows, and the rest of the deck untouched.

A list shuffle also turns them back over. Randomising the order of cards you are looking at hides nothing from you, so the cards it moves lose revealed, revealed:<you> and their walk marking, and the panel you were reading them in closes. One effect, because they are one thought: you are done looking.

A whole zone, standing open

peek and walk both take a count, off the top. A hand has neither. So “target opponent reveals their hand” is a third thing, and it is the pile menu's own Reveal Pile and Reveal N to:

#grammar 1

onReady() {
  addButton({ text: "Show Hand", at: "hand", do: () => { revealZone(zone("hand-1")); } });
  addButton({ text: "Show Bob",  at: "hand", do: () => { revealZone(zone("hand-1"), "bob"); } });
  addButton({ text: "Hide",      at: "hand", do: () => { concealZone(zone("hand-1")); } });
}

The second argument is one player's name, and leaving it off is the whole table. A target() with emit: answers with a player's name when the arrow lands on one, which is where the name usually comes from. Unlike a walk there is no panel and nothing to close: the reveal stands until concealZone takes it off, exactly as it does when a person reveals from the menu — because it is that.

A script names its own zones. zone("hand-1") is your hand, so revealZone is how you show yours, never how you open somebody else's. A card that makes an opponent reveal ends where every such card ends — at a sentence, with an offer on it that they press:

#grammar 1

// Target opponent reveals their hand.
onEmit("hit") {
  if (event.ok) {
    log("Hollow Egg: reveal your hand, and I choose a non-unit card for you to discard.", {
      to: event.choice,
      offer: () => { revealZone(zone("hand-1")); }
    });
  }
}

Their press, their board, their hand — and the button reads Reveal Hand because that is what the body does.

A walk is left open while you decide. Take what the card lets you take by dragging; closing the panel puts everything still there back face-down, so nothing needs to turn them over again.

find's second argument sets the two things you would otherwise reach for the moment the panel opened: type: turns on a type chip, and text: types into the search box. Either on its own, or both — { type: "unit", text: "drake" } is one button for the drakes in my deck.

type: is the same word the card carries in card.meta.type, and case does not matter. A type this zone holds none of is not an error: the panel opens on All Types rather than on an empty strip. text: is the search box, so it matches card names — and it takes the same :field=value filters the box does. That matters because tutors name a subtype far more often than a type:

find(zone("deck-1"), { text: ":subtype=drake" })   // every Drake, whatever it is called
find(zone("deck-1"), { text: ":subtype=charm" })
find(zone("deck-1"), { text: "ember :color=red" })  // name AND field, together

Any declared field works — :color=, :cost=, :power= — and the match is greedy, so :subtype=drak finds Drakes. The key is forgiving about plurals: :subtype=, :sub-type= and :subtypes= are the same request. Without it, searching a subtype by name finds Drakestorm and Drake's Fire, neither of which is a Drake, and hides every Drake not named one.

Both are only legal behind a button, for the obvious reason: a view that opened itself mid-turn would be worse than no view.

Only while it was played this turn

An enters play button is useful the turn the card lands and clutter every turn after. card.playedTurn is the turn it last entered the zone it is in — stamped by the game, not by you — so a button can say when it is worth showing:

#grammar 1

onReady() {
  addButton({
    text: "Target",
    at: self,
    zones: ["play-1"],
    when: self.playedTurn == turn.count,
    emit: "pick"
  });
}

onEmit("pick") {
  target(self, { type: "unit", who: "opponents" });
}

playedTurn is 0 for a card that was never stamped, and that catches more cards than it sounds like. The stamp is written when a card changes zone, and only from turn 1 onwards — so anything that reached the board during deck load or setup has none, and a deck list that starts cards in play gives every one of them playedTurn 0.

If the button never appears, this is usually why. Either the card has been in play since before turn 1, or it was played on an earlier turn — which is the whole point of the condition. Open the script editor and hover the card: the line at the bottom shows its playedTurn next to the current turn, so you can see which of the two numbers is not what you expected.

turn is readable inside when: even though the handler around it cannot see one. A when: is not run where it is written — it is kept and re-checked against the live game every time the board changes, which is exactly why a button can appear and disappear on its own.

Targeting

A great many cards say target creature an opponent controls. A script must never pick that target — but drawing the arrow is four separate motions, and target() makes it one press and a click.

#grammar 1

onReady() {
  addButton({ text: "Target", at: self, zones: ["play-1"], emit: "pick" });
}

onEmit("pick") {
  target(self);
}

The arrow starts on the card and waits. You click what it points at, exactly as if you had started it from the card's own menu — the arrow is drawn, sent, and seen by everyone at the table. Hold shift to keep going for a second target; press escape to give up.

Cards say target creature an opponent controls, not target anything, and the arrow can say so too. Anything outside what you asked for dims and stops taking clicks:

#grammar 1

onReady() {
  addButton({ text: "Target", at: self, zones: ["play-1"], emit: "pick" });
}

onEmit("pick") {
  target(self, { type: "unit", zones: ["play-1"], who: "opponents" });
}
Option Meaning
type A card type — "unit", or a list. Naming one takes players off the table, unless the list also names "player": that is how a card says "or a player". A game whose any target means a unit, a hero or a player writes ["unit", "hero", "player"], and writing it out is what stops the arrow landing on a resource.
zones Where it may be, as engine zone ids — ["play-1"], not ["play area"].
who "mine", "opponents" or "any". A card counts as belonging to whoever controls it, so a creature you have stolen is yours.
not A card the arrow may not land on. not: self is what a card means by another — the arrow stops offering the card that drew it. It takes a card, not a name: not: "self" excludes nothing, because nothing on the board is named by a string. Any card works, so not: event.choice excludes the one just picked.
emit Hear what the arrow hit — the answer arrives as an onEmit, like a prompt's. See below.
reason Why the arrow was drawn, in the card's own words. It goes on the arrow's line in the game log. See below.

“Another” — not:

A card that says another target unit you control is naming a filter, and every word of it belongs in the filter. Without not: the arrow offers the card that drew it, the handler has to refuse the click afterwards, and the board has already rung an illegal target as legal:

#grammar 1

// {grey:⤸}: Another target unit you control gains a shield until end of turn.
onReady() {
  addButton({ text: "Shield", at: self, zones: ["play-1"], color: "cyan", emit: "pick" });
}

onEmit("pick") {
  target(self, { type: "unit", zones: ["play-1"], who: "mine", not: self, reason: "shielded until end of turn" });
}

The excluded card is taken out of the legal set, not refused on click — so it is never highlighted, and the board's prompt says “Select another unit you control to target.” A restriction that is applied only after the player has clicked is not a restriction, it is an apology.

One card, not a list. An arrow excludes a single card. To exclude several — “units other than the two you chose” — take the extras out in the emit: handler, where a script can compare whatever it likes.

Saying what the arrow is for

An arrow on its own says something happened to that creature and nothing else. reason: puts the rest of the sentence on the log line it already writes:

#grammar 1

// {grey:⤸}: Target 1/1 creature gets +1/+2 until end of turn.
onReady() {
  addButton({ text: "Target", at: self, zones: ["play-1"], color: "green", emit: "pick" });
}

onEmit("pick") {
  tap(self);
  target(self, { type: "unit", zones: ["play-1"], reason: "+1/+2 until end of turn" });
}

The log then reads “you have targeted Stone Sentinel from Ashfen Grove — +1/+2 until end of turn”, and everyone at the table can see what the arrow meant rather than only that it was drawn.

This is what an until-end-of-turn effect gets instead of being performed. A script does not change a creature's size for one turn — unwinding it is bookkeeping nobody asked for — but the effect still happened, and the log is where it can be said. Keep it to the clause the card says; it is cut at 80 characters, and the log's own markup (#, ^, *) is stripped out of it so a reason cannot draw a card pill for a card that does not exist.

Hearing what the arrow hit

With emit: the arrow answers. That is what lets one button offer the whole legal set and still finish the job on the cards you control:

#grammar 1

// {grey:3}, {grey:⤸}: Put a +1/+1 counter on target creature.
onReady() {
  addButton({ text: "Target", at: self, zones: ["play-1"], color: "lime", emit: "pick" });
}

onEmit("pick") {
  tap(self);
  target(self, { type: "unit", zones: ["play-1"], emit: "hit" });
}

onEmit("hit") {
  if (event.ok && event.choice.mine) {
    addStats(event.choice, [1, 1]);
  }
}

event.choice is the card it landed on, event.ok is false if it landed on nothing, and a player answers as their name rather than as a card. Holding shift keeps the arrow going, so a card that targets three creatures answers three times from one press.

event.choice.mine is not optional. A script may not change a card someone else controls — the server refuses it — so without that check the handler tries anyway, the console fills with rejections, and the script claims work it did not do. A player's name has no .mine, so the same check reads false there, which is the right answer for every damage spell in the game.

This is the shape most "target" cards want. The script does the part that is mechanical and stops at the part that is a decision. It is also why target() only works behind a button: an arrow that seized the pointer on a trigger nobody pressed would be worse than no arrow at all.

Rolling dice

roll(6) throws one six-sided die; roll(6, 2) throws two. Sides run from 2 to 1000 and you may throw up to 50 at once. Two sides is a coin, and the board says so — it announces heads or tails rather than 1 or 2.

#grammar 1

onReady() {
  addButton({ text: "Roll", at: self, zones: ["play-1"], emit: "roll" });
}

onEmit("roll") {
  roll(6, 2);
}

The result does not come back. A roll is not a number your script asked for and gets to keep — it is a thing that happens at the table, announced to everyone and written into the game log, exactly as if you had rolled from the dice menu. Nothing in the language reads it, and that is deliberate: the engine rolls, so no client decides its own numbers, and there is one place a roll can have come from.

If a card needs to act on the result, that is a person reading the banner and pressing the next thing — or an ask({ number: true }) if you want the number in the script. Behind a button either way: roll() is one of the effects that only runs when somebody presses something, for the same reason shuffle() is.

Tokens

createCard names a card the way a deck list does:

#grammar 1

onReady() {
  addButton({
    text: "Emberling",
    at: "hand",
    color: "white",
    do: () => {
      createCard("Emberling");
    }
  });
}

createCard("Emberling (core)") asks for a printing by name, exactly as you would write it in a deck list — and it is read by the same matcher a deck list is. It does not have to be a token, and it does not have to be in your deck: anything in the card database can be created. Whatever is made arrives in play as a token, so it behaves like one and leaves the game when you are done with it.

The card that makes a token is what describes it. "Create a 2/2 Emberling with flight" is a fact about Hollow Egg's ability — the Emberling token in the database may be any size, and carries no useful text of its own. So say it:

#grammar 1

onEmit("die") {
  createCard("Emberling", {
    stats: [2, 2],
    notes: "Flight. Pay 1: this gets +1/+0 until end of turn."
  });
  moveCard(self, zone("pile-discard"));
}

The token arrives with its stat counter already set and its ability written on it, so nobody has to remember what it was. Both are optional, and both are also accepted inside the single-object form — createCard({ title: "Emberling", stats: [2, 2] }).

Symbols in a note work the way they do on a card. The best note is the card's own words copied across, markup and all — Untap stores them as {red:}, {grey:5}, {grey:⤸}, and they render as pips. If you are typing from memory instead, the shorthand everyone else uses is understood too: {R}, {G}, {U}, {B}, {W}, {C}, {T} and a bare number all become the right symbol.

Leave the set off and the card inherits it. A script on a core card that says createCard("Emberling") makes the core Emberling — the token matches the printing you are holding. That matters most for a script written once and carried by hundreds of different printings, which cannot know in advance which one it is on. Name a set and yours wins; name one that has no such card and the most-played printing is used rather than nothing at all.

Key Meaning
title What the card is called — "Emberling" or "Emberling (core)". Cards already on your table answer first; anything else is looked up in the card database.
set Which printing. Left off, it is inherited from the card whose script is running.
from A card you can already see, instead of a name — createCard({ from: card }) makes another of that card.
stats Its size as a stat counter — [2, 2]. Overrides whatever the printing says, because the card creating it is the authority.
notes Its rules text, written on the card. A token has none of its own worth reading.
count How many. One effect however many it makes, up to 12.
at Where they go. zone("play-1") by default.

New cards arrive tagged token, which is how the board knows to let you bin them at end of turn. You cannot give them tags of your own as they are created — the card does not exist yet to be tagged.

Where a card lands

A card a script puts into play arrives at the board's default spot, which for a creature fetched out of hand by the card that fetched it is nowhere near it. moveCard's third argument says where:

#grammar 1

// {grey:2}, {grey:⤸}: You may put a unit from your hand into play.
onReady() {
  addButton({
    text: "Put Unit",
    at: self,
    zones: ["play-1"],
    when: me.hand().where(it.meta.type == "unit").length > 0,
    emit: "put"
  });
}

onEmit("put") {
  tap(self);
  askCard({ text: "Put which unit into play?", from: me.hand().where(it.meta.type == "unit"), emit: "chosen" });
}

onEmit("chosen") {
  if (event.ok) {
    moveCard(event.choice, zone("play-1"), { near: self });
  }
}

near: self puts it beside the card that summoned it. The board nudges it clear of anything already sitting there, exactly as it does when you drop one card on top of another by hand, so two cards can share the same near: without landing in the same place.

near: also takes a position — near: [30, 60, 200], which is [top, left, board]: down, across, and the width they were measured against. Positions is the whole of it, including where those numbers come from without working any of them out.

It only means something moving into play-1. Nowhere else on the board has positions — a card in a pile or a hand is in an order, not at a spot — so a near: aimed anywhere else is dropped, and the script console says so rather than letting it look honoured.

Logging

Everything a card script does is marked in the game log. A small S sits at the end of the line — hover it and the card whose script did it rings on the board and shows in the preview. You do not write it and you cannot turn it off: a script acts as you, so the table is entitled to know when it was a script rather than your hand. Board scripts are not marked, because there is no card to point at.

log() takes anything, not just text, and prints it the way you would write it. A card is its title, a list is its contents, and a card's data is its fields — so log(card), log(zone("play-1").cards) and log(card.meta) all say something useful.

#grammar 1

onPivot("0->90") {
  log(card.title + " tapped — " + card.meta);
}

Which reads:

Stone Sentinel tapped — {cmc: 5, type: ["unit"], power: 2, colors: ["green", "white"], subtypes: ["golem", "guard"], toughness: 3}

Joining with + renders the same way, so "drew " + card is "drew Stone Sentinel" rather than [object Object]. The same goes for note(card, …).

The game log is shared. Everything log() writes is visible to your opponent, and so is a note. Long output is trimmed at 512 characters — log(zone("deck-1").cards) is a reasonable thing to type and a hundred card titles is not a log line. If you only want to see it yourself, the script console shows every log() too.

Telling only yourself — tip()

log() is for the table. It is sent to the server, every player sees it, and it stays in the replay — which is right when an opponent has to act on something, and wrong for everything else. A card reminding you that its own trigger just fired is not news to anybody else, and a deck full of cards each announcing themselves is not helpful, it is a log nobody can read.

tip() is the same sentence with none of that. It appears in your log only, it is never sent anywhere, and anyone who does not want it can turn it off.

Neither of them is you talking. If what you want is a sentence in the Chat tab under your own name — a GG button, a "one moment" button — that is chat(), and it works only from a button.

#grammar 1

// On the card.
onTurnEnd("any") {
  if (self.zone == "play-1") {
    tip("Returns to hand at end of turn", self);
  }
}

The second argument is optional and it is a card. Name one and it gets a gold ring until you click it — because a message about a card you then have to go and find is most of a message you did not get.

Tips can be switched off, from the chat panel's menu — Hide Script Tips. So write them for the player who wants the help, and never put anything in a tip that the game would not make sense without. If an opponent has to know, that is log().

Saying it out loud — chat()

log() is the game telling the table what happened. tip() is a note to yourself. chat(text) is the third one and the only one that speaks as a person: the line lands in the Chat tab, under your name, with a report button beside it, exactly as if you had typed it.

#grammar 1

onReady() {
  addButton({ text: "GG", at: "hand", emit: "say-gg", color: "blue" });
  addButton({ text: "Thinking…", at: "hand", emit: "say-wait" });
}

onEmit("say-gg") { chat("Good game!"); }
onEmit("say-wait") { chat("Thinking — one moment."); }

It takes one sentence and builds like any other string, so chat("I have " + me.hand().length + " in hand") says a real number. Anything past 255 characters is cut, which is the limit the chat box itself has — a script gets no more room than a person.

It only happens when you press something. chat() is gated to onEmit, so it belongs to a button and nothing else can reach it — a card in an onEnterZone or an onTurnStart cannot make you say a word. That is not a limitation to work around; it is the rule that makes the effect safe to have at all, because a card script is code somebody else wrote running in your browser under your name.

One per press. A second chat() in the same run is dropped and the console says so. Build the whole sentence and say it once — eight lines from one button is a flood, and the report button beside each of them points at you.

Never in an offer. An offer's body runs on the other player's board, as them, so a chat() in one would be words in their mouth under their name. The checker refuses it.

Giving them the button too — an offer

A script may only touch your cards. So a card that makes an opponent do something ends at a sentence: log() says what has to happen and they do it by hand. An offer puts a button on that sentence, on their screen, that does it for them.

It is not a request and there is nothing to accept. The line goes to the whole table exactly as before, the button is a shortcut for the one player it names, and doing it by hand stays just as possible. Ignoring it is not an event and nothing waits on it.

#grammar 1

onReady() {
  addButton({ text: "Send 3", at: self, zones: ["play-1"], color: "blue", emit: "pick" });
}

onEmit("pick") {
  target(self, { type: "player", who: "opponents", emit: "hit" });
}

onEmit("hit") {
  if (event.ok) {
    log("Ashfen Grove: that player puts the top three cards of their deck into the discard pile.", {
      to: event.choice,
      offer: () => { moveCard(me.deck.top(3), zone("pile-discard")); }
    });
  }
}

to: is who gets the button, and an offer must have one — a player's name, or "all" for every opponent at once. Without it the button reaches nobody, which is why it is a compile error rather than a quiet disappointment. On a plain log() with no offer, to: does nothing at all: a log line always goes to the whole table — that is what makes it the record — and tip() is the one only you see. A targeting arrow that hit a player answers with their name, so to: event.choice is the usual way to say that player. offer: is the body it runs — written as () => { … }, the same shape a button's do: takes, and the only other place that shape is legal. Inside the body, me is the person pressing it: their deck, their hand, their life. That is the whole point — the effect runs on their board, as them, and the server allows it for the same reason it allows anything they do themselves.

You do not write the button's words. Untap does, from what the body is about to do — moveCard(me.deck.top(3), zone("pile-discard")) reads Move 3 to Discard, and the place is named with your game's word for it: the same line reads Move 3 to Drop Area in Dragon Ball and Move 3 to Trash in Digimon. This is not a convenience: a button whose label its author chose could say Reveal Hand and exile a hand instead, and the person pressing it would have no way to know. Because the label has to describe the whole body, an offer is limited to three effects and may not ask, askCard, target or addButton — a body that stops to ask cannot be described before it runs. It also may not mention self: that is your card, and they do not control it.

When the number is worked out on your board — with:

Plenty of cards say each opponent loses life equal to something on your side. The effect is on their board, so it wants to be an offer — but the body is written when you write the script, and the number is not known until it fires. with: reads one number on your board at the moment the line goes out, and the body reads it as amount:

#grammar 1

onReady() {
  addButton({ text: "Drain", at: self, zones: ["play-1"], color: "blue", emit: "go" });
}

onEmit("go") {
  log("Hollow Egg: each opponent loses life equal to the number of units you control.", {
    to: "all",
    offer: () => { life(me.life - amount); },
    with: zone("play-1").cards.length
  });
}

With three units out, the button on their screen reads Lose 3 life — the value, never the word amount. A button that named a variable would not be saying what it does, which is the one thing an offer's label is for.

with: is an ordinary expression in the handler that is running, so it can be anything that handler knows — including an answer you were just given: with: event.choice after an ask({ number: true }) turns each opponent discards that many into a button. The body still may not mention event, and that has not changed: the body runs on their client, where the answer you gave does not exist. with: is read here, and only the number travels.

A number, and only a number. with: cannot bind text, a card or a list — just one number, at most 999. That is not an arbitrary restriction: a number cannot change which effect runs, only how much of it, so the words on the button are still derived from the body exactly as before. Anything else could put an author's own wording on their screen, and then Reveal Hand over a body that discards a hand becomes writable again. If with: reads nothing — an empty zone, a missing field — the sentence still goes out and the button is dropped, because a button whose number went missing must not be a button that runs without one.

with: and amount are one thing written in two places, so each is an error without the other: a with: nothing reads is a number computed and thrown away, and an amount with no with: is a button that would say Lose 0 life forever. One number per offer — two placeholders in one sentence is a sentence nobody reads.

An offer is shown once and pressed once. A player with card scripts switched off sees the sentence and no button, which is exactly what everybody saw before offers existed — so the sentence still has to say what to do. Replays show the line without the button: a recording is not something you can press.

Colours

Everything that takes a color takes the same thing — a button, a snap box, a die, a counter tray, a play counter, and a counter hanging off a box. Three ways to write one:

Write Get
One of the board's twelve swatches — red, orange, yellow, olive, lime, green, cyan, blue, lavender, magenta, grey, white Exactly the colour the right-click colour grid paints, so a red button and a red counter are the same red.
Any other CSS colour name — coral, teal, rebeccapurple, midnightblue… That colour. All 148 of them work.
A hex — "#e6194b" or "#e14" That colour. For matching your sleeves.

Where the two lists share a word, the board's own wins. Nine of the twelve are also CSS names, and the board's are softened for a table — its red is #e6194b rather than CSS's #ff0000, and its lime is #bcf60c rather than #00ff00. That is what they have always meant here, so nothing you have already written changes colour.

Case does not matter — "Orange" and "orange" are the same colour. A word that is not a colour at all is a compile error that names the nearest one.

You never choose the text colour on a coloured plate: a button's label, a box's title and a die's number are picked for contrast against whatever you asked for, so a yellow button gets black text rather than the white-on-yellow it would otherwise inherit.

What you asked for is what gets painted — the contrast is bought with the text, not by moving your colour. The one exception is a snap box's title spine, which is small, bold and turned on its side: that plate is deepened until white on it is comfortable, and the box's own border draws your colour undarkened right beside it. A counter hanging off the box is not deepened, so a counter you colour from something you read — an opponent's seat, say — is the colour you read.

Buttons

A button is declared in onReady — that fires on every refresh, and the button list is rebuilt each time.

#grammar 1

onReady() {
  addButton({
    text: "Untap all",
    at: "hand",
    color: "blue",
    do: () => {
      untap(zone("play-1").cards);
    }
  });
}

do: is the button's body — what pressing it does, written where the button is. It is not a function: this language has none, nothing can hold one and nothing can pass one on. () => { … } is simply how a block is written in a place that expects a value, and a button's do: is the only place it is allowed.

Inside it you have everything a handler has — me, card, box, the zones — but nothing from around it. A let declared in the onReady above is not in scope, and naming it is a compile error rather than a null. The body runs when the button is pressed, and reads the board as it is then.

…or emit, when more than one thing answers

The longer form announces a name and lets handlers answer it. Reach for it when two buttons should do the same thing, when something other than a press should trigger the same work, or when a card script needs to answer a button that is not its own.

#grammar 1

onReady() {
  addButton({ text: "Untap all", at: "hand", emit: "untap-all", color: "blue" });
}

onEmit("untap-all") {
  untap(zone("play-1").cards);
}

A button has one or the other, never both.

Key Meaning
text The label.
do What pressing it does — do: () => { … }. Private to this button: nothing else can reach it and it reaches nothing else.
emit Instead of do: the name announced when it is pressed, for onEmit handlers to answer. Without either, the button does nothing.
at "hand" (beside your quick actions) or "board" (on the near edge of the play area). Also box("Tools") for a row just above that box, or self on a card script.
left Board buttons only: how far across the board it sits, 0–100 as a percentage of the board's width. Defaults to 50, the middle.
color red, orange, yellow, olive, lime, green, cyan, blue, lavender, magenta, grey, white — or any CSS colour name, or a hex. See Colours.
tip A sentence shown when the pointer rests on it — what the button does, when the label has no room to say. Up to 120 characters.
zones Only show it while the button's card is in one of these zones.
when Only show it while this is true. Rechecked as the board changes.
#grammar 1

onReady() {
  addButton({
    text: "Clear marks",
    at: "board",
    when: zone("play-1").cards.where(hasTag(it, "sick")).length > 0,
    do: () => {
      untag(zone("play-1").cards, "sick");
    }
  });
}

A box can carry its own buttons. at: box("Lands") puts a small row just above that box, at its top-left corner — the one edge nothing else uses, since a box wears its title down the left and its counters along the bottom and up the right. The row comes and goes with the box: name a box that is not on the table and the button waits, out of sight, until there is one.

#grammar 1

onReady() {
  createBox({ title: "Lands" });

  addButton({
    text: "Stash",
    at: box("Lands"),
    tip: "Move everything turned sideways into this box",
    emit: "stash"
  });
}

onEmit("stash") {
  moveCard(zone("play-1").cards.where(hasTag(it, "pivot-90")), box("Lands"));
}

To take a card back out of a box, move it to a zone. moveCard(card, zone("play-1")) puts it back on the open table; moveCard(card, zone("pile-discard")) sends it to the pile. Either way it stops being a member of the box, exactly as if you had dragged it out. There is no separate effect for leaving a box, and untag is not it — a card's box is not one of its tags, which is why card.box is where you read it.

tip says what a button does. A label has to be short enough to sit in a row, which leaves nowhere to explain itself; a tip appears as soon as the pointer rests on the button, and works on any of them — hand, board, box or card.

A board button sits on the near edge of the table, and left says where along it. 0 is the far left, 100 the far right, and leaving it out puts the button in the middle. The height is not yours to choose: a board button straddles the edge, half on the table and half below it, so it reads as a tab on the side of the board rather than as something lying among the cards. Put one near the thing it is about — a pile, a box, the seat it belongs to.

#grammar 1

onReady() {
  addButton({ text: "Mulligan", at: "board", left: 15, emit: "mull" });
  addButton({ text: "Clear Stack", at: "board", left: 85, color: "orange", emit: "clear-stack" });
}

Two buttons that ask for the same spot share it, side by side — so a handful that never mention left still read as one row in the middle.

Prefer when to checking inside the handler. A button you can press that then refuses is worse than a button that is not there. If one never appears, the script log says which rule hid it.

There is a limit, and it is two limits. Your own scripts get 24 buttons across the hand and board strips, boxes included — that is a row of buttons on a screen, and no honest script comes near it. A card gets 6 of its own, counted per card rather than against the 24: a card script is armed wherever its card is, deck and hand included, so a deck full of scripted cards would otherwise use the whole allowance up before the first turn and the card you actually played would get nothing.

A button over either limit is not registered at all — there is nothing to press and nothing to see — so the script log says which one it was and which limit it hit. That is the only notice you get, and it is why the log is the first place to look when a button you wrote is not on the board.

Boxes & resources

A snap box is the titled outline you can drop cards onto. Scripts name one by its visible title.

#grammar 1

onReady() {
  createBox({ title: "Lands" });
  addButton({ text: "Stash tapped", at: "board", emit: "stash" });
}

onEmit("stash") {
  moveCard(zone("play-1").cards.where(hasTag(it, "pivot-90")), box("Lands"));
  box("Lands").addResource(1, "stashed");
}

createBox makes one only if a box with that title is missing, so it is safe in onReady. addResource(n, "label") finds or creates the counter hanging off the box and adds to it; removeResource subtracts. Renaming a box by hand breaks scripts that name it.

setResource(n, "label") is the value it should end up at, rather than a step — the same pair addStats and setStats are on a card. Use it whenever the number comes from somewhere else: box("Command").setResource(zone("pile-discard").cards.length, "In the pile") is a counter that always agrees with the pile, and there is no way to write that with a delta. A set to the number the counter already holds sends nothing.

You can read them back. box(t).resources is a list of { label, value, color, locked, reserved, empty }, and box(t).resource(x) is one of them — so box("Command").resource("Tax").value is what that counter says right now. Naming one that is not there reads null, like every other missing read.

Naming a counter

Everything that touches one counter takes the same x, and it can be any of three things:

Written Means
"Tax" The counter with that label. Labels are treated as unique and the first match wins. Nothing stops you putting two counters called "Tax" on a box by hand — this is a quirk to know about rather than a rule being enforced.
2 The counter at that place in box(t).resources — so resources[2] and 2 are always the same one. It is not the same as counting them on the board: an empty reserved slot is in that list and draws nothing.
r A counter out of resources. This one cannot be ambiguous and survives a rename, so reach for it whenever you already have it.

A text and a number are told apart by which they are, so a counter labelled "2" is "2" and the third one in the list is 2.

A label is 40 characters. A longer one is cut to 40 — by the board, and by every call that names it, so a counter made with a long name is still the counter addResource and resource() find. The script log says once what the name became, because the board is showing something shorter than what you wrote. cut(text, n) is there for when you would rather choose which part survives.

resource(x) gives you the counter; .value is its number. Doing arithmetic on the counter itself is a compile error rather than a surprise, because it would otherwise be a quiet zero — resource("Cast") * 2 multiplies an object, and this language floors that to 0, so the line would run, send, and set your counter to nothing every time. Asking whether a counter is there at all — resource("bob") == null — is fine.

A counter you changed earlier in the same handler still reads its old number. Effects are collected and sent when the handler finishes, so every read in it sees the board as it was when it started. addResource(1, "Cast") followed by a read of "Cast" gives the value before the one you just added — write the arithmetic out instead: (box(t).resource("Cast").value + 1) * 2.

addResource makes a counter that does not exist yet, and the one it makes is an ordinary counter — so it is written into this board's layout the next time you save. That is usually what you want for a tally the box always has. It is not what you want for a counter named after a person, and that is one keystroke away: see below.

A script can set everything the snap-box menu can. Right-click a box and you get a colour, a stack direction, an overlap, a minimum width, an alignment and counters — all of them are arguments here, so a script can build the board you would otherwise assemble by hand. One of them is not in that menu: maps, which turns the box into a window onto a pile.

Option Takes Same as
title text — required, and how box("…") finds it again Change Label
color "red", "blue", … any CSS colour name, or a hex — the same palette as a button. See Colours the swatch grid
pos [top, left] — board units, down then across. See Positions. dragging it there
pinned true fixes it where you put it Position: free / pinned
stack "vertical" or "horizontal" Stack
overlap 90, 50, 0, -50, -90 Overlap
space 0–4 quarter-cards of air between cards; only means anything at overlap: 0 Space
minWidth 1–6 cards; only means anything on a horizontal stack Min Width
align "start", "center" or "end" — which edge pos is, and so which way the box grows Align From
counters [{ label, color, value, locked }] — up to 12 slots, labels 24 characters. null is an empty slot. locked: true is a counter nobody can click, and { reserved: true } is a slot a script fills. + Counter
maps A pile- zone this game has — the box draws that pile instead of holding cards of its own. —
#grammar 1

onReady() {
  createBox({
    title: "Command",
    color: "lavender",
    pos: [20, 60],
    counters: [{ label: "Tax", color: "lavender" }]
  });

  createBox({ title: "Lands", pos: [8, 78], stack: "horizontal", overlap: 50, minWidth: 3 });
}

Counters sit in slots, and null leaves one empty. The first three hang along the bottom edge — left, centre, right — and the rest climb the right edge. Their place in the list is their place on the box, so a null is a gap: the counters after it move along one. Delete the null and press Apply and the gap closes again — a spacer holds nothing, so taking one out is the one thing in this list Apply will remove for you.

A counter is matched by where it sits in the list, because the declaration has no other handle on it. So editing a label renames the counter in that slot and it keeps its count — which is what you want when you are naming one — but closing a gap slides everything back a place, and the counter that lands in a slot is renamed to whatever the declaration says goes there. Take gaps out one at a time if the box is holding numbers you care about.

#grammar 3

box "Command" { pos: [20, 60], counters: [null, { label: "Charge" }] }

A box that draws a pile

maps turns a box into a window onto one of your piles. The cards stay in the pile — nothing is moved, and nothing is copied — but they are drawn on the table at the box's slots instead of as a stack beside it. It is for a pile the game treats as part of the board rather than as somewhere cards go: a row of face-down cards you turn over one at a time, a set of resources everyone can see, anything you would rather look at than click into.

#grammar 3

box "The Yard" { maps: "pile-discard", stack: "horizontal", overlap: 0, space: 1 }

Piles only, and a pile your game actually has. Write the zone's engine name — pile-discard here — not the title your game prints on it. A deck or a hand is refused: a pile is already hidden or shown per player by the server, so a box over one shows exactly what the pile shows — real cards where it is open, card backs where it is not, on everybody's screen including yours. Nothing about maps decides who can see what, which is the point of it.

The pile's own stack disappears from the row beside the board while the box is drawing it, because the same cards in two places is one place too many. Its menu comes with it: right-click the box and Shuffle, Find Card and the rest are there under the box's own settings. Take the box away and the stack comes straight back — the cards never went anywhere.

Cards go in and out by hand. Drag one onto the box and it moves into the pile, at the slot you dropped it. Drag one off and it is an ordinary move to wherever you dropped it. Only you can drop cards into your own mapped box — putting a card into somebody else's pile is not a move this table has.

One box per pile. Two would each draw every card in it, so the same card would appear twice and taking it out of one would leave the other copy behind; the compiler refuses the second one and names the first.

A counter for each opponent

reserved: true is a slot the declaration makes and a script fills. The box's shape stays in the text — how many slots it has, and where they sit — while what is in them does not. So a box can carry one counter per opponent without the people at this particular table ending up written into a board layout you keep and load into every other game.

#grammar 3

box "Damage" { pos: [20, 60], counters: [{ label: "Tax" }, { reserved: true }, { reserved: true }, { reserved: true }] }

onReady() {
  let dmg = box("Damage");
  for p of game.opponents {
    dmg.createResource(p.name, { color: p.color });
  }
}

createResource(label, { color, locked }) looks in three places, in order: the reserved slot already wearing that label, then the first empty one, then it adds a new one on the end. The first step is what makes this safe to leave in onReady — that handler runs again on every refresh, and without it each reload would put a second copy of everybody on the box.

An empty slot draws nothing. It is a real counter with a real id, holding its place, and it appears when a script gives it a name. So a box declared with four reserved slots looks like a box with one counter until the game has opponents in it, and it does not change shape when somebody leaves.

If there is no free slot it makes one, and that goes back into your declaration as a bare { reserved: true } — never as the name that is in it. Your layout learns that this box needs room for five, which is a thing worth remembering; it never learns who was sitting there, which is not.

Each fill is one effect, and a handler gets eight — so a six-player pod is five of them before you have done anything else. In the steady state that costs nothing on the board, because a slot already wearing the right name sends nothing, but it is still five against the cap. Keep the loop in a handler of its own rather than on the end of one that is already doing work, and the console will tell you if a run is ever cut short.

Giving a slot back

emptyResource(x) — x being a label, an index or the counter itself, as everything here takes — clears a slot's label, colour and tally and leaves it reserved and waiting. Nothing does this for you, because nothing tells a script that somebody has left, so a script that cares reconciles the slots against the table whenever it runs:

#grammar 1

onTurnStart() {
  let dmg = box("Damage");
  for r of dmg.resources {
    if (r.reserved && !r.empty && game.opponents.where(it.name == r.label).length == 0) {
      dmg.emptyResource(r);
    }
  }
  for p of game.opponents {
    dmg.createResource(p.name, { color: p.color });
  }
}

Both halves are safe to run over and over: one empties only a slot whose player has gone, the other fills only what is not already filled. Give the slots back first — every read in a handler sees the board as it was when the handler started, so a loop that reads the counters after changing them is reading the old list, and the editor will say so. The r.reserved test is not optional — without it the loop asks whether there is a player called "Tax", decides there is not, and empties the counter the box's own declaration put there. emptyResource refuses a counter that is not a reserved slot for that exact reason, but the test is what makes the loop say what it means.

Emptying clears the number too. A slot is the next one createResource will fill, and one still holding a 7 would come back under the next player's name showing 7 — wrong, believable, and impossible to trace afterwards.

Reacting to a counter

onResource(label) runs when a counter on one of your boxes changes value. However it changed — your script moved it, or you clicked it. That second half is the point: a counter is something a person prods, and until now nothing in a script could see one being prodded.

#grammar 3

box "Vault" { pos: [20, 60], counters: [{ label: "Charge" }, { label: "Spent", locked: true }] }

onResource("Charge") {
  box.setResource(event.to * 2, "Spent");
}

event.from and event.to are the numbers either side of the change, resource is the counter itself — the same { label, value, color, locked, reserved, empty } that box(t).resources holds, so resource.label is what it is called and resource.locked is whether anyone but a script can move it — and box is the box it hangs off — a real box, so box.title, box.cards and box.setResource(…) all work. Prefer box over naming the box again. It is the box the change actually happened on, so one handler serves every box that has a Charge on it, and renaming a box by hand cannot quietly stop it matching the way box("Vault") would. resource.value is the same number as event.to, because the handler runs against the board after the change. That is what makes this a better home for a derived counter than the line that moved the first one: a read inside the handler that did the changing sees the board from before it.

Pair it with locked. A counter a script keeps is wrong the moment a stray click nudges it, and there is nothing afterwards to say it was touched — so the derived one is locked and the one it is derived from is not.

It takes no scope argument, unlike the handlers above it. A snap box is always yours; there is no "any" to ask for. Which box is a line in the body: if (box.title == "Vault") { … }.

Only the number. A counter appearing is not a change to a number, and neither is a rename, a reserved slot being filled, or a counter going away — one handler, one idea, and from/to already covers adding, removing and setting. Loading a board helper is silent for the same reason: every counter on it is new, and none of them has changed.

Do not write the counter you are listening to. onResource("Charge") that changes "Charge" fires itself, four deep, and then the console says the depth budget stopped it. The editor warns about the obvious version before you run it.

A box that is still being built is waited for. Loading a helper puts its furniture on the board through the server, and your script arms before that comes back — so anything naming a box that has not arrived yet holds until it does, rather than failing on the one run that matters. That covers the counter calls and moveCard(…, box("…")), which is how a board helper puts a card back where it belongs on load. A box that never appears at all is reported, once, when it gives up.

A waiting move only carries the cards that stayed put. If you play or discard one of them while the box is still being built, it is left where you put it — a script's move is about the board it was looking at, and a card that has since gone somewhere else is no longer that card.

Board helpers only. A card's script cannot hear this — a counter on a box is not about any one card, and a hundred copies of a card would each hear every change.

A counter only a script can move

locked: true takes the counter off the board's click handlers. Clicking it does nothing, the -/+ row in its panel does nothing, and the three calls above are the only thing that still moves it. The lock shows on the label you get when you hover the token — which is where you will be looking, because you will have clicked it and nothing will have happened — and that same label opens the menu with Unlock in it.

It is for a counter a script is keeping. A tally that counts something is wrong the moment a stray click nudges it, and nothing about the number afterwards says it was ever touched. Lock it and the script is the only author of that number.

#grammar 3

box "Command" { pos: [20, 60], counters: [{ label: "In the pile", locked: true }] }

onEnterZone("pile-discard") {
  box("Command").setResource(zone("pile-discard").cards.length, "In the pile");
}

On a phone the counter panel shows a locked counter's number in its row rather than a −/+ stepper, with the same padlock — and its chevron still opens the menu with Unlock at the top.

It is a lock on the gesture, not on the counter. A script's change and a person's reach the board through the same action, so this is the board declining to send one rather than the game refusing to accept one. Lock a counter to stop yourself knocking it, not to stop anyone else.

That box has one counter, in the middle of its bottom edge, with nothing to its left. A gap only means something in front of a counter — one at the end of the list is dropped, and the editor says so.

Adding a gap to a box that is already on the table works too — type it in, press Apply, and the counters after it move along. It is the one thing about a box's counters that Apply can change, because inserting a gap moves nothing that is already there: every counter keeps its colour and its tally. Everything else about them — adding a counter, renaming one, recolouring one — still waits for the box to be built, and the console says so rather than pretending.

The sign on overlap is the fan direction, not a mistake: 90 fans so older cards peek up and left, -90 down and right, and 0 lays them out with no overlap at all.

space is the other half of that same number. Overlap pulls the gap between one card and the next below a card's width; space pushes it above. Each step is a quarter of a card, so space: 4 leaves a whole card of air between neighbours — a row of cards that reads as separate things rather than a hand. Because it is the one gap, the two cannot both apply: space only means anything at overlap: 0, the board offers it only there, and the checker warns if a box asks for both. The value is kept either way, so a box taken to overlap: 50 and back to 0 has the spacing it started with.

align says which way the box grows. A box's position is one point. With one card in it that point is the card, but a box that fills up has to spread somewhere, and align says where from: "start" anchors the top edge of a vertical box (or the left edge of a horizontal one) so it grows down, or right; "end" anchors the far edge so it grows up, or left; "center" anchors the middle and it creeps both ways, which is what every box did before this setting existed and is still the default. Reach for it when a box is lined up against something — a row along the bottom of your board, or two boxes side by side — where growing in both directions is what runs one into the other.

#grammar 3

// Sits on the bottom edge and fills upwards, however many go in it.
box "Discarded" { pos: [100, 20], align: "end" }

pinned: true fixes a box in place. Useful when a script has laid the board out and a stray drag would put a box somewhere its script does not expect. It stops the box being dragged and nothing else — the label still has its menu, so a pinned box can be recoloured, renamed, unpinned or removed like any other, and the menu's Position line says which it is. Anyone can pin or unpin their own boxes there whether a script is involved or not.

Anything the box does not have is a compile error, not a shrug. Write minwidth or stack_type and the editor says so and names the right one — because a setting the board quietly ignores leaves you with a box that looks wrong and no clue why.

Run it again on a box that already exists and it updates that box, in place. The cards in it stay in it, and so does anything the script did not mention: a script that never says pos: will never move your box, so you can drag it where you like and keep the colour the script gave it. What a script does name, it owns — say pos: and the box goes back there whenever the script runs, which is every refresh. pinned: true is how you say "and nobody move it".

Two things are never touched, for the same reason: they are the game, not the furniture. Cards in the box stay put — the box keeps its identity rather than being rebuilt, which is what would tip them onto the table. And a counter that already exists keeps its tally; a script naming a counter the box does not have creates it at zero, but one you have been counting on is left alone. Nobody's tally resets because they pressed refresh.

Changing a box's title is the one thing it cannot do — the title is how the script found the box in the first place.

Declaring the board

Everything you can put on the board from its own menu — a snap box, a die, a counter tray, a blank counter, a pile — can be written down instead. A declaration says what the board has. It is not something a handler does.

#grammar 2

box "Lands" { pos: [8, 78], color: "green", stack: "horizontal", overlap: 50, minWidth: 3 }
box "Relics" { pos: [30, 78], stack: "horizontal", overlap: 0, space: 2, minWidth: 3 }
box "Command" { pos: [20, 60], color: "lavender", counters: [{ label: "Tax" }] }
die "d20" { pos: [90, 20], sides: 20 }
tray "Resources" { pos: [10, 150], pinned: true }
counter "Storm" { pos: [50, 10], value: 0 }
pile "Sideboard" { pos: [94, 70], mode: "deck", pinned: true }

onReady() {
  addButton({ text: "Stash tapped", at: "board", emit: "stash" });
}

onEmit("stash") {
  moveCard(zone("play-1").cards.where(hasTag(it, "pivot-90")), box("Lands"));
}

The name is the title. It is how the board labels the thing, and how box("Lands") finds it again — so it goes in the header rather than being a title: that could go missing. Two declarations of the same kind under one name are a compile error, because to the board they are one thing.

Everything inside the braces is a plain value. No arithmetic, no variables, no calls. That restriction is the feature: a declaration is data, which is what lets the board edit it back — drag the box and the pos: in your script follows, and nothing else in the file is touched. There is no number to edit in pos: [20 + i * 30, 50], so that stays an effect.

Declaration Takes
box "…" everything in the snap-box table above except title
die "…" pos, color, pinned, sides (2–1000)
tray "…" pos, color, pinned
counter "…" pos, color, pinned, value, label
pile "…" pos, pinned, mode ("stack" fans like a discard pile, "deck" behaves like a deck), common (true makes it a common pile — everyone at the table can draw from it and put cards into it, for a shared deck or a market row)

A counter can be a bare number. The board has always had two shapes of play counter — one with a name and a value beside it, and one that is just a number — and label: false is the second. The name is still how a script and the board find it; it simply is not drawn on it.

pos is [top, left], down then across, in board units rather than percentages — see Positions, which also covers the hint line that prints them for you.

Every kind takes pinned. A pinned piece of furniture cannot be dragged, and that is all it stops — it keeps its menu, so it can still be recoloured, renamed, unpinned or removed, and the menu's Position line says which it is. Anyone can pin or unpin their own furniture from that menu whether a script is involved or not. It is worth saying on a board a script laid out: you put things somewhere deliberate, and a stray drag is what undoes that.

createBox() is still there and is not the same thing. The difference is how long it lasts.

Declared is your board. Created is this game only.

A declaration is furniture you keep: saved with the board helper, rebuilt every game, and the thing that follows when you drag it. createBox() makes one now, because something happened — in a loop, on a condition, in the middle of a game. It is never saved and it is gone when you reload; whatever made it makes it again.

So a button that opens a token bin does not turn into a bin you get every game, and a bin you meant to keep does not quietly disappear. Put it in a declaration to keep it; call createBox() to conjure one for the game you are in.

There is a practical difference too: effects are capped at 8 per run, so nine pieces of furniture built out of effects would quietly stop at the eighth. Declarations are not effects and have their own, much larger, limit of 32.

The rule is the same for all five kinds, but only a box can be conjured mid-game — createBox is the only one of these that is also an effect. A die, a tray, a counter and a pile are declared or not at all, so for them "declared is your board" is the whole of it.

Once a script declares boxes, naming one it does not make is a warning with a spelling suggestion — box("Land") where you declared "Lands" used to be a silent nothing that made the whole handler appear broken. A script that declares nothing is left alone, because naming a box you placed by hand is a perfectly good thing to do.

A card script cannot declare furniture. It is code somebody else wrote, arriving because a card was played — it may ask you to do things and act on its own card, but it does not get to rearrange the board it lands on. createBox() is still open to one, because a card that makes tokens and wants somewhere to put them is a real thing and that is a single box, asked for on purpose.

What Apply does to the board

Apply adds and adjusts; it never removes. Press Apply on a script that declares a box the board does not have and the box appears. Change a declared value — pos:, color:, sides:, a pile's mode: — and the piece on the table takes it. Delete a declaration and press Apply and its box stays: undeclared now, so it lasts this game and does not come back, but nothing tips the cards inside it onto the table. Text is edited far too casually for Apply to take things off a board — Remove Snap Box is one click away and says what it does.

A box's counters: are edited in the script like anything else. Rename one, recolour one, add one, open a gap in front of one — Apply carries all of it, and a counter that is only renamed keeps its count, because it is the same counter. The two Apply will not do are the two that would cost you something: taking a counter off the board (delete it from the box's own menu, where you can see what its tally was) and putting a counter where the board has a gap, which would shove everything behind it along. Either way it says so beside the Apply button rather than quietly leaving your edit undone.

One setting really is fixed when the piece is built: a play counter's label:, which chooses between a counter with its name written on it and a bare number. To change that, delete the counter from the board and Apply to rebuild it.

The board writes back. Drag a declared box and the pos: literal in your script follows it, and nothing else in the file is touched; Save keeps that. A box createBox() conjured is left out of the save — it was meant to last one game, and a save that promoted it would quietly turn "this game only" into "every game".

Positions

Two things take a position: a card being put into play (moveCard's near:) and a piece of furniture (pos:, on createBox and on every declaration). They are the same three numbers and this is how they work.

Only the play area has positions

A position is a place on the table, and the table is play-1. Everywhere else on the board is an order, not a place: a card in your hand is third from the left because it is third in the zone, and a card in a pile is somewhere in a stack. There is no coordinate to give them, so a position aimed at any other zone is dropped and the script log says so — rather than being accepted and quietly doing nothing.

The three numbers

A position is [top, left, board]. Down, then across — the opposite order to the x, y most people reach for, and the reason a position that reads fine puts a card somewhere surprising.

Number Is Runs from
top How far down the play area. 0 at the top edge to about 111 at the bottom.
left How far across. 0 at the left edge to 200 at the right.
board The width the first two were measured against. 200, and it is not a stacking order. Optional — leave it out and it is 200.

The two ranges are not the same, and that is not an oddity to memorise — it is the shape of the table. The play area is 200 across and 200 ÷ 1.8 down, so left runs to 200 and top only to about 111. Anything past an edge is brought back onto the board rather than written as given: a card put off the table cannot be dragged back, because nothing draws it there.

The third number is a scale, not a layer. Your board is not the same width as your opponent's, or as yours was yesterday on a different screen, so a position is stored as "these two numbers, measured on a board this wide" and every client scales it to its own. Write 200 — it is what the board itself uses, what the hint line prints, and what every position already stored on a card carries.

Where the numbers come from

You should never have to work one out. Open the script window and hover the play area: the line along the bottom prints the position under your cursor in exactly the form a script wants it — [30, 60, 200] — ready to copy. Hovering a zone, a box, a die or a counter tray names that instead, and the line keeps the last thing you hovered rather than clearing as you move away, because moving away is what you do in order to type it.

Hovering names a thing; alt+clicking it dumps it to the Devlog — every read a script can make of it, spelled as you would type it, beside its current value. A snap box answers with its position and its counters among the rest.

What takes one

#grammar 2

box "Lands" {
  pos: [86, 24]
}

onEmit("summon") {
  // beside the card that did it — the usual answer
  moveCard(card, zone("play-1"), { near: self });
}

onEmit("stage") {
  // or a spot you picked off the hint line
  moveCard(card, zone("play-1"), { near: [30, 60, 200] });
}

near: self — or any card — copies that card's own spot, which is almost always what you want and never goes stale. A literal is for a place on the table that means something in your own game: a staging row, a spot beside a box.

Two cards cannot share a spot

The board nudges. A card landing exactly where another one already sits is stepped down and to the right until it finds room — the same thing that happens when you drop one card on top of another by hand. So near: self from a card that summons three creatures gives you three cards fanned beside it, not three cards in one place, and you never have to space them out yourself.

Moving a card around the table is not an event. Positions are deliberately left out of what the runtime watches, so dragging a card — or a script placing one — never re-runs a handler. That is what stops tidying your board from setting scripts off, and it means a script cannot watch for a card being moved from one part of the table to another. Zones are what a script can see; places within a zone are not.

emit / onEmit

emit announces something; onEmit listens. It is how a button reaches a handler, and how one part of a script reaches another.

#grammar 1

// On a card: announce to the whole table's scripts when this one has three charges.
onAttr("counter1") {
  if (self.attr1 >= 3) {
    emit("charged", game, self.attr1);
  }
}

// Every copy of the card hears every copy's announcement, and says which one.
onEmit("charged", game) {
  log(event.source.title + " is charged (" + event.data + ")");
  fx(event.source, "quantum_slash_10.webp");
}

event.source is always whatever emitted — the card, when a card script did it — and without it the shared channel would be a room full of strangers all saying "charged". A button emits to its mount point, so a button on a box arrives with box in scope and one on a card arrives with card.

The optional second argument is the channel: self or game, written bare rather than quoted — or a card, so emit("pump", card) addresses one card's script in particular. Your own scripts hear everything by default and rarely need it; a card script hears only what is addressed to its own card unless its onEmit names game, which is why the listener above does.

The optional third argument is a value to send along, and it arrives as event.data — a number, some text, a card. It is how an announcement carries its how much without the listener having to go and read the board again.

Card scripts

A script can live on a card instead of in your editor. It arrives with the card, runs while you control it, and self is the card itself.

They are on by default. The switch is in the game bar's cog — Card Scripts › On / Off — and it is per game, so a game you would rather move every counter yourself in can have them off while the rest stay on. Turning it off unbinds the cards already in play, not just the next one.

#grammar 1

// On the card itself. `self` is this copy of it, not the printing.
onEnterZone("play-1") {
  tag(self, "sick");
}

onTurnStart("mine") {
  untag(self, "sick");
}

A button mounted on a card with at: self appears in the same column as quick actions — down the card's right edge, numbered, expanding to its label on hover. Same place, because it is the same affordance: the setting that chooses between them is one setting.

Four things work differently from a script you write in the editor, and all four are about this copy of this card:

"self" is the default, not a ceiling: a card whose text governs other cards — "whenever another creature dies" — opts into "mine" or "any" the same way any handler does.

Card scripts are written by moderators, on the card in the card database. Yours are the ones in your editor, and nothing here changes them.

You can open a card's script from the card's own menu in a game — Edit Card Script — and change it there, but only for yourself and only for this game. Try on this card arms what you have written on your own copies, so you can play the card and watch the Devlog. It is a place to experiment, not a way to edit the card: nothing in that tab is written to the card database or seen by anyone else, and closing it puts the card back to whatever it actually carries.

Worked examples

Turn reminder

#grammar 1

onTurnStart() {
  untap(zone("play-1").cards);
  log("Untapped. Draw for turn.");
}

Keep the discard pile tidy

#grammar 1

onEnterZone("pile-discard") {
  untag(card, "highlight");
  untap(card);
}

Count what is on the board

#grammar 1

onReady() {
  addButton({ text: "Count power", at: "hand", emit: "count-power" });
}

// Printed power, unless the card has a stat counter, which knows better.
onEmit("count-power") {
  let total = 0;
  for (c of zone("play-1").cards) {
    if (c.stats.length > 0) {
      total = total + c.stats[0];
    } else if (c.meta.power != null) {
      total = total + c.meta.power;
    }
  }
  tip("Total power on board: " + total);
}

Notice the loop only adds up — it issues no effects. A loop that acted on every card would hit the effect cap; the collection forms (tag(list, …)) do the whole list as one action instead. And the total is a tip, not a log: it is a number you worked out for yourself, and the table did not ask.

A scratch pile with a running count

#grammar 1

onReady() {
  createBox({ title: "Exiled" });
}

onEnterBox("Exiled") {
  box("Exiled").addResource(1, "count");
  log(card.title + " exiled");
}

onLeaveBox("Exiled") {
  box("Exiled").removeResource(1, "count");
}

A whole board helper

Everything above in one file, the shape a real board helper takes: the furniture it needs declared at the top, a remembered count, buttons in onReady, and handlers that do the tidying. It is #grammar 3 because it declares furniture and keeps state; a script that did neither would say 1.

#grammar 3

// A whole board helper: the furniture it needs, the marks it keeps, and the
// buttons that drive it. Resources collect themselves into a row as they are
// played, everything else is marked until my next turn, and a count of
// resources played this turn survives a refresh.

box "Resources" { pos: [86, 24], color: "olive", stack: "horizontal", overlap: 50, minWidth: 4 }
die "d6" { pos: [90, 180], sides: 6 }

state played = 0

// Buttons are declared here because onReady runs on every arm, refresh included.
onReady() {
  addButton({ text: "Untap row", at: box("Resources"), tip: "Straighten everything in this row", emit: "untap-row" });
  addButton({
    text: "Clear marks",
    at: "board",
    left: 85,
    when: zone("play-1").cards.where(hasTag(it, "sick")).length > 0,
    emit: "clear-marks"
  });
}

// A resource goes to its row; anything else arriving is marked until my next turn.
onEnterZone("play-1") {
  if (card.meta.type == "resource") {
    moveCard(card, box("Resources"));
    played = played + 1;
    if (played > 1) {
      tip("That is resource " + played + " this turn.", card);
    }
  } else {
    tag(card, "sick");
  }
}

// The marks and the count reset with my turn, not with the script.
onTurnStart() {
  played = 0;
  untap(zone("play-1").cards);
  untag(zone("play-1").cards, "sick");
}

// `box` is the row the button sits on.
onEmit("untap-row") {
  untap(box.cards);
}

onEmit("clear-marks") {
  untag(zone("play-1").cards, "sick");
}

Three things to notice. The count lives in state rather than on a counter because nobody else needs it, and it is reset by onTurnStart rather than left to climb. The Clear marks button has a when:, so it is not there when there is nothing to clear. And onEmit("untap-row") reads box rather than naming the box again — a button emits to its mount point, so the same handler would serve a second row too.

A whole card script

A made-up unit with four lines of text, each of which is a different mechanism: a mark to undo, an activated ability with a cost, a trigger about other cards, and a reminder. What it deliberately does not do is the part that is a decision — which card to discard — and the part that is somebody else's — the return to hand, which is one drag.

#grammar 1

// Ashfen Grove — a unit, 2/3, whose text reads:
//   Arrives ready.
//   {grey:⤸}, discard a card: Draw a card. Only if this has a +1/+1 counter on it.
//   Whenever another unit you control leaves play, put a +1/+1 counter on this.
//   At the end of your turn, if this is tapped, return it to your hand.

// "Arrives ready" — undo the board script's mark. Card scripts run after board scripts.
onEnterZone("play-1") {
  untag(self, "sick");
}

// The button shows only while the ability can be used: untapped, grown, and
// with something in hand to pay with.
onReady() {
  addButton({
    text: "Discard: Draw",
    at: self,
    zones: ["play-1"],
    color: "blue",
    tip: "Tap and discard a card to draw a card",
    when: !hasTag(self, "pivot-90") && self.stats.length > 0 && self.stats[0] > self.meta.power && me.hand().length > 0,
    emit: "activate"
  });
}

// The cost is a choice, so a person makes it; the draw waits for the answer.
onEmit("activate") {
  tap(self);
  askCard({ text: "Discard which card?", from: me.hand(), emit: "paid" });
}

onEmit("paid") {
  if (event.ok) {
    moveCard(card, zone("pile-discard"));
    draw(1);
  }
}

// "Another unit you control" — this handler opts out of the card-script
// default and hears every card I control; `card` is the one that left.
onLeaveZone("play-1", "mine") {
  if (self.zone != "play-1") { return; }
  if (card.guid == self.guid) { return; }
  if (card.meta.type == "unit") {
    addStats(self, [1, 1]);
  }
}

// The reminder is a tip, because the return is mine to do and nobody else's business.
onTurnEnd() {
  if (self.zone == "play-1" && hasTag(self, "pivot-90")) {
    tip("Ashfen Grove returns to your hand.", self);
  }
}

onLeaveZone("play-1", "mine") is the line that makes this a card about other cards. A card script's handlers are about self unless they say otherwise, so without the "mine" the handler would only ever hear the Grove itself leaving. With it, card is the card that left and self is still the Grove, which is why the first two lines check that the Grove is in play and that it is not the card that just went.

The when: reads self.stats[0] > self.meta.power for has a +1/+1 counter: the live number against the printed one, both of which exist on a card in play. Written against a counter the card might not have — self.attr1 > 0 — it would compile and never show.

Limits

These exist so a mistake costs you a warning rather than your game. All of them report in the script log when they bite.

Limit Value Why
Effects per handler 8 A collection counts as one — tag(zone("play-1").cards, "x") is a single effect however many cards.
Chain depth 4 Effects cause events, which run handlers. Four deep and the chain stops.
Rate 5 at once, then 1/s Per script, with a ceiling of 60/min and 20/s across the whole board. Both are counted per thing you did — one board change, one button press, one answered prompt — however many scripts and handlers it runs. So a board carrying forty scripted cards is not forty times closer to the limit than a board carrying one; arming all of them is one thing happening.
Steps per handler 2000 A backstop. The language has no loops that can run away.
Failures per handler 3 A handler that keeps throwing is switched off for the game. The rest of the script carries on.
Buttons in the strips 24 The hand and board rows, across all your scripts and the boxes they build.
Buttons on a card 6 Per card, and a separate budget: a card script is bound wherever its card is — deck and hand included, since zones: decides where a button shows and not whether it exists — so a deck of scripted cards would otherwise spend the whole allowance above before the game began.
Remembered values 16 state lines per script, with 200 characters for any one piece of text. They are kept in this browser, per game — see Remembering a value.
Text into the game log 80 A target's reason:, cut to the clause the card says. The log's own markup is stripped out of it too, so text a script wrote cannot render as a card, a player or a zone. A log() line itself is trimmed at 512.
Declarations per script 32 Boxes, dice, trays, counters and piles together. Not effects, so not counted against the 8.
Scripts per board helper 1 Everything the board does goes in one file, of up to 64 KB. A helper is the unit you load, so it is the unit a script lives in.
An offer 3 effects, 400 characters The label has to describe the whole body. with: binds a number from -999 to 999. See offers.
A prompt's number 9999 ask({ number: true }) caps the answer there, the same bound a stat counter has, so a slipped keypress cannot ask for a million.
Tokens per createCard 12 count: is one effect however many it makes, up to this.
Dice 2–1000 sides, 50 at once Both for roll() and for a declared die.
A button's tip: 120 It is drawn as one line over the board. The long version is a comment in the script.

Gotchas

== is not JavaScript's ==. A list on one side means "is this in it", and text compares without regard to case. That is what makes it.meta.type == "unit" find an artifact creature — see Card data. It also means != reads as "is not one of these", which is almost always what you wanted.

A prompt does not pause the handler. ask shows the question and the handler finishes; the answer arrives later, in a different handler. Nothing in this language waits — see Asking.

Handlers watch your cards by default. If a script seems dead, check the log — it says when a handler was skipped because the card belongs to someone else.

Zone names come from the game type. pile-discard, not graveyard or trash or whatever the rulebook says. The editor lists the real ones when you get it wrong.

attr sets, it does not add. attr(card, "counter1", 1) makes it 1, not one more. Write attr(card, "counter1", card.attr1 + 1).

onReady is not onGameStart. onReady fires every time the game arms, refreshes included — right for boxes and buttons. onGameStart fires once per round — right for a greeting or an opening ritual.

A tapped card is a tag. Untap models no combat, so hasTag(it, "pivot-90") is as close to "is it attacking" as the table gets. That is the honest mapping, and it is usually enough.

Two tabs, one runtime. Open the same game twice and only one tab acts, so nothing happens twice. The other still shows the log.

What does not exist

This is the list most first attempts get wrong. None of the following is part of the language, and every one is a compile error the editor shows before anything runs — usually with the replacement spelled out. Each row is asserted against the parser by the test suite, so this table cannot quietly go stale.

Not available Write instead
while, do, C-style for (let i = 0; …) for x of <list> — the only loop.
break, continue .where(…) to narrow the list before looping, or if inside it.
function, recursion, an arrow anywhere but a button's do: Nothing. A script is a set of handlers; there are no user-defined functions at all. () => { … } is a block, legal only as do:, and cannot be stored or passed anywhere.
.map(), .filter(), .forEach(), .reduce() .where(…), which binds each item to it. Nothing else on a list is callable.
.push(), .slice(), .toUpperCase() — any built-in method Nothing. Strings and lists have no methods; lists have .length and indexing.
Math, console, Date, JSON, window Nothing. There are no globals beyond what this page lists. log() writes to the game log.
x += 1, x++, x-- x = x + 1.
a ? b : c if / else.
try / catch, throw Nothing. A handler that fails is reported in the script log and disabled after three failures.
async, await, setTimeout, promises Nothing. Everything is synchronous. Waiting for a person is done with a button and onEmit.
import, require, fetch, any network access Nothing. A script sees the board and acts through the effects listed above. The only thing it can keep is a state value, and even that it reaches by name rather than through any storage of its own.
&, |, ^, << && and ||. There is no bitwise arithmetic.
card.power, card.cost, card.type Card data lives under card.meta.<field> — card.meta.type, card.meta.cmc.

Does exist and is easy to miss: list literals [1, 2], object literals { a: 1 }, indexing list[0], null, true, false, // and /* */ comments, and optional semicolons.

Cheat sheet

The whole language on one screen. If something is not here, it is not in the language.

GRAMMAR
  #grammar 1                  version stamp, first line, optional
  onX(...) { ... }            a handler — where every statement lives
  box "Lands" { ... }         furniture this board has          (grammar 2)
  state uses = 0              a value the script remembers      (grammar 3)
  let x = v   x = v           declare, assign
  if (c) { } else { }         branch
  for x of list { }           the only loop (parens optional)
  return                      stop this handler
  // line     /* block */     comments

OPERATORS
  + - * / %   == != < > <= >=   && || !
  + also joins strings.  < > <= >= against null are false; null counts as 0 in
  arithmetic; == and != are ordinary, so x != "unit" is TRUE of a missing x.

HANDLERS                                    in scope
  onGameStart()                             —            once per round
  onReady()                                 —            every arm, incl. refresh
  onTurnStart(who?)  onTurnEnd(who?)        turn         who: "mine" | "any"
  onEnterZone(zone, scope?)                 card zone event turn
  onLeaveZone(zone, scope?)                 card zone event turn
  onEnterBox(title, scope?)                 card box event turn
  onLeaveBox(title, scope?)                 card box event turn
  onPivot("0->90", scope?)                  card event turn
  onTag(tag, scope?)   onUntag(tag, scope?) card event turn
  onAttr(name, scope?)                      card event turn
  onResource(label)                         box resource event turn
  onEmit(name, channel?)                    event card box turn   channel: self | game

  scope: "mine" (default) | "any" | "self".  Filters take a trailing * wildcard.

  event.from .to                 zone left / entered; rotation or counter before / after
  event.tag                      the tag an onTag / onUntag matched
  event.source .data             who emitted, and emit()'s third argument
  event.ok .choice .data         a prompt's or an arrow's answer

READ
  card.title .zone .box .tags .attr1 .attr2 .attr3 .notes .mine .guid .meta.<f>
  card.stats                    [power, toughness]; empty list if it has none
  card.playedTurn               the turn it entered the zone it is in; 0 = never
  zone("play-1").cards          my zone, as a list
  box("Lands").cards .exists .resources
  me.name  me.life  me.otherCounter  me.hand()  me.deck.top(n)
  turn.count  turn.player  turn.mine
  self                          the card a card-script rides on
  list.where(expr with it)      narrow;  list.length;  list[0]
  hasTag(card, "x")             "x*" matches a prefix
  ==  != on a list means "is it in there";  text ignores case
                                it.meta.type == "unit"   card.tags == "sick"

DO
  tag(cards, t)        untag(cards, t)      tap(cards)  untap(cards)
  rotate(cards, deg)                        0 | 90 | 180 | 270; clears the others
  attr(cards, "counter1", n)                absolute, not a delta
  addStats(cards, [1, 1])                   a CHANGE to the stat counter, per card
  setStats(cards, [3, 3])                   the size itself; null removes the counter
  note(cards, text)    moveCard(cards, zone()|box(), { near: self, bottom: true }?)
  attach(card, target) detach(card)
  createCard("Title (set)", { stats, notes }?)   createBox({ title, … })   fx(card, file)
  createCard({ title | from, set, stats, notes, count, at })
  log(anything)                             log(text, {to, offer, with}) -> OFFERS
  chat(text)                                you, in the Chat tab. a press only, one per press
  addButton({ text, at, color, zones, when, do: () => { ... } })
  addButton({ text, at, ..., emit: "name" })  answered by onEmit("name")
  emit(name, target?, data?)                target: self | game | a card;  data -> event.data
  ask({ text, emit, options?, number?, value?, data?, log? })  -> onEmit: event.ok/.choice
                                number: true -> event.choice is a NUMBER
  askCard({ text, from, emit, data?, log? }) pick  -> onEmit: card, event.choice
  box(t).resource(x)  .resources
  box(t).addResource(n, x)  .removeResource(n, x)  .setResource(n, x)
  box(t).createResource(label, { color, locked })  .emptyResource(x)     x = label | index | resource
  box(t).resources -> [{ label, value, color, locked }]   locked: only a script moves it

DO — only behind a button (a do: body, or an onEmit a button emits to)
  draw(n)  draw(n, {fromBottom:true})       peek(zone(), n)  private, a look
  walk(zone(), n)  public, a reveal      peek/walk(zone(), n, {emit})  after it lands
  revealZone(zone(), who?)    a WHOLE zone, standing open; no player = the table
  concealZone(zone(), who?)   closes it again
  find(zone(), {type,text}?)  target(card, {type,zones,who,not,emit,reason}?)
                                not: self -> what a card means by "another"
                                emit -> onEmit: event.choice is the card, or a
                                player's name; check .mine before changing it
  shuffle(zone())  whole zone      shuffle(cards)   just those, and closes the panel
  flipTop(zone())  top card face up, in place    roll(sides, count?)  2-1000 sides, 1-50 dice
  randomCard(zone()|cards)    the engine picks, logs and shakes; nothing returns
  life(n)  otherCounter(n)    the two player counters; read me.life / me.otherCounter

OFFERS — the same line, with a button on it for the player it names
  log(text, { to: "all" | name, offer: () => { ... }, with: n? })
  amount                        the number `with:` bound — only inside a body,
                                and only where there is a `with:` to bind it
  The BODY runs on their board, as them: `me` is the presser, not you.
  You never write the button's words — they are derived from the effects the
  body will run.  So a body may not `ask`, `askCard`, `target`, `addButton`,
  mention `self` or `event`, or hold another offer, and is capped at 3 effects.
  `with:` is read on YOUR board at the moment the line goes out.

LIMITS
  8 effects per handler (a whole list counts as one) · depth 4 · 2000 steps
  5 effects at once then 1/s, 60/min per script, 20/s overall · 24 buttons
  an offer: 3 effects, 400 characters of body, |with:| <= 999

ZONE IDS are the game type's own: play-1 hand-1 deck-1 pile-discard
  sideboard-1 token-1 pile-expel pile-facedown ... (one game shown; every game differs)