Cards: A technical breakdown

This essay has been superseded by “Ink for show control”.

Table of Contents
  1. What Ink has in common with a deck of cards
  2. Card notation

Ink is a scripting language from Inkle Studios, makers of narrative fiction games ranging from murder mysteries like Overboard! and Expelled!, to interactive novel 80 Days (Time magazine’s game of the year) and 3D adventure game Heaven’s Vault. The language is designed to be easy to use and riff on, legible to a non-technical audience, and to support the branching structures you need to tell complex stories.

Inkle Studios founder, Jon Ingold, famously quipped that while some games make you ask “What am I supposed to do next?” and others may get to asking “What can I do next?”, their games aim to leave you asking, “What have I done?!?“. That sort of player experience feels right in line with a Space Center flight.

We’re going to use Ink to explore a snippet from a Space Center script, in part to highlight its legibility but also because I believe the deck of cards metaphor needs to be at least as good as Ink. Ink is the bar we need to clear if a different paradigm is going to have a chance at competing with something that already works and is award winning.

Later one, we will explore how that Ink script might be expressed in an alternative notation that is more structured, despite being less legible. Later on, I hope to unify the two approaches so that you have a literate way to describe a deck of cards with all their features that doesn’t feel like you’re talking to a computer.

There are a few Ink shortcomings relative to Space Center stories. One of the biggest is that it’s not really designed for extensibility. Once you’ve made your script, it’s compiled and set in stone. It seems to me like it would be a good idea to be able to dynamically assemble a flight’s story from plugins and other bits and pieces at runtime. Ink is also more of a markup language, designed for outputing pages of text. Space ships don’t need pages of text as much as imperative commands and “wait for game state X to change to Y” statements. This can be done in Ink, but it’s not as clear a fit as it could be.

What Ink has in common with a deck of cards

Ink models such “chains of possible cards” using relatively simple markup. It doesn’t think in terms of cards and decks, but knots and weaves. A knot is a unit of content. You can put branching choices in it by starting a line with *. You can jump from one knot to another by starting a line with ->. It supports inline variables and functions (though we won’t use that much here). A full primer on Ink is available in their writer’s guide.


// The following is an exchange from a Space Center mission after the crew has rescued an asylum seeker that is running from enslavement.

// This line (and the lines where Computer says something) is just plain text as far as Ink is concerned. The running game could interpret lines that break things up with colons like this into dialog that could be presented to a flight director to cue what they need to say at every step of the story.
// More technically eager readers might imagine an AI-generated character performing these lines. I'm not convinced that voice inflection and performance notes would work well here, but suffice it to say that whoever or whatever is performing the lines would be fed the speaker and what is being spoken.
// Inkle games will append tags like #audioclip:return_that_slave_now.mp3 that would reference audio clips for the game to play when we hit this line, or tags to indicate changes to the art of an on-screen NPC avatar.

Bounty hunter: I insist you return that escaped slave to me this instance! He belongs to me!

// We're going to jump into this confrontation section. It's broken into its own section so we can loop back on the same set of choices if the crew is indecisive.
-> confrontation

- (confrontation)

// What follows here is a list of choices. Choices could be chosen by a flight director, or we could set up conditions that determine which choice is selected based on the actions of the crew. For example, we could have a timer for 10 seconds on the "Deflect" choice below. If 10 seconds expires, that choice is activated.

* [Reject]
  "Where we come from, we don't believe in slavery. You can't have him!"
  // -> tells Ink to jump to the engage_in_combat knot below...
  -> engage_in_combat

* [Accept]
  "The safety of my crew matters more than this stranger, take him back!"
  -> abandon_asylum_seeker

* [Deflect]
  You do nothing for 10 seconds.....
  Bounty hunter: What will it be captain? Stay, or go?
  -> confrontation // Loops back to the top, though the deflect option has now been "played" and won't show up again.

- (engage_in_combat)

Bounty hunter: Very well.

// This is valid Ink for adding tags to a line. When running the flight, the software would see the #command tag and would run whatever command is associated with it.
// Flight software would need to build up a dictionary of all the things you need to be able to do as part of your flight. These would either be hard coded, or perhaps could be customized using something like Lua scripts.

#command:disconnect_short_range_comms

Computer: Line disconnected.

// Another command, this time with a parameter.

#command:wait #duration:10

// An alternative notation to this would be something like:
// >>> Wait 10 seconds
// The >>> calls out the script, and the software would know how to read and parse the command.

// Yet another notation for playing a sound effect, this time where the game engine knows that SFX means to play a sound effect. The #loop tag does just what you'd expect here.
SFX: red_alert.wav #loop

// This line shows how Ink can selectively modify text based on game state, the computer will say something different depending on whether shields are down or up
Computer: Warning: weapons are locked on this vessel. {shields_down ? Shields are down. : Shields are up.}

