Skip to content

How a deal is decided

Core concepts gives you the words. This page is the mechanics behind deal and peek. It says exactly which cards are considered, in what order, and why a card that looks right sometimes doesn’t come up. It’s the page to read when the Board’s Not listed · why fold names a reason and you want to know what that reason means.

At any moment, each box has a stock, every card that could be dealt right now, in ranking order. It isn’t stored anywhere. The engine works it out whenever you ask, and it changes as state changes. A peek shows you the top of the stock, and a deal takes cards from it into a hand.

When your game deals a hand, the hand’s own condition is checked first, once. If it fails, nothing is dealt and no card is looked at, so the trace for that deal is empty rather than a list of refusals. (A peek has no hand condition.)

Every card in the box then goes through the same checks, in this order, and stops at the first one it fails:

  1. Deck gate: the card’s deck has a condition and it’s false.
  2. Cooldown: the card was played recently and its redraw policy says not yet.
  3. Tags: the card’s tags don’t match what the hand asked for.
  4. Condition: the card’s own condition is false.
  5. Claims: every copy of the card is already sitting in some hand.
A deal first checks the hand's condition once; if it passes, every card goes through five checks in order (deck gate, cooldown, tags, its own condition, and claims) and stops at the first one it fails. Whatever survives is ranked, and the top of that is the stock. hand condition once, for the whole deal fails passes nothing is dealt every card, in order: deck gate cooldown tags condition claims ranked: the stock a card stops at the first check it fails, and the trace names that one

The order matters when you’re debugging. The trace reports the first check a card failed, so a card that’s both outside its deck’s gate and on cooldown is reported as deck-gate, and fixing the cooldown won’t make it appear.

If a condition can’t be evaluated at all (it reads a property that doesn’t exist, say), the card is treated as unavailable and the reason is recorded. It never quietly passes.

The cards that pass every check are put in order by:

  1. Priority comes first. It’s a number you set on the card, or an expression that works one out, and higher goes first.
  2. Specificity breaks ties. A card whose condition asks for more beats one that asks for less, so the special case wins over the general one. It’s on by default and you can switch it off per box, in which case priority decides outright.
  3. Chance decides anything still tied. The random numbers are seeded, so the same seed gives the same order every time, in every runtime.

A hand with a slot limit takes the top few. With no state change between two deals, you get the same cards.

There’s one copy of every card unless the card says copies: N. A dealt card is claimed by the hand holding it, so a card sits in at most one hand at a time (and at most once in any one hand), exactly as a physical card can’t be in two places at once.

You don’t configure this. deal claims, and peek only respects the claims that exist. Playing a card removes it from its hand and releases its claim. The slot stays empty until that hand is next dealt.

This is what makes the “one rumour, offered wherever the player goes first” pattern free. Write one card, and whichever hand deals it first has it.

Claims live on the flow, so “at most one hand at a time” means at most one hand in that playthrough. Run several flows and a copies: 1 card is on two participants’ boards at once, because each of them is playing their own copy of the deck.

Unless you say otherwise. Mark a deck (or a single card) shared and its claims count across every flow instead, so there’s one goblin in the whole world, held by whoever was dealt it first, and nobody else can be dealt it until they play it or it leaves their board. sharedCopies sets how many may be out anywhere, defaulting to copies, so copies: 1, sharedCopies: 5 is five golden tickets with one to a customer. A card refused because somebody else holds it says so. The trace verdict is claimed-elsewhere, not claimed, because your own board has room.

A single-flow game never sees any of this, because with one playthrough open a shared claim and a per-flow one are the same thing.

The clock is the turn, and each box has its own. Your game advances a box’s turns, and a play also advances the played card’s box by the project’s configured amount (one by default, and you can override it per call). Nothing here runs off a frame, and a turn is whatever your game says it is.

A card’s redraw policy is its cooldown, measured in its own box’s turns: always (no cooldown), never (a one-shot), or a number N (unavailable for N of that box’s turns after it’s played). Cooldowns start when a card is played, not when it’s dealt, and a peek never touches any clock.

