Skip to content

Using the InkCPP Runtime

Julian Benda edited this page Jul 16, 2023 · 5 revisions

The runtime is compiled into a static library using the inkcpp library. Understanding the Ink C# Runtime will help you understand the InkCPP runtime, but there are some significant differences in how the implementation is organized.

There are three primary runtime objects: the story, the runner, and the globals store.

The Story

The story is a read-only object that loads and stores all the story data and manages all the runners and global stores executing on it. This class would be roughly analagous to the Story class in the Ink C# runtime, except it is not responsible for executing the story or storing variable values. In fact, as mentioned above, it is read-only. It simply loads the story data and acts as a central store for it for other objects to read.

Execution is handled by the runner and globals objects.

Example of Loading a story

// Load ink story from an InkBin file
story* myInk = story::from_file("test.bin");

...

// delete story when you're done 
delete myInk; myInk = nullptr;

Runner

Execution is handled by the runner class. The runner stores the current execution context (what instruction are we at? what's on the evaluation stack? which containers are we in? etc.) and handles moving through the story or checking which choices are available. All the Continue, Choose, and related functions from the C# Ink Story class are on runners in InkCPP.

What's significant about this division between a story and a runner is that a story in InkCPP can have many runners all running at once. These runners could even be on separate threads.

New runners are created by the story object.

// Load ink story from an InkBin file
story* myInk = story::from_file("test.bin");

// Create two runners
runner firstRunner = myInk->create_runner();
runner secondRunner = myInk->create_runner();

// Start the first runner at the start of the story
firstRunner->move_to("story.start");

// Have the second runner be executing an ambient conversation between two NPCs
secondRunner->move_to("tavern.ambient");

// Each can be executed independently of the other
std::cout << "First story line: " << firstRunner->getline();
std::cout << "First ambient line: " << secondRunner->getline();

Globals

Starting multiple runners means you can have things like multiple ambient conversations with NPCs running at the same time in the world without creating multiple story instances. However, by default, each runner is its own independant execution state. They don't share anything. Ideally, we'd at the very least like them to share global data.

Using the story class, you can create a global variable store that can be shared across runners.

// Load ink story from an InkBin file
story* myInk = story::from_file("test.bin");

// Create a new global variable store
globals variables = myInk->new_globals();

// Create two runners that share the same global variable set
runner firstRunner = myInk->create_runner(variables);
runner secondRunner = myInk->create_runner(variables);

There is no limit to the number of global variable stores you can create from a single story, and each runner created by that story can use any valid global variable store created by that story.

To access and modify global variables you can use the variables->set<...>(...) and variables->get<...>(...). Further callbacks can be used to observe a variable. A callback function passed to variables->observe(callback) can have 0, 1 or 2 arguments. It than is called with no value, the new value or the new value and a optional old value. Further should be noted that the observer is also called when bind with the current value of the variable.

Note: One of the project goals is to have the global variable store object multi-thread safe so each runner could foreseeably run in its own CPU thread, but this hasn't been done yet.

external functions

External functions must be bind to each runner (who should use it) with .bind(name, function). If you want a fail save or dummy implementations without binding, add a function with the same name and same arguments in you story script

EXTERNAL sqrt(x)
// fallback if sqrt is not bind when executed
=== function sqrt(x) ===
// can return explicit error or a dummy value to simulate the result
~ return -1

Save and Load

It is possible to create snapshots of you story, and restore with them the state. A snapshot contains the globals and all connected runners. For a simple one runner per global state the usage looks like this:

runner myRunner = myInk->new_runner();
snapshot* snap = myRunner->create_snapshot();

runner myRunnerAgain = myInk->new_runner_from_snapshot(*snap);
// bind functions: myRunnerAgain->bind("add", [](int a, int b)->int{ return a + b; })

If you have multiple runners for one globals you must reconstruct them step wise:

global vars = ...;
runner* runners = ...;

snapshot* snap = vars.create_snapshot();

global new_vars = myInk->new_globals_from_snapshot(*snap);
runner* new_runners = new runners[snap.num_runners()];
for(size_t i = 0; i < snap.num_runners(); ++i) {
	new_runners[i] = myInk->new_runner_from_snapshot(*snap, new_vars, i);
	// bind functions
}

Note: functions must bind after loading a snapshot!

Note: The snapshot only contains data of the current state, therefore to load a snapshot the original story file must be available.

Garbage Collection

Runners and Global Variable Stores are both created as a kind of shared pointer that is managed by the story object. There is no need to delete runners or stores and they can be passed around as pointers safely. As soon as the last copy of runner is destroyed, the runner itself will automatically be cleaned up. Similarly, as soon as the last instance of a global variable store is destroyed, and all runners using it are destroyed, it's automatically cleaned up.

The story object is the only one you must make sure to delete. Once it is deleted, it will automatically destroy all runners and stores it has created to prevent them from accessing a destroyed story.

Clone this wiki locally