// Another knot, we won't actually get to this from the combat knot. The only way we can reach this is if we hit a line with `-> abandon_asylum_seeker`
- (abandon_asylum_seeker)

Bounty hunter: *Chuckles* Very good, captain. Transport the prisoner immediately. You have 5 minutes to comply before I destroy your ship and take you and your would-be "asylum seeker" with me back to the markets.

#command:disconnect_short_range_comms

Computer: Line disconnected.

Although the Ink script is quite literate, and with a bit of practice mission authors can understand exactly what’s going to happen, let’s explore what it could look like if we model it with a deck of cards. Here are what some of these cards would be from this exchange:

  • The bounty hunter’s first bit of dialog
  • A card for each of the accept/reject/deflect options
    • These cards would be added to a temporary deck (a hand of cards is really just a face up deck where you can see all the cards) that players choose from
    • Whatever card you choose is the next action that will happen
    • Automation could detect that certain actions correspond to one of the choices. For example, firing torpedoes at the bounty hunter would not be considered accepting or deflecting the demands. Even if it were by accident, if the crew fires weapons during this encounter, that would immediately choose the “reject” option.
  • A card for the “Engage in combat” knot and another card for the “Abandon asylum seeker” knot.
  • The “Deflect” card redirects back to the set of confrontation options, forcing a choice. Further deflection could be modeled as a “rejection”, and off we go into the combat sequence.

Each of the “Accept” and “Reject” knots themselves unlock further branches of story. If we kept writing, the “accept” knot could end up leading to combat anyway, if we choose to transport a bomb on to the bounty hunter’s ship instead. Or, we could give up the asylum seeker, only to later regret the decision and try to rescue them.

Of course, if we choose one choice, that will also exclude others. It’s not enough to say that “This card can be played if this other card has been played”, we need to also consider if we’ve made an exclusive choice. We can’t accept and reject the demand at the same time.

Ink’s scripting language elegantly expresses these situations, despite also not allowing for some content author to extend the scenario in a separate plugin. It’s entirely conceivable that some alternative story could throw in another card option: a mutiny aboard the bounty hunter’s ship, and we then go on an adventure to rescue others who would also seek asylum.1

Card notation

The Ink script is legible in the way that a movie script is legible. It has jargon and special syntax, but it reads top to bottom, left to right, in a way that makes the scene easy to understand, the choices clear, and the impact of alternatives easy to follow.

How might we describe the above scene in an alternative syntax that’s easy to read, write, and that builds up our card metaphors?

Here’s one example we can play around with. Each card has a name and a list of actions. We’re defining this using YAML, which is both human-readable and machine processable. It’s admittedly not as expressive as Ink, but it’s a starting point for figuring out how to define cards outside of just using Ink.

# This is not spelled out in the Ink example, but it's pretty easy for us to scope these cards to a specific "scene". This ID would be part of how we might uniquely identify these cards, e.g. perhaps we have a URL like nova://story=intolerance/scene=bounty_hunter_negotiation/card=encounter
#
# Those URLs are designed to make it easy to swap out or add keys in each hierarhcy level, so nova://story=intolerance&task=damage_step/... could also work as a prefix for damage steps that only belong in intolerance, or we could define damage steps without the story key.
#
# In the case of this document, we presume it's somehow embedded in the broader intolerance story, hence the prefix.
id: scene=bounty_hunter_negotiation
cards:
  encounter:
      actions:
          - dialog: # dialog is one of the actions we know how to perform. It has two parameters: speaker and text
              speaker: Bounty hunter
              text: I insist you return that escaped slave to me this instance! He belongs to me!
          - goto: confrontation # This will jump us to a card named confrontation

  confrontation:
      actions:
          - add_choice:
              id: reject
              selection_criteria: "TBD" # Selection criteria are not called out in the Ink example, but I'd like to note that we can specify how a choice becomes selected using criteria like this.
              actions:
                  - text: "Where we come from, we don't believe in slavery. You can't have him!"
                  - goto: engage_in_combat
          - add_choice:
              id: accept
              selection_criteria: "TBD"
              actions:
                  - text: "The safety of my crew matters more than this stranger, take him back!"
                  - goto: abandon_asylum_seeker
          - add_choice:
              id: deflect
              selection_criteria: "TBD"
              sticky: false # A sticky choice is one that can be chosen more than once when looping. In this case, we are saying that you can only deflect once. If you deflect, you don't get the option to deflect again.
              actions:
                  - text: "You do nothing for 10 seconds....."
                  - dialog:
                      speaker: Bounty hunter
                      text: What will it be captain? Stay, or go?
                  - goto: confrontation
          # draw_choices is not something we can really model in Ink.
          # The idea is that a plugin could define other choices that could be taken, perhaps tagging those choices with a certain label. If the selector on draw_choices matches these "expansion pack" cards, we can take up to three of them and mix them in as potential choices.
          - draw_choices:
              max: 3
              # Implied in this selector is that we'd look at the current scene and look for anything else that's defined under that potential scene. If a plugin were to define cards that are under the same namespace as the current scene, they'd be potential options for the player to choose from.
              selector: scene
          - await_choice # This step starts listening for one of these choices to be made. We continue from there.


  engage_in_combat:
      actions:
          - dialog:
              speaker: Bounty hunter
              text: Very well.
          - disconnect_short_range_comms
          - dialog:
              speaker: Computer
              text: Line disconnected.
          - wait: 10
          - sfx:
              file: red_alert.wave
              loop: true
          - dialog:
              speaker: Computer
              text: |
                  Warning: weapons are locked on this vessel.
                  {% if shields_down %}
                  Shields are down.
                  {% else %}
                  Shields are up.
                  {% endif %}


  abandon_asylum_seeker:
      actions:
          - dialog:
              speaker: Bounty hunter
              text: |
                  *Chuckles* Very good, captain. Transport the prisoner immediately. You have 5 minutes to comply before I destroy your ship and take you and your would-be "asylum seeker" with me back to the markets.
          - disconnect_short_range_comms
          - dialog:
              speaker: Computer
              text: Line disconnected.
          - draw_choices
            # draw_choices could be an action we throw in at the end of a set of actions, maybe by default. It would implicitly scope to the scene, allowing extension by other story authors in expansion packs in the same way that we handled our confrontation choices.
          - await_choice


