ink lists are hell

Ink is a dialogue scripting language that I absolutely loathe, but to be fair to them it can be difficult to design a scripting language meant for users who are primarily non-technical. Recently, I was reading their documentation so I could help someone with it and I learned how lists work in Ink. Here’s what they look like:

LIST colors = red, green, blue

If you have a technical background like me, you might assume that this defines a new variable that is of the type list which contains the values red, green, and blue in it. Is this what Ink does? No.

LIST colors = red, green, blue
{colors} // prints nothing

Oh, okay, it’s not set to anything… interesting. So… colors is null? How do we set it to a value?

ink lists are enums

Well, that’s because Ink lists are actually enums. See, you can set the value of colors:

LIST colors = (red), green, blue
{colors} // prints "red"
~ colors = green
{colors} // prints "green"

“Oh okay,” you say, “So we’re saying that colors can be none of those values or one of those values and we set it to red. Kind of weird they use the LIST keyword for that though, isn’t that just an enum type? Why don’t they call it an enum instead of a list?”

Well, yeah, I mean, it’s kind-of like an enum. You can do what you might expect for enum types like ask what values colors could possibly have:

LIST colors = red, green, blue
{LIST_ALL(colors)} // prints "red, green, blue"

As an aside, you can also declare more variables that use the same enum, like this:

LIST colors = red, green, blue
VAR colors_a = red

You can see here that both colors and colors_a share the same enum type, since they both have the same possible values:

LIST colors = red, green, blue
VAR colors_a = red
{LIST_ALL(colors)} // prints "red, green, blue"
{LIST_ALL(colors_a)} // prints "red, green, blue"

It just so happens that LIST colors actually does two things:

  • Declares an named enum with those potential values
  • Declares a named variable with whatever value was assigned

You can also get the next and previous enum values by incrementing or decrementing them like numbers:

LIST colors = (red), green, blue
{colors} // prints "red"
~ colors++
{colors} // prints "green"
~ colors--
{colors} // prints "red"

Wait a second, it’s like a number?

ink lists are numbers

Oh, okay, so ink lists are kind-of like enums, but they’re also kind-of like numbers. Can we see what numbers are assigned to each enum? You sure can, because ink lists are kind-of like numbers! You can use LIST_VALUE to convert the value into a number:

LIST colors = (red), green, blue
{LIST_VALUE(colors)} // prints "1"
~ colors++
{LIST_VALUE(colors)} // prints "2"
~ colors++
{LIST_VALUE(colors)} // prints "3"

And you can convert a number back into the enum too:

LIST colors = red, green, blue
colors = colors(2)
{colors} // prints "green"

As you can see, ink lists are 1-indexed and numbers are assigned sequentially. Can you assign numbers to the possible values? You can! Here’s what that looks like:

LIST colors = red=1, (green=3), blue=5

You can also compare them!

LIST colors = red=1, (green=3), blue=5
{ red < green } // prints "true"
{ green < red } // prints "false"
{ red == blue } // prints "false"
{ colors <= green } // prints "true"

Great, so… it’s a number, right? We should be able to add the difference between different possible values to get to the respective numbers:

LIST colors = red=1, (green=3), blue=5
~ colors += 2
{colors} // prints "blue"
~ colors -= 4
{colors} // prints "red"

Nice! Okay, and obviously, since adding two numbers is the same as adding the sum of the numbers, you should get the same result, right? No; they’re only kind-of like numbers:

LIST colors = red=1, (green=3), blue=5
~ colors++
{colors} // prints nothing
~ colors--
{colors} // still prints nothing

In fact, whenever you do some arithmetic and the resulting value is invalid, it’s shunted off into the void and replaced with 0.

LIST colors = red, green, blue
{colors} // prints nothing
{colors < green} // prints "true"
{LIST_VALUE(colors)} // prints "0"

“Ah, okay,” You say once again, as you recline in your chair, “So that’s why they use 1-indexing. It’s because they’re using 0 as a null-like value that you can’t do anything with.”

But you’re wrong! Ink is perfectly happy letting you use the value 0, because we can do that ourselves just fine:

LIST colors = (red=0), green=1, blue=3
{colors} // prints "red"
{LIST_VALUE(colors)} // prints "0"
~ colors++
{colors} // prints "green"
{LIST_VALUE(colors)} // prints "1"

…but the moment the value is invalid it becomes the 0 we can’t do anything with instead of the 0 we can do something with:

LIST colors = (red=0), green=1, blue=3
{colors} // prints "red"
{LIST_VALUE(colors)} // prints "0"
~ colors++
{colors} // prints "green"
{LIST_VALUE(colors)} // prints "1"
~ colors++
{colors} // prints nothing
{LIST_VALUE(colors)} // prints "0"
~ colors++
{colors} // prints nothing
{LIST_VALUE(colors)} // prints "0"

Well, okay, to be pedantic we can still do something with the 0 by converting back into the enum:

LIST colors = red=0, blue=2, green=3
{colors} // prints "red"
{LIST_VALUE(colors)} // prints "0"
~ colors++
{colors} // prints nothing
{LIST_VALUE(colors)} // prints "0"
{color(LIST_VALUE(colors))} // prints "red"

