Ink Numeric Input
Learn how to setup user input for numbers in Ink
Intro
The motivation for this document is as interesting as it is subjective. Some exposition is necessary.
To me, there’s an interesting conflict nested in the Ink paradigm. The focus of Ink is writing not programming. It’s a middleware so you typically want data processing to happen in your game engine, where it’ll (hopefully) be easier to program/debug and also faster to calculate. These data values can then be used in Ink after processing. The problem with this is that you can no longer accurately run your Ink story in Inky (or equivalent tools) and are reliant on the game engine for data evaluation. Ink is now coupled with the game engine. With some extra scripting, it’s possible to make the coupling looser but ultimately, there’s still a coupling; the Ink story needs the game engine for accurate reading or writing.
In some production environments, this could mean that your writers now need access to the game engine environment, which could mean paying for new licenses or even new equipment. There are also steep learning curves and engine build stability to contend with. It’s an unnecessary hurdle for writing in Ink. To me, this says that you want your Ink projects to be able to run in Inky as much as possible. Your Ink story needs to work outside of your game engine and that means you actually want to do as much programming as possible in Ink itself! This is the conflict of “coding” in Ink.
There are other advantages to making your Ink story rely mainly on internal code and not external code.
- If you need to switch game engines for any reason, you don’t need to rewrite any logic. Your story would still work and all your test cases maintain validity.
- Besides Inky/Inklecate, you can take advantage of web tools such as Graphink and Inky Doc. This means you can work on Ink projects on any device that supports modern web browsers.
- Ink files can act as a Single Source of Truth. In reality, SSOT is very hard to achieve in game development but it’s worth striving for.
So what does all this have to do with numeric inputs?
Well…I was trying to port the original BASIC version of Oregon Trail to Ink with no intention of integrating it into Unity or any other game engine. I just wanted to make Oregon Trail in pure Ink. But in Oregon Trail, the player uses the console to input numbers for buying items among other things. And the only user input in Ink is the choice (aka option) mechanic. Now it is possible to use this mechanic to allow numeric input. It’s a bit janky but hey it works™️.
Scripting Digits in Ink
The basic idea is that to support numeric input we need to afford at least 10 choices, one for each digit from 0 to 9. This is quite easy to do.
-> choose_digit
=== choose_digit
VAR choice = 0
+ [1]
~ choice = 1
+ [2]
~ choice = 2
+ [3]
~ choice = 3
+ [4]
~ choice = 4
+ [5]
~ choice = 5
+ [6]
~ choice = 6
+ [7]
~ choice = 7
+ [8]
~ choice = 8
+ [9]
~ choice = 9
+ [0]
~ choice = 0
- >> Chose: {choice}
-> choose_digit
The above is an infinite loop of letting us pick digits but it does nothing with them. In order to concatenate them into a single number, we can do a few things. Let’s first take a look at what I did for Oregon Trail.
Oregon Trail did not really use numbers that were greater than 1000 so I was able to take some shortcuts by assuming we could only input numbers with 3 digits.
=== pick_number(ref num)
~ num = 0
Choose Digit in the Hundredth's place
-> choose_digit(num, 3) ->
Choose Digit in the Tenth's place
-> choose_digit(num, 2) ->
Choose Digit in the Unit's place
-> choose_digit(num, 1) ->
->->
=== choose_digit(ref num, digit)
VAR choice = 0
// + [1] etc etc
// i.e. all the numeric options here
- >> Chose: {choice} <>
~ num += choice * POW(10, digit - 1)
, Value: {num}
->->
In the above, if we chose 9
, 6
and 3
, we would compute (9 * 100) + (6 * 10) + (3 * 1)
to make 963
. It could be simplified even further by skipping the Power operations and just using 100, 10 and 1 as the second function argument. Note that references are used to ensure that variables could be funneled into tunnels so they could be altered. Also a glue <>
is used to calculate num before displaying it side-by-side with the choice value.
But what if we couldn’t control the digit count? This means we would need to support digit input much like a calculator where each digit gets shifted to the left when a new one is chosen. We could use string concatentation to accomplish this. The code below is similar but we have a new choice for concluding the input (RETURN).
VAR num = 0
-> choose_digit_string
=== choose_digit_string
VAR choice = ""
+ [1]
~ choice += "1"
+ [2]
~ choice += "2"
+ [3]
~ choice += "3"
+ [4]
~ choice += "4"
+ [5]
~ choice += "5"
+ [6]
~ choice += "6"
+ [7]
~ choice += "7"
+ [8]
~ choice += "8"
+ [9]
~ choice += "9"
+ [0]
~ choice += "0"
+ [RETURN]
// ~ num = INT(choice) // Error: Cannot perform operation 'INT' on String
Final number is {choice}
-> DONE
- >> Value: {choice}
-> choose_digit_string
The problem with this method is that we’re making a string which cannot be converted to an int for subsequent numeric calculations. Ink has only two operations for strings: comparison and substring. If you were testing for specific numbers stored as strings, the above would suffice but assuming we’re not, we can do something else.
Basically, we end up writing an Ink script that’s similar to choose_digit
and choose_digit_string
- except to concatenate we multiply the current number by 10 and add the choice value to it. The result looks like this.
VAR just_a_number = 0
-> pick_number(just_a_number) ->
- picked number is {just_a_number}
~ just_a_number += 1
I added 1 to it to make {just_a_number}
////////////////////////////////////////////
=== pick_number(ref num)
~ num = 0
-> choose_digit(num) ->
->->
=== choose_digit(ref num)
VAR choice = 0
+ [1]
~ choice = 1
+ [2]
~ choice = 2
+ [3]
~ choice = 3
+ [4]
~ choice = 4
+ [5]
~ choice = 5
+ [6]
~ choice = 6
+ [7]
~ choice = 7
+ [8]
~ choice = 8
+ [9]
~ choice = 9
+ [0]
~ choice = 0
+ [RETURN]
->->
- >> Chose: {choice} <>
~ num = num * 10 + choice
Value: {num}
-> choose_digit(num)
Of course there’s a problem in this script and it becomes apparent if you pick the number 2147483647
which is valid but that increment blows it past the maximum integer value and circles around to -2147483648
. We could ignore this or we could early-out if the number gets bigger than a desired value. Something like this could do:
- >> Chose: {choice} <>
~ num = num * 10 + choice
Value: {num}
// early return
{ num > 999999 :
~num = 999999
->->
}
// etc.
-> choose_digit(num)
That random number 999999
could be stored as an INPUT_MAX global variable and customized per Ink file.
Extra (Complicated) Credit
Writing out 10 choices for each decimal digit is not so bad but let’s assume we had more digits than that. In such a situation, we may want to use loops. Loops in Ink can be accomplished in a manner similar to loops in other programming languages that are made with JUMP or GOTO statements. This is a while loop in C unrolled into something kinda like x86 Assembly.
// C code
int i = 0;
while (i < 10) {
i++;
}
// Pseudo Assembly
SET X to 0
- label while :
COMPARE X, 10
if X is greater or equal to 10, JUMP to false
- label true :
INCREMENT X
JUMP to while
- label false :
;; rest of the code
It might looked complicated but if you squint, you can see how Ink diverts are similar to JUMPs. The above loop could look like this in Ink.
VAR X = 0
- (label_while)
{ X >= 10 : -> label_false }
- (label_true)
X is {X}
~ X++
-> label_while
- (label_false)
Loop is finished
Here we’re taking advantage of labelled Gathers. The above Ink script prints the following.
X is 0
X is 1
X is 2
X is 3
X is 4
X is 5
X is 6
X is 7
X is 8
X is 9
Loop is finished
So using a loop like this, we can then use Threads to collect a bunch of choices. Basically X is {X}
will be replaced by an Ink script that outputs choices.
VAR X = 0
- (label_while)
{ X >= 10 : -> label_false }
- (label_true)
<- generate_numeric_choice(X) // thread in action
~ X++
-> label_while
- (label_false)
>> Loop is finished
=== generate_numeric_choice(n)
+ [Input {n}]
-> DONE // temporarily added
The above outputs Input 0
to Input 9
and can now be shoved into the Ink script we wrote in the previous section (with some modifications).
VAR choice = 0
VAR X = 0
=== choose_digit(ref num)
~ X = 0
- (label_while)
{ X >= 10 : -> label_false }
- (label_true)
<- generate_numeric_choice(X)
~ X++
-> label_while
- (label_false)
// Skip text output and just add the next choice
+ [RETURN]
->->
- (chose) >> Chose: {choice} <>
~ num = num * 10 + choice
Value: {num}
-> choose_digit(num)
=== generate_numeric_choice(n)
+ [Input {n}]
~ choice = n
-> choose_digit.chose
Because we need to run the while loop over and over again, we need to ensure X is reset to 0 each time so we use ~ X = 0
because VAR X = 0
does not re-set the variable. Both choice
and X
are global so it might be better to move those out of the knot to avoid confusion.
Also since the generated choices are stitched via threads, it’s important to ensure that the generated choices divert to where they once used to gather. So we give the gather a label and can jump to it via knot-name.gather-name which in this case is choose_digit.chose
.
As mentioned before this is extremely complicated for a few digits but it could be useful in other scenarios.
Conclusion
Using choices, it is possible to accept numeric input in Ink. By making a choice for each digit, we can conjure a number and use it for various mathematical calculations. The method of input is definitely clunky in Inky. However, it should be possible to create virtual keyboards or similar user interfaces within the game engine to present a smoother user experience.