--- # This calls out a new deck, this time for our mutiny plugin
# What's nice about this approach is that we don't have to modify the canonical Intolerance mission. Players or flight directors can choose which cards to load for a flight, so if they don't want these cards to be possible, they just don't check the box.
id: scene=mutiny
cards:
  beam_a_bomb:
    preconditions: # Preconditions could be on any card
      # When we're in the confrontation card collecting choices...
      - scope: nova://story=intolerance/scene=bounty_hunter_negotiation/card=confrontation
      # The current ship has a transporter system
      - has_component: transporter_system
    selection_criteria:
      - TBD # We'd express that the crew manages to beam a bomb on to the enemy ship here
    actions:
      - dialog:
        speaker: Tex
        text: Captain, are you sure about this? It seems like a real gamble to beam a bomb on to the enemy ship. We could be caught in the blast ourselves!
      - add_choice:
        id: accept
        actions:
        # This ditches this choice and rolls us back to whatever other stack of cards we happen to be coming from
        - discard_choice
      - add_choice:
        id: reject
        actions:
          - dialog:
            speaker: Tex
            text: Very good, captain. Let's do it. Let me get this bomb loaded into the transporter bay, then we'll await the bridge crew to power it up.
          # Left as an exercise for the reader: actions to wait for a period of time, then tex jumps back in with some dialog, then we wait for the transporters to beam out
  mutiny:
    preconditions:
      - scope: nova://story=intolerance/scene=bounty_hunter_negotiation/card=confrontation
      - time_remaining: # If we have a timer on the flight, we can use it to decide if certain side stories are even possible.
      # This allows us to write stories like accordions: we can tell them in 30 minutes or 300, skipping details, scenes, and subplots as needed to accomodate shorter run times
        amount: 90
        units: minutes
    selection_criteria:
      - roll_dice:
        oracle: d20 # We'd parse these terms to figure out what random number generator to use, here it's a 20-sided die
        eq: 20
    actions:
      - receive_lrm:
        text: |
          This is the crew of the bounty hunter's ship. We're fed up with our captain, and are about to mutiny. We need your help. Accept our captain's offer, and stall transporting the asylum seeker. When we lower our shields, beam our captain out of our ship and on to yours. We'll take over our own ship, then you can beam him back and we'll throw him in the brig.

          If this goes according to plan, we'll even help you rescue more slaves. What do you think?
      - add_choice:
        id: accept
        actions:
          # To be continued...

Well, that’s quite a lot of text. How readable is it? Is it preferable to the sort of thing you might read out of an Ink script?

From a programming perspective, YAML is easy to parse, easy to verify it’s in the right shape, and easy enough to work with.

From a human author’s perspective, YAML is probably not so good. It’s picky about things like whitespace, it’s not intuitive what putting a | at the end of a line means, and more.

Even so, I think the bones of this deck of cards metaphor is laid out here, including how a separate document can fold its contents into other documents, including in ways that avoid conflicts like having a mutiny from Intolerance show up in a different story altogether. Even if you load those cards, they can’t happen in a different story. If an expansion pack had cards designed for any mission, those would be playable as such.


  1. This admittedly might be a stretch goal. Starting off with just Ink to prove the concept could be good enough for now. However, this essay is about emulating Ink using a deck of cards, so we’re going to roll with that.