Anyway, since ink lists are enums that are also numbers, you can of course add and subtract them!

LIST colors = (red=1), green=2, blue=3
{colors} // prints "red"
~ colors += green
{colors} // prints "red, green"

Wait a second —

ink lists are lists

“That’s right! Have you forgotten?” I say, shaking you awake by the shoulders. When Ink says LIST it’s definitely a list. Like, kind-of. You can of course assign lists multiple values…

LIST colors = (red), (green), blue
{colors} // prints "red, green"

…perform some straightforward list operations…

LIST colors = (red), (green), blue
{LIST_COUNT(colors)} // prints "2"
{LIST_MIN(colors)} // prints "red"
{LIST_MAX(colors)} // prints "green"
{LIST_RANDOM(colors)} // prints either "red" or "green"

~ colors += blue // Adds blue to the list
{colors} // prints "red, green, blue"
~ colors -= blue // Removes blue from the list
{colors} // prints "red, green"

…and you can compare them to each other by value:

LIST colors = (red), (green), blue
VAR colors_a = (red, green)
VAR colors_b = (green)
{colors_a == colors_b} // prints "false"

// Remember that `colors` is also a variable?
{colors == colors_a} // prints "true"
{colors == colors_b} // prints "false"

“But wait, so ink lists are lists and numbers?” You ask, “What does that mean for the operations from earlier like LIST_VALUE, arithmetic operations, and comparisons?”

Well, in the case of LIST_VALUE, it always returns the value that is the largest:

LIST colors = (red), (green), blue
{LIST_VALUE(colors)} // prints "2"

In other words, it’s the same as LIST_MAX. For arithmetic operations, the operation is applied to every element in the list:

LIST colors = (red), (green), blue
~ colors++
{colors} // prints "green, blue"

For the functional programmers reading this, it’s like fmap! Kinda. So obviously, I think this qualifies Ink as a fully functional programming language (/j). And for comparisons, well:

LIST colors = red, green, blue
VAR colors_a = (red, green)
VAR colors_b = (blue)
{colors_a < colors_b} // prints "true"

How does that work?

ink lists are ranges

As it turns out, ink lists are also ranges, but not number ranges; you can only use them with defined values.

LIST colors = (red), (green), blue, yellow
{colors < blue} // compiles
{colors < 0} // compiler error

A < B returns true if the range of A is entirely to the left of the entirety of B. A > B does the opposite; it returns true if the range of A is entirely to the right of B. In both cases, overlaps in the range return false.

LIST colors = red, green, blue, yellow
VAR a = (red, green)
VAR b = (green, blue)
VAR c = (blue, yellow)

{a < c} // prints "true"
{a < b} // prints "false"
{b < c} // prints "false"
{c < a} // prints "false"

{c > a} // prints "true"
{b > a} // prints "false"
{c > b} // prints "false"
{a > c} // prints "false"

A <= B is the same as < and A >= B is the same as > except that overlaps are allowed:

LIST colors = red, green, blue, yellow
VAR a = (red, green)
VAR b = (green, blue)
VAR c = (blue, yellow)

{a <= c} // prints "true"
{a <= b} // prints "true"
{b <= c} // prints "true"
{c <= a} // prints "false"

{c >= a} // prints "true"
{b >= a} // prints "true"
{c >= b} // prints "true"
{a >= c} // prints "false"

And notably, doing A > B or A == B is not equivalent to A >= B:

LIST colors = red, green, blue
VAR a = (red, green)
VAR b = (red, blue)
{a < b or a == b} // prints "false"
{a <= b} // prints "true"

And how does this work with empty ranges? Well, the comparison operators treat an empty list some number that is less than all possible values in the list:

LIST colors = red=-1, green=0, blue=1
{() < red} // prints "true"
{() < blue} // prints "true"
{red < ()} // prints "false"
{blue < ()} // prints "false"

Empty lists suck though, so we should probably stick to lists that actually have stuff in them. Lets append lists together:

LIST colors = (red), (green), blue
VAR colors_a = (green)
{colors} // prints "red, green"
{colors + colors_a} // also prints "red, green"

Oh no.

ink lists are sets

Ink lists are also sets because they can’t contain duplicate values. And since they are sets, you can do the usual things with them like invert them…

LIST colors = (red), (green), blue
{LIST_INVERT(colors)} // prints "blue"

…do union, difference, and intersection operations…

LIST colors = red, green, blue
VAR colors_a = (red)
VAR colors_b = (red, blue)
{colors_a + colors_b} // prints "red, blue"
{colors_a - colors_b} // prints nothing
{colors_b - colors_a} // prints "blue"
{colors_a ^ colors_b} // prints "red"

…and test whether or not a set contains something with ? and whether or not it doesn’t have something with !?:

LIST colors = (red), (green), blue

{colors ? red} // prints "true"
{colors ? (red, green)} // prints "true"
{colors ? (red, green, blue)} // prints "false"