Clocks and cooldowns are per flow as well, so advancing a box in one flow moves nothing in another, and a one-shot spent by one participant is still there for the next.

On a shared card, redraw: never is the exception. The first participant to play it takes it out of the world for everyone, permanently, and the others are told taken rather than cooldown (they have no cooldown, because it isn’t there any more). A finite redraw stays personal even on a shared card, and that combination is a good rule rather than a gap. The goblin goes straight back in the pool for whoever is next, while the participant who just fought it waits their own three turns. There is no shared clock to count anything else against, so a world-wide timer belongs in @world, where your game already keeps the time (@world.now >= @world.goblin_returns_at).

And never is the one that can outlive the run. Mark a deck or a card durable and its never spend is still spent tomorrow, for whoever played it, or for everyone if the card is also shared. See Durable state. Nothing else can carry, because a finite cooldown is a turn of a clock that resets with the run, so durable on one is a compile warning.

A box can declare that its turns are time rather than plays:

box.storyletbox
{ gameId: "street", title: "The Street",
turn: { seconds: 60 }, // one turn a minute of the run
... }

That makes it a timed box, and three things follow. Plays in it advance nothing, because the project’s play-advance setting doesn’t apply, though a play that names advanceTurns still gets what it asks for. Your game ticks it, once every seconds of real time, for every open flow, and the engine still has no clock of its own. And redraw: N on its cards reads as N minutes rather than N plays, which is the point of declaring it. redraw: 30 in a sixty-second box is half an hour, and the editor, the bundle inspectors, and the coverage report all say so out loud.

Nothing else changes. The unit is a convention the tools spell out, and the time is still per flow, so “the goblin comes back for everyone after half an hour” is a job for @world as above.

A hand template is a kind of hand you define once: “NPCs you can talk to” fixes some tags, leaves others for each hand to choose, and carries one condition that’s written once and evaluated for each hand against that hand’s own @hand. Edit the template and every hand that uses it follows. A hand can override only its slot count. A hand can also stand alone, with its own tags, condition, and slots written directly on it.

Your game never names a template. It deals hands.

@hand is put together fresh for each deal, from three sources, later ones overriding earlier ones where names collide:

  1. The properties of every tag the hand binds. These are usually declared on the tag group, so every tag in it has them, and each tag sets only its own starting value, though a single tag can also declare its own. Either way, a hand bound to zone = forest sees @hand.peril.
  2. The hand’s own properties come next, declared on the hand or its template.
  3. What the deal or peek asked for, by group name. peek("village", { npc: "elder" }) makes @hand.npc read elder. A hand that pins a group itself reads the same way.

Every name remembers where it came from, so a write goes back to the right place: @hand.peril = @hand.peril + 1 raises the peril of the zone the card was dealt into. That’s how place-like state works without a separate scope for places. A quality works the same way, so each place can sit at its own stage of one shared ladder, and advance(@hand.haunting) moves only the place the hand belongs to.

@hand names are checked when you publish, the same as any other scope, so a misspelt @hand.perl is an error rather than a card that quietly never comes up, and a comparison against the wrong type or a stage that isn’t on the ladder is caught too. What can’t be worked out ahead of time is which hand will be asking, so the Links window and storyletengine links still leave @hand relationships out rather than guess. One name is read-only. A group’s own name is what the hand asked for, so writing @hand.zone is refused.

Every deal and peek emits a trace event listing each card the engine considered and the reason it was kept or dropped: dealt, capped (ranked but outside the hand’s slots), cooldown, deck-gate, tags, condition, priority, claimed, claimed-elsewhere (another playthrough holds the world’s copies) and taken (a shared one-shot spent, by anyone, for everyone). The Board’s Not listed · why fold shows it. In your game, subscribeTrace streams it and a retained log keeps it, as Dev tools describes.

Open source under the MIT licence, made by .