{colors !? red} // prints "false"
{colors !? (red, green)} // prints "false"
{colors !? (red, green, blue)} // prints "true"

Obviously, this works for variables too:

LIST colors = red, green, blue
VAR colors_a = (red)
VAR colors_b = (red, green, blue)

{colors_a ? colors_b} // prints "false"
{colors_b ? colors_a} // prints "true"
{colors_a !? colors_b} // prints "true"
{colors_b !? colors_a} // prints "false"

{colors ? colors_a} // prints "false"
{colors ? colors_b} // prints "false"
{colors !? colors_a} // prints "false"
{colors !? colors_b} // prints "false"

Oh, right, colors is also a variable and it’s not currently set to anything, so it is empty. Ink actually asserts that nothing can contain the empty list, not even the empty list itself:

LIST colors = red, green, blue
VAR colors_a = (red)
VAR colors_b = (red, green, blue)

{colors_a ? ()} // prints "false"
{colors_b ? ()} // prints "false"
{colors ? ()} // prints "false"
{() ? ()} // prints "false"

{colors_a !? ()} // prints "true"
{colors_b !? ()} // prints "true"
{colors !? ()} // prints "true"
{() !? ()} // prints "true"

Which is, you know, fun:

LIST all_keys = house_key, padlock_key, mail_key
VAR inventory = (mail_key)
VAR required_keys = (house_key, padlock_key)

VAR can_open_door = false
~ can_open_door = inventory ? required_keys
{can_open_door} // prints "false"

// break the door
~ required_keys -= house_key
// break the lock
~ required_keys -= padlock_key

~ can_open_door = inventory ? required_keys
{can_open_door} // prints "false", guess we can't open the door still even though it has no requirements

Anyway, that’s about all ink lists can do. It’s kind of a funny little thing where you have some constrained set of values a variable can be and you can do a bunch of different things with them. But, at least it guarantees the invariant that a variable can only be a few different things and — wait — oh no…

ink lists are untyped

LIST colors = red, green, blue
LIST polygons = triangle, square, pentagon
VAR shape = ()
~ shape += red
~ shape += triangle
{shape} // prints "red, triangle"

Ink lists are not actually constrained to only one possible type. You can, at any time, add lists of different types together. So, what does that mean for, well, everything else we reviewed?

Generally speaking, when there is a function that returns one value, you should imagine that it returns the first result in the list that matches. For example:

LIST colors = red, green, blue
LIST polygons = triangle, square, pentagon
VAR shape_a = (red, triangle)
VAR shape_b = (triangle, red)
{LIST_MAX(shape_a)} // prints "red"
{LIST_MAX(shape_b)} // prints "triangle"

And when you treat it as a range, all of the values are turned into numbers first for the sake of comparing them:

LIST colors = red, green, blue
LIST polygons = triangle, square, pentagon
VAR shape_a = (red, square)
VAR shape_b = (green, triangle)
{shape_a < shape_b} // prints "false"
{shape_a <= shape_b} // prints "true"

And when a function depends on all of the possible values of a list, it only considers the possible values of every type that is currently present in the list, or the type that was last present in the list if it is empty:

LIST colors = red, green, blue
LIST polygons = triangle, square, pentagon

VAR shape = (red, square)
{LIST_ALL(shape)} // prints "red, triangle, green, square, blue, pentagon"
{LIST_INVERT(shape)} // prints "triangle, green, blue, pentagon"

~ shape -= red
{LIST_ALL(shape)} // prints "triangle, square, pentagon"
{LIST_INVERT(shape)} // triangle, pentagon

~ shape -= square
{LIST_ALL(shape)} // prints "triangle, square, pentagon"
{LIST_INVERT(shape)} // prints "triangle, square, pentagon"

~ shape += red
{LIST_ALL(shape)} // prints "red, green, blue"
{LIST_INVERT(shape)} // prints "green, blue"

~ shape -= red
{LIST_ALL(shape)} // prints "red, green, blue"
{LIST_INVERT(shape)} // prints "red, green, blue"

And finally, you can replace the list with a value of a different type:

LIST colors = red, green, blue
colors = 2 // This is a number now instead of a list
{colors} // prints "2"

As a side note, according to the official documentation, using multiple list types for a variable is the idiomatic way to do record types in Ink:

This allows us to use lists - which have so far played the role of state-machines and flag-trackers - to also act as general properties, which is useful for world modelling.

This is our inception moment. The results are powerful, but also more like “real code” than anything that’s come before.

But this is a bad idea because you can accidentally nuke other parts of your state:

LIST state = on, off
LIST temperature = cold, tepid, hot
VAR kettle = (on, cold)
~ kettle = hot // whoops
{kettle} // prints "hot"

And you can’t enforce invariants like making sure that there is only one possible value for each field:

LIST state = on, off
LIST temperature = cold, tepid, hot
VAR kettle = (on, cold)
~ kettle = LIST_INVERT(kettle) // whoops
{kettle} // prints "off, tepid, hot"

Okay! I think that’s everything. I think I’m gonna head out before the code crime police shows up. Maybe I’ll read some yuri manga…