Development
This page is the C# guide. A C# program uses what a Divooka graph uses: the Divooka nodes are this API's methods, named by their XML documentation. C# has one thing of its own, the story builder, which writes a whole story in fluent C# with no script; its node counterparts are the Story Writing, Conditions, Effects, Scenes, Animation, Images, Transitions, Screens and Story nodes. Installation covers the project reference.
The canonical example is The Red Thread, at TestPrograms/Novella.RedThread in the Parcel NExT tree: its story, theme and settings are data in its scripts, and its C# says where the files are, configures the game, and implements the one command the story asks code for. It is quoted throughout this page.
The assemblies
| Assembly | Holds |
|---|---|
Novella.Core |
The model (Story, Label, statements, definitions, GameConfig, Theme, GameDefinition), the script parser and writer, expressions, text markup, transforms, the StoryRunner, saves, persistent data, localization and the validator. No dependencies beyond .NET; headless and deterministic. |
Novella |
NovellaGame, the session and its screens, the stage and transition renderers, the audio mixer, movies, hosting in a window or headless with keyboard and gamepad, AutoPlayer, and the node classes Divooka shows. |
Novella.Python |
Python for stories that opt into it, through Parcel NExT's Divooka.Scripting.Python, which runs IronPython 3.4.2 with the standard library beside the game. Nothing calls it directly: when a story has Python, the engine finds it by name beside the game, so a game that never writes Python need not reference it. |
NovellaGame
NovellaGame is the entry point: create it, configure it, give it content, and run it.
using Novella;
using Novella.Values;
NovellaGame game = new("The Garden", 1920, 1080);
game.AddAssetFolder("Assets");
game.LoadScriptsFromAssets("scripts");
game.SetTheme("text_font", "EB Garamond, LXGW WenKai Lite");
game.RegisterCommand("riddle", arguments => Value.FromBoolean(arguments[0].AsText() == "moon"));
game.Run();
| Member | What it does |
|---|---|
NovellaGame(title, width, height) |
A game with a title and a resolution (default 1280×720). |
NovellaGame(GameDefinition) |
A game from a definition built elsewhere, such as by a functional graph. |
SetResolution, SetTitle |
The resolution everything is laid out at, and the title. |
SetConfig(key, value), SetTheme(key, value) |
A setting, written as a script would write it. |
AddAssetFolder(folder) |
A folder of files: images, audio, video, fonts, scripts. |
AddAsset(path, bytes) |
One file from memory, under the path scripts use. |
AddAssetSource(IAssetSource) |
Any other source of files. |
LoadScript(text, name), LoadScriptFile(path) |
A script from text or from disk. |
LoadScriptsFromAssets(folder) |
Every .novella file among the assets (under a folder, or everywhere), in path order. Returns how many. |
AddStory(Story) |
A story built in code or by nodes. |
WriteStory(story => ...) |
Writes into the game's own story with a story builder, with no script. |
AddScene(SceneDefinition) |
A scene, added as a label of its name. |
DefineCharacter(id, name, color, image), DefineImage(name, file), SetDefault(variable, value) |
Definitions without a script. |
RegisterCommand, RegisterAction, RegisterFunction, RegisterCommandFunction, RegisterExpressionFunction, RegisterScreen |
Extensions a story calls. |
UseLiveScript(Action) |
A procedure that is the story (live scripts). |
Validate() |
The lint: one line per problem, such as error: script_07.novella:84: There is no command 'fortune'; the game must register it. |
Run() |
Opens a window and plays until the player quits; with the NOVELLA_HEADLESS environment variable set to a number, plays that many interactions headless instead and prints the transcript. |
RunHeadless(storage) |
Runs without a window for tests; returns the host to pump. |
Definition, Config, Theme, Story, Assets, Session |
The data, and the running session while the game runs. |
A setting made with SetConfig, SetTheme, SetResolution or SetTitle holds over a script's define of the same key, whether the script is loaded before or after it, so a program can load a game's scripts and then change one of their settings. The title and resolution given to the constructor are only defaults, which a script's define replaces.
A game as a class
Derive from NovellaGame and override its callbacks:
| Callback | When it runs |
|---|---|
Setup() |
Once, before the window opens: the place for settings, assets, scripts and definitions. |
Script() |
If overridden, starting a game runs it as a live script instead of the start label. |
Command(name, arguments) |
For a run command nobody registered by name. It returns nothing: arguments arrive as text, and SetResult(value) answers with what the story reads as _return (none if nothing is set). |
LabelEntered(label) |
Whenever the story enters a label. |
GameEnded() |
When a story ends, before the main menu returns. |
The Red Thread is such a class (abridged):
public sealed class RedThreadGame : NovellaGame
{
private static readonly string[] Fortunes =
[
"Upper fortune: the thread you are tied by is already being drawn tight.",
"Middle fortune: a gift chosen with care will be kept with care.",
"Great fortune: the old man under the moon has written both your names."
];
public RedThreadGame(string assetsFolder)
: base("The Red Thread", 1920, 1080)
{
AssetsFolder = assetsFolder;
}
public string AssetsFolder { get; }
protected override void Setup()
{
SetResolution(1920, 1080);
AddAssetFolder(AssetsFolder);
LoadScriptsFromAssets("scripts");
// The scripts set everything else; a setting made here holds over theirs.
SetConfig("autosave_slots", "8");
}
// The story's `run fortune`: draws a slip at the Lantern Festival.
protected override void Command(string name, string[] arguments)
{
if (name != "fortune")
{
base.Command(name, arguments);
return;
}
SetResult(TranslateText(Fortunes[RandomInteger(0, Fortunes.Length - 1)]));
}
}
It uses the same calls a Divooka graph would, and two details are worth copying. RandomInteger draws from the story's own random generator, which is part of the saved and rolled-back state, so rolling back and choosing again draws the same slip; System.Random would let a player reroll by rolling back. And the slip goes through TranslateText, so it appears in the player's language when the story's string tables translate it.
The base Command throws for an unknown name, which stops the story with an error that names the command.
The story builder
The Red Thread keeps its story in scripts. A game can instead write its story in C# with a StoryBuilder (namespace Novella.Building, in Novella.Core), and need no script at all.
The builder describes the story; it does not play it. Each call adds the same labels and statements a script parses into, so a game written this way saves, loads, rolls back, skips seen text, validates and is translated exactly as a scripted one is. That holds because the program builds the same story every time it starts, which code does naturally. It is the opposite of a live script, where the code is the running story and so cannot be saved; see Concepts for why.
A worked example
A short game with a riddle, a choice that changes a variable, a test of that variable, and a gift that depends on it:
using Novella;
using Novella.Building;
public sealed class LanternGame : NovellaGame
{
public LanternGame()
: base("The Lantern", 1920, 1080)
{
}
protected override void Setup()
{
AddAssetFolder("Assets");
WriteStory(story =>
{
// Definitions: a character, a colour image, variables and an achievement.
story.Character("q", "Qinglan", color: "#c0392b", image: "qinglan")
.Image("bg night", "#101828")
.Default("riddles", 0)
.Default("gift", "")
.Default("lit", false)
.Achievement("riddler", "Riddler", "Solved Qinglan's riddle.");
story.Label("start", s => s
.Scene("bg night", with: "fade")
.Music("audio/bgm/lanterns.ogg")
.Show("qinglan smile", at: "right", with: "dissolve")
.Say("q", "Guess my riddle.")
.Menu("What has a face but no mouth?", m => m
.Choice("A clock.", c => c
.Add("riddles", 1)
.Achieve("riddler")
.Say("q", "Right.", "laugh"))
.Choice("A drum.", c => c.Say("q", "No.")))
// A lambda effect, and a lambda condition.
.Do(v => v["gift"] = v.Number("riddles") > 0 ? "lantern" : "nothing")
.If(v => v.Text("gift") == "lantern",
then => then.Say("q", "The lantern is yours."),
otherwise => otherwise.Say("q", "Next year, then."))
.Jump("walk"));
// Built conditions and effects instead of lambdas: these print as script.
story.Label("walk", s => s
.Narrate("You walk on along the river.")
.Menu(m => m
.Choice("Light the lantern.", c => c.Apply(Effect.Set("lit", true)), when: Condition.Compare("gift", "==", "lantern"))
.Choice("Keep walking."))
.If(Condition.IsSet("lit") & Condition.AtLeast("riddles", 1),
then => then.Narrate("The river fills with light."))
.Centered("The End")
.Return());
});
}
}
new LanternGame().Run() plays it. Every builder method returns the builder, so a label reads top to bottom like a script; a body can also be a block lambda with one call per line. Text takes the usual text tags and [variable] interpolation. Values (Default, Set, Add, Run arguments) are Novella.Values.Value, which converts implicitly from numbers, text and booleans.
WriteStory writes into the game's own story, so it combines with LoadScriptsFromAssets and AddScene: a scripted chapter can jump to a built label and back. A label defined twice, in scripts or in code, is an error. The game's configuration and theme stay on the game (SetConfig, SetTheme); the builder writes the story. A StoryBuilder can also be made on its own (new StoryBuilder(), whose Story goes to AddStory, or straight into a StoryRunner in a test).
Labels written with the builder do not fall through to the next label, because they come from no file: reaching the end of one ends the game, or returns to its caller. End a label that should lead on with a Jump, as the example's start does, and one that should end with a Return. When a label built in code can run off its end and the label after it is also built in code, the validator warns, because that is where a script's label would have fallen through and the author may have expected it to.
The builder can say everything the script language can: besides lines, menus and logic, it writes transforms, layered images and image sources, named transitions, translations, audio names, channels and music room tracks, constants, configuration and theme settings, label parameters and return values, jumps and calls to a label worked out as the story runs, every option of show, scene, hide, play, queue, voice, movie, input, pause and window, and screens. Novella's tests hold it to that by building one story both ways, as a script and with the builder, and requiring the two to print as the same script. The one difference is in arguments: what the builder passes to a label, a command or a screen (Call, Run, ShowScreen, CallScreen, Use) are values fixed when the story is built, where a script's are expressions worked out as it plays.
Conditions and effects
A condition or an effect can be written two ways, and both work everywhere one is taken (If, While, Choice, scene endings):
| A lambda | A built value | |
|---|---|---|
| Condition | v => v.Number("affection") > 2 |
Condition.Compare("affection", ">", 2) |
| Effect | v => v["gift"] = "fan", with Do |
Effect.Set("gift", "fan"), with Apply |
| Prints as script | A description, such as a C# condition, which does not read back |
The expression a script would hold, which reads back the same |
| Checked by the validator | No: its code is opaque | Yes, like a script's: unknown functions, variables with no default |
| Available in Divooka | As a Function graph handed to a From Function node, which has no script form either | Yes, as the Conditions and Effects nodes |
A lambda receives StoryVariables, a view of the game state: what it reads and writes there rolls back and saves with the story. It should touch only the story's variables. Anything it changes outside the story, such as a field of the game class, does not roll back when the player rolls back and is not restored by a load. For randomness, use v.Random(minimum, maximum), which draws from the story's own generator so that rolling back and choosing again draws the same number; System.Random would let a player reroll by rolling back.
Built conditions combine with &, | and ! (or And, Or, Not), and print the script a person would write: Condition.AtLeast("affection", 6) & (Condition.Compare("gift", "==", "fan") | !Condition.Visited("market")) prints as (affection >= 6) and ((gift == "fan") or (not visited("market"))). Condition.FromScript and Effect.FromScript take script text when that is the clearest way to say something.
Scenes
A scene is the configuration model Novella One built games from, kept as a way to write a story without control flow: a name, a background, music, lines, and an ending that says where the story goes next. The scene named start begins the game. A SceneDefinition becomes an ordinary label of its name, so scenes save, roll back and validate like any label, and mix with labels and scripts.
WriteStory(story =>
{
story.Default("courage", 0);
story.Scene(new SceneDefinition("start", s => s.Narrate("A bridge in the rain."),
SceneEnding.Choose("Cross it?",
[
new SceneChoice("Cross.", "far_side", effect: Effect.Add("courage", 1)),
new SceneChoice("Wait.", "near_side")
]),
background: "bg bridge", music: "audio/bgm/rain.ogg"));
story.Scene(new SceneDefinition("far_side", s => s.Narrate("The far side."),
SceneEnding.Branch(Condition.AtLeast("courage", 1), "brave", "near_side")));
story.Scene(new SceneDefinition("near_side", s => s.Narrate("Still here.")));
story.Scene(new SceneDefinition("brave", s => s.Narrate("Brave.")));
});
SceneDefinition(name, lines, ending, background, transition, music) takes its lines as a block-builder lambda (or as statements). The background comes in with transition, fade unless another is given or null; a null background keeps the stage, and null music keeps what plays. The ending is one of:
| Ending | Becomes |
|---|---|
SceneEnding.GoTo(scene) |
A jump to the scene. |
SceneEnding.Choose(caption, choices) |
A menu. Each SceneChoice(text, scene, when, effect) is offered only when its condition holds (if it has one), applies its effect (if any), and jumps to its scene. |
SceneEnding.Branch(condition, then, otherwise) |
An if that jumps to one scene or the other. |
SceneEnding.End, or no ending |
A return: the game ends, or a called scene returns. |
story.Scene(scene) and NovellaGame.AddScene(scene) both add one.
Animations, images, transitions and screens
The definitions a script writes at its top level have builder calls too. This writes a transform with a parameter, a named transition, a filtered image, a layered image and a screen, and uses them:
WriteStory(story =>
{
story.Character("q", "Qinglan", color: "#c0392b", image: "qinglan")
// As `transform bob(height=20):` would write it.
.Transform("bob", a => a
.Set("yoffset", 0)
.Ease(1.0, "yoffset", AnimationValue.FromScript("-height"))
.Ease(1.0, "yoffset", 0)
.Repeat(), StoryBuilder.Parameter("height", 20))
.Transition("slow", Transitions.Dissolve(2))
.Image("room", ImageSources.Grayscale(ImageSources.File("bg/room.jpg")))
.LayeredImage("qinglan", l => l
.Always("sprites/qinglan/base.png")
.Group("face", g => g
.Attribute("neutral", "sprites/qinglan/neutral.png", isDefault: true)
.Attribute("smile", "sprites/qinglan/smile.png")))
.Screen("riddle", s => s
.Parameters(StoryBuilder.Parameter("question", "Who?"))
.Dim()
.Text("[question]", 0.5, 0.3, size: 44)
.Button("A mountain", 0.35, 0.6, ScreenActions.Return("mountain"))
.Button("A river", 0.65, 0.6, ScreenActions.Return("river"))
.Timer(10, ScreenActions.Return("timeout")));
story.Label("start", s => s
.Scene("room", with: "slow")
.Show("qinglan smile", at: "bob(30), left", with: "dissolve")
.Show("cloud", animation: a => a.Set("xalign", 0.0).Linear(3.0, "xalign", 1.0).Repeat())
.CallScreen("riddle", null, "What has roots that nobody sees?")
.If(Condition.Compare("_return", "==", "mountain"),
then => then.Say("q", "Correct.", "smile"),
otherwise => otherwise.Say("q", "Look up, scholar."))
.Return());
});
Written out with ScriptWriter.Write, it is the transform, transition, image, layeredimage and screen definitions and the label a script would hold, and it reads back as the same story. An animation value follows the script's rule: a whole number is pixels and a number with a fraction is a share of the stage, so 100 and 1.0 mean different things; AnimationValue.Pixels, Fraction, Parameter(name) and FromScript(expression) say which outright. Screen positions follow the same rule, and each element is centred on its position.
Reference
StoryBuilder: the story's labels and definitions.
| Method | What it adds |
|---|---|
Label(name, s => ...) |
A label: a named place in the story. The game starts at start. |
Label(name, [Parameter("who"), Parameter("times", 1)], s => ...) |
A label with parameters, which Call(label, arguments...) binds and the label reads as variables; StoryBuilder.Parameter(name, defaultValue) makes one. |
Scene(SceneDefinition) |
A scene, as a label of its name. |
Character(id, name, color, image, sideImage, textColor, kind, prefix, suffix, font, size, voice, beep) |
A character lines name by its id, with every option of the script's character line: the shown name, its colour (#rrggbb), its image tag (so a line's attributes change the expression), whether its image shows beside its lines, the text's colour, CharacterKind.Adv, Nvl or Centered, quotation marks, a font and size, a voice tag and a typing sound. Character(CharacterDefinition) takes one defined in full. |
Image(name, fileOrColor), Image(name, ImageSource) |
An image from a file among the game's files or a colour, or from a source built with ImageSources (below). Image files are already images without this, named for their paths (see Concepts). |
Image(name, a => ...) |
An image that is a timeline, as a script's image name: block: Image("candle", a => a.Picture("candle a").Pause(0.5).Picture("candle b", "dissolve").Pause(0.5).Repeat()) flickers wherever it is shown. |
LayeredImage(tag, l => ...) |
A layered image, built with a LayeredImageBuilder (below). |
Transform(name, a => ..., parameters...) |
A named animation, built with an AnimationBuilder (below), with parameters a show's at can pass: at: "bob(30)". |
Transition(name, transition) |
A named transition: Transition("slow", Transitions.Dissolve(2)). |
Audio(name, file) |
An audio name, as the script's audio line. |
Channel(name, mixer, loop, restoreOnLoad, fadeOut) |
An audio channel of the story's own, as the script's channel line. |
Track(file, title, when) |
A music room track, unlocked when heard, or when a Condition holds if one is given. |
Default(variable, value) |
A variable's value when a game starts. |
Constant(name, value) |
A constant, as define: set at start and again after every load. |
Config(key, value), Theme(key, value) |
A define config. or define theme. setting written into the story. |
Gallery(id, title, images...), Gallery(id, title, when, images...) |
A gallery entry, unlocked once its pictures have been seen, or when a Condition holds. |
Achievement(id, title, description, hidden) |
An achievement the story grants with Achieve. |
Translate(language, t => ...) |
A translation, built with a TranslationBuilder (below). |
Screen(name, s => ...) |
A screen the story shows or calls. |
Python(code) |
Python run once when a game starts, as init python:; the functions it defines can be called from any expression. The game needs Novella.Python. |
Story |
The story being written. |
BlockBuilder: the statements of a label, a choice or a branch, in order. Each method adds one statement (or, for an effect, its statements) and returns the builder. An optional parameter left out leaves its clause out of the statement.
| Method | Statement |
|---|---|
Say(character, text, attributes...) |
A line; attributes change the speaker's image and stay changed. |
Say(character, text, new LineOptions { Attributes, Briefly, With, Id, SpeakerName }) |
A line with everything a script line can carry: lasting attributes, attributes for this line only, a transition played as it appears, an id of the author's choosing, a one-off speaker's name. |
SayBriefly(character, text, attributes...) |
A line whose attributes last for the line only (@). |
Narrate(text) |
Narration. |
SayAs(speakerName, text) |
A line from a one-off speaker who is not a defined character. |
Centered(text) |
Text in the middle of the screen, as for a chapter title. |
Extend(text) |
Continues the previous line. |
NvlClear() |
Clears the NVL page. |
Input(variable, prompt, defaultText, maximumLength, allowed) |
Asks the player to type into a variable (24 characters unless told otherwise), accepting only the characters allowed lists if it is given. |
Scene(image, with, layer, at, animation) |
Clears a layer and shows a background; no image clears to black. |
Show(image, at, with, behind, zorder, as, layer, animation) |
Shows or changes an image, with every clause of the script's show. at is left, center, right or the story's transforms with their arguments, separated by commas ("bob(30), left"); animation is an inline timeline (a => a.Set("alpha", 0.0).Linear(1.0, "alpha", 1.0)). |
Hide(tag, with, layer) |
Hides an image. |
With(transition) |
Runs a transition over everything that changed since the last line. Transitions are written as in a script (dissolve, fade, dissolve(1.5)) or made with Transitions. |
Pause(seconds, hard) |
Waits for some seconds, or for a click when none are given; a hard pause cannot be clicked through. |
Window(WindowMode, with) |
Shows or hides the dialogue window, or returns it to showing only with lines (Show, Hide, Auto), optionally with a transition. |
ShowScreen(name, with, arguments...), HideScreen(name, with) |
Shows a screen over the game, or hides it. |
CallScreen(name, with, arguments...) |
Shows a screen and waits for its answer, which is then in _return. |
Music(file, fadeIn, fadeOut, volume) |
Plays music, looping, replacing what plays unless it is the same piece; fades in over a second by default. |
Sound(file, volume) |
Plays a sound once. |
Play(channel, file or files, fadeIn, fadeOut, loop, volume, ifChanged) |
Plays on any channel with every option of the script's play; several files play in order. |
Queue(channel, file or files, loop, volume) |
Plays after what is playing. |
Stop(channel, fadeOut) |
Stops a channel (music by default). |
Voice(file), VoiceSustain() |
A voice file for the next line; the current voice kept playing through the next line. |
Movie(file, skippable, keepMusic) |
A full-screen movie. |
Menu(m => ...), Menu(caption, m => ...) |
Offers choices, optionally with a line above them. After a choice's statements, the story carries on after the menu. |
If(condition, then, otherwise) |
Runs one block or another; the condition is a Condition or a lambda, and otherwise may be left out. |
If((a, then), (b, then), (null, otherwise)) |
An if/elif/else chain: the first branch whose Condition holds runs, and a branch with no condition is the else. |
While(condition, body) |
Repeats a block while a condition holds. |
Jump(label) |
Continues at a label (or a named menu). |
JumpTo(v => ...), JumpToVariable(name) |
Continues at a label worked out when the story gets there: by a lambda, or named by a variable (jump expression). |
Call(label, arguments...) |
Runs a label and comes back; the arguments go to the label's parameters. |
CallTo(v => ...), CallVariable(name) |
Calls a label worked out when the story gets there; these take no arguments. |
Return(), Return(value), Return(v => ...) |
Returns from a call, or ends the game; with a value, which the caller reads as _return. |
Set(variable, value) |
Sets a variable to a value. |
Set(variable, v => ...) |
Sets a variable to a value computed by a lambda from the others. |
Add(variable, amount), Subtract(variable, amount) |
Adds to a number or appends to a list; takes from a number or removes from a list. |
Apply(effect) |
Applies a built Effect. |
Do(v => ...) |
Changes the story's variables with a lambda. |
Run(command, arguments...) |
Runs a command the game implements; its result is in _return. |
Notify(text), Achieve(achievement), Pass() |
A notification, an achievement granted, nothing. |
Python(code) |
A Python block, as python:, run against the story's variables; what it sets that the story can hold goes back into the story. The game needs Novella.Python. |
Script(text) |
Statements written as script, for a passage easier to say that way. |
MenuBuilder: the choices of a menu.
| Method | What it does |
|---|---|
Choice(text, c => ..., when, id) |
A choice and what happens when it is taken; shown only when the Condition when holds, if given, and carrying an id of the author's choosing, if given. A choice with no body does nothing and the story carries on. |
Choice(text, v => ..., c => ...) |
A choice shown only when a lambda test holds. |
Caption(character, text) |
A line shown with the choices, spoken by a character (or narration when the character is null). |
Once(listVariable) |
Hides choices already taken by recording them in a list variable, as menu set does: for "ask about each topic once" menus. |
Name(name) |
Names the menu, so a jump or call can reach it as it reaches a label. |
Condition: built conditions.
| Member | Holds when |
|---|---|
Condition.Always |
Always. |
Condition.Compare(variable, comparison, value) |
The variable compares so with the value: ==, !=, <, <=, > or >=. |
Condition.CompareVariables(first, comparison, second) |
Two variables compare so: CompareVariables("trust", ">", "doubt"). |
Condition.AtLeast(variable, number) |
A number variable is at least the number. |
Condition.IsSet(variable) |
The variable holds: true, non-zero, non-empty. |
Condition.Visited(label), Condition.VisitedAtLeast(label, times) |
The label has been entered in this playthrough, or at least so many times. |
Condition.EverReached(label) |
The label has been reached in any playthrough, as an ending once seen. |
Condition.SeenImage(image), Condition.Heard(file) |
An image has been seen, or audio heard, in any playthrough. |
Condition.Achieved(achievement) |
An achievement has been earned. |
Condition.Showing(tag) |
An image with the tag is on the stage now. |
Condition.InLanguage(language) |
The player reads the game in a language, such as zh-Hans. |
Condition.Chance(probability) |
By chance, from 0 to 1, drawn from the story's own generator so that rolling back draws the same. |
Condition.Contains(listVariable, item) |
A list variable contains the item. |
Condition.FromScript(expression) |
The script expression holds, such as affection >= 3 and gift == "fan". |
Condition.FromCode(v => ..., description) |
A lambda says so; the description stands in for it in error messages and written-out script. |
a & b, a \| b, !a, And, Or, Not |
Both, either, the opposite. |
Effect: built effects.
| Member | Changes |
|---|---|
Effect.Set(variable, value) |
Sets a variable. |
Effect.Add(variable, amount) |
Adds to a number, or appends to a list. |
Effect.Subtract(variable, amount) |
Takes from a number, or removes from a list. |
Effect.Change(variable, assignment, value) |
Changes a variable by an operator: =, +=, -=, *=, /=, //= or %=. |
Effect.ChangeBy(variable, assignment, other) |
Changes a variable by another variable's value, with the same operators: ChangeBy("bag", "-=", "chosen") takes the chosen gift out of the bag. |
Effect.Copy(variable, from) |
Sets a variable to another's value. |
Effect.Toggle(variable) |
Turns a true variable false and a false one true. |
Effect.Random(variable, minimum, maximum) |
Sets a variable to a whole number from the story's own generator, both ends included. |
Effect.SetToCondition(variable, condition) |
Sets a variable to whether a condition holds now. |
Effect.None |
Nothing. |
Effect.FromScript(code) |
What the script says, as after $: affection += 1. |
Effect.FromCode(v => ..., description) |
What a lambda changes. |
Effect.SetFromCode(variable, v => ...) |
Sets a variable to a value a lambda computes. |
a.Then(b) |
One effect, then another. |
StoryVariables: the story's variables, as a lambda sees them.
| Member | What it does |
|---|---|
v["name"] |
Reads or writes a variable by its full dotted name (affection, persistent.endings); an unset variable reads as none. |
Number, Integer, Text, Flag, List |
Reads a variable as a number, a whole number, text, a boolean or a list. |
ListContains(name, item) |
Whether a list variable holds an item, compared as text. |
SetNumber, SetText, SetFlag |
Sets a variable to a number, text or a boolean. |
Add(name, amount) |
Adds to a number. |
Visited(label) |
Whether a label has been entered in this playthrough. |
Random(minimum, maximum) |
A whole number from the story's own generator, both ends included. |
The reading members (Number, Integer, Text, Flag, List, ListContains, Visited) carry <const>true</const> in their documentation, so Divooka offers them in Function graphs, which are dataflow and take only what changes nothing. The writing members, and Random, which advances the generator, are offered in procedural graphs only.
Definitions are built with these:
| Type | Members |
|---|---|
AnimationBuilder |
Set(property, values...); Interpolate(warper, seconds, property, values...), with Linear(...) and Ease(...) for the common warpers; And(property, values...), another property in the same step; Pause(seconds); Repeat(times); Block(a => ...); Parallel(a => ..., b => ...); Include(transform, arguments...); Picture(image, with), a picture line; Choose((weight, a => ...)...), a weighted choice; Time(seconds); On("show, replace", a => ...), a handler for the image being shown, replaced or hidden; Contains(image, a => ...); Function(name), a function registered with RegisterTransformFunction; Script(timeline) for lines written as script. The script reference says what each does. |
AnimationValue |
A whole number is pixels and a number with a fraction a share of the stage; Pixels, Fraction, Parameter(name) and FromScript(expression) say which. |
ImageSources |
File(path), Color(color, width, height), Movie(path, loop), Filtered(source, zoom, flipHorizontal, flipVertical, crop, tint, saturation, brightness, blur, opacity), Grayscale(source), Composite(width, height, (x, y, source)...), Switch((when, source)...) (the first case whose Condition holds; a case with no condition goes last), FromScript(source). |
LayeredImageBuilder |
Always(file), Group(name, g => g.Attribute(name, file, isDefault, when), multiple), Attribute(...) outside any group, Size(width, height). |
Transitions |
Dissolve(seconds), Fade(out, hold, in, color), Flash(...), ImageDissolve(mask, seconds, ramp, reverse), Move(seconds, warper), Named(name, seconds): each returns the transition text a with takes. |
TranslationBuilder |
Line(label, speaker, original, translated) finds a line by the label it is in, who says it and what it says; LineById(id, translated, speaker) by an id given with LineOptions.Id; String(original, translated) translates a choice, a name, a prompt or a notification. |
ScreenBuilder, ScreenActions |
See screens as data. |
StoryText.ParseValue and StoryText.ParseTransition read a value or a transition as a script writes it, for code that takes them as text.
Commands and functions
A story reaches code in two ways: a run statement calls a command, and an expression calls a function.
using Novella.Runtime;
using Novella.Values;
// run riddle answer -> _return is true or false
game.RegisterCommand("riddle", arguments => Value.FromBoolean(arguments[0].AsText() == "moon"));
// A command that works with the runner: variables, the random generator, achievements.
game.RegisterCommand("pay", (StoryRunner runner, IReadOnlyList<Value> arguments) =>
{
double gold = runner.GetVariable("gold").AsNumber() - arguments[0].AsNumber();
runner.SetVariable("gold", Value.FromNumber(gold));
if (gold <= 0)
runner.GrantAchievement("broke");
return Value.FromNumber(gold);
});
// A command with no arguments and no result.
game.RegisterAction("shake_controller", () => Rumble());
// if is_full_moon(day): ...
game.RegisterFunction("is_full_moon", arguments => Value.FromBoolean(arguments[0].AsInteger() % 30 == 15));
Values are Novella.Values.Value: a number, text, a boolean, a list or null, made with Value.FromNumber, FromText, FromBoolean and FromList, and read with AsNumber, AsInteger, AsText, AsBoolean and AsList. A command's result goes into _return. Commands run on the frame thread, synchronously, between two statements: they should return promptly, and a command that needs the player belongs in a called screen instead.
RegisterCommandFunction(name, arguments => ...) and RegisterExpressionFunction(name, arguments => ...) are the same two extensions with text in and text out: the arguments arrive as text, and the answer is read as a script reads a setting (a number, true or false, a list or quoted text, and otherwise the text itself). They are what the Divooka nodes Register Command Function and Register Expression Function call, so a graph can implement a command or a function; C# code is better served by the Value forms above.
An animation's function name statement calls a transform function, registered with RegisterTransformFunction(name, (state, seconds) => ..., duration): given the image's TransformState and the seconds since the statement began, it answers the state to show, for duration seconds before the timeline carries on, or for good when no duration is given. It must depend on those two inputs only, so that a load or rollback shows what was shown.
// transform wobble:
// function wobble
game.RegisterTransformFunction("wobble", (state, seconds) => state with { Rotate = 4 * Math.Sin(seconds * 6) }, duration: 2.0);
The validator knows which commands, functions, transform functions and screens a game registers, so Validate() reports a run of a command that does not exist before a player meets it. A game that overrides Command counts every run name as handled.
A story's Python needs no registration: reference Novella.Python and the engine finds it when the story has python: or init python: blocks, and the functions init python: defines join the ones a game registers. Without it, Validate() reports that the story has Python and the game does not.
Live scripts
A live script is a procedure that is the story. Override Script (or pass a delegate to UseLiveScript) and starting a game runs it on its own thread, where each call plays immediately and returns when the player has answered:
public sealed class RiddleGame : NovellaGame
{
public RiddleGame()
: base("A Riddle", 1280, 720)
{
}
protected override void Setup()
{
DefineCharacter("q", "Qinglan", "#c0392b", "qinglan");
}
protected override void Script()
{
Scene("bg garden", "fade");
Show("qinglan smile", "right", "dissolve");
Say("q", "Sometimes it rests on the mountainside, sometimes it hangs from the treetop. What is it?");
int answer = Menu(["A gong", "The moon", "A lantern"]);
Say("q", answer == 1 ? "The moon. Correct." : "Look up, scholar.");
string name = Input("And your name?", "Lu Wenjing");
Say("q", "Goodnight, " + name + ".");
}
}
The live calls are Say, Narrate, Show, Scene, Hide, With, Pause, Play, Stop, Menu (returns the chosen index), Input (returns the text), CallLabel, Notify and Achieve, with SetVariable, GetVariable, GetNumber, RandomInteger and TranslateText for the story's state (these also work inside a command). Each runs through the runner exactly as the matching script statement would, so transitions, the history and skipping all behave as usual.
A live script cannot be saved, loaded or rolled back, because its position is in a running procedure rather than in the story. CallLabel runs a story label and continues when it returns, but the live script underneath still prevents saving and rolling back while the label runs. When the player returns to the main menu, the script's pending call throws LiveScriptCancelledException, which unwinds it; do not catch it. Use live scripts for small games and prototypes, and labels for anything a player will want to save; the story builder writes those labels in C# just as directly.
Custom screens
A custom screen is something the story shows with show screen or calls with call screen. It is written one of two ways: as data, in a screen language of elements, boxes, conditions and loops, or as a C# class, which can draw anything. When both exist under one name, the class registered in C# wins, so a game can replace a defined screen with a hand-made one without changing the story.
Screens as data
A screen written with ScreenBuilder is the same thing a script's screen definition is, and the Screens nodes in Divooka build it too. It saves, validates and prints as script with the rest of the story.
story.Screen("map", s => s
.Background("bg town_map")
.Text("Where to?", 0.5, 0.08, size: 40)
.ImageButton("gui/map_market.png", 420, 300, "gui/map_market_hover.png", null, ScreenActions.Jump("market"))
.Button("The river", 0.7, 0.6, Condition.Visited("market"), ScreenActions.Set("went_river", true), ScreenActions.Jump("river"))
.Button("Stay home", 0.5, 0.9, ScreenActions.Return("home")));
Boxes, branches and loops take a builder for what they hold. This is the inventory the script reference writes as script:
story.Screen("inventory", s => s
.Modal()
.Variable("sorted_view", false)
.Frame(f => f
.VBox(v => v
.Text("Your bag", size: 44).Align(0.5, 0.0)
.Viewport(640, 300, list => list
.For("item", "bag", row => row
.Button("[item]", ScreenActions.SetTo("chosen", "item"), ScreenActions.Hide())), spacing: 8)
.If(Condition.FromScript("not bag"), empty => empty.Text("Nothing yet."))
.HBox(h => h
.Button("Sort", ScreenActions.Toggle("sorted_view"))
.Button("Close", ScreenActions.Hide()), spacing: 20), spacing: 14),
padding: 30, width: 700, height: 560).Align(0.5, 0.5)
.Key("escape", ScreenActions.Hide()));
ScreenBuilder |
Adds |
|---|---|
Dim(), Modal(), Background(image) |
The game darkened behind the screen; a shown screen that keeps every click; an image covering the stage behind the elements. |
Parameters(StoryBuilder.Parameter(...)...) |
Parameters the screen's arguments bind to, readable in its text and conditions. |
Variable(name, value) |
A variable of the screen's own, as default in a screen: set when it opens, changed by its buttons, lasting as long as it does. |
Text(text, [x, y,] size, color, width, when) |
Text, which may interpolate [variable]. |
Image(image, [x, y,] zoom, when) |
A picture: an image name or a file. |
Button(text, [x, y, [when,]] actions...) |
A button with text on it. |
Sensitive(condition) |
Makes the button added last pressable only while the condition holds; it shows dimmed otherwise. |
ImageButton(image, x, y, hoverImage, when, actions...) |
A picture to press, with another under the pointer. |
Bar(variable, maximum, [x, y,] width, height, when) |
A bar showing a variable against a maximum. |
Input(variable, maximumLength, allowed, width, when) |
A text field that types into a variable. |
Space(width, height) |
Empty space, to hold things apart in a box. |
Timer(seconds, [repeat,] actions...) |
Acts once the time has passed, or every time it passes. |
Key(key, actions...) |
Acts when a key is pressed while the screen is up: "escape", "enter", "left", "f1". |
VBox, HBox, Grid(columns, ...), Frame, Fixed, Viewport(width, height, ...) |
A box, each taking a builder for its children and, by kind, spacing, padding, a background, a size and a condition. |
If((condition, s => ...), (condition, s => ...), (null, s => ...)), If(condition, then, otherwise) |
The elements of the first branch whose condition holds; a branch with no condition is the else. |
For(variable, list, s => ...) |
Elements once for every item of a list, with the item in a variable of its own. |
Use(screen, arguments...) |
Another screen's elements, in place, with arguments for its parameters. |
At(x, y), Align(x, y), Anchor(x, y) |
Placement of the element added last: centred on a position; the same share of it at the same share of its area (Align(1.0, 1.0) is the bottom right corner, and in a box, its alignment across the box); which point of it is put at its position. |
ScreenActions |
Does |
|---|---|
Return(value) |
Answers a called screen; the story reads the value as _return. |
Jump(label) |
Closes the screen and continues the story at a label. |
Set(variable, value) |
Sets a variable: the screen's own, if it has one of that name, and otherwise the story's. |
SetTo(variable, expression) |
Sets a variable to what an expression gives when pressed, such as a For row's item. |
Toggle(variable) |
Turns a true variable false and a false one true. |
Notify(text) |
Shows a notification. |
Play(file) |
Plays a sound once. |
Open(menu) |
Opens one of the game's menus: save, load, preferences, history, gallery, music_room, achievements or main_menu. |
Hide() |
Closes the screen; a called screen answers none. |
ShowScreen(name), HideScreen(name) |
Shows or hides another screen. |
Run(command) |
Runs a command the game registers. |
Positions are AnimationValues, read as in an animation: 0.5 is a share of the area, 420 is pixels. The screen is laid out again every frame from the story's variables and its own, while a button's hover, a field's typing and a viewport's scroll are kept by the element's place in the screen. The script reference describes how each element, box and action behaves.
Screens as classes
A screen the data cannot describe, such as one with drag and drop, is a class derived from NovellaScreen, registered by name.
using Divooka.Graphics.Canvas.Primitives;
using Novella.Hosting;
using Novella.Presentation.Screens;
using Novella.Presentation.Ui;
using Novella.Values;
/// <summary>call screen gift_picker(["hairpin", "fan", "book"]): returns the gift chosen.</summary>
public sealed class GiftPicker : NovellaScreen
{
private readonly ScreenRequest _request;
public GiftPicker(ScreenRequest request)
{
_request = request;
}
// Draw the game behind this screen.
public override bool IsOpaque
=> false;
public override void Opened()
{
IReadOnlyList<Value> gifts = _request.Arguments.Count > 0 ? _request.Arguments[0].AsList() : [];
for (int index = 0; index < gifts.Count; index++)
{
Value gift = gifts[index];
Ui.Add(new UiButton(gift.AsText(), ButtonKind.Choice, () => Session.Game?.ReturnFromScreen(this, gift))
{
Bounds = RectF.FromXYWH(Session.Width / 2f - Style.Px(400), Style.Px(320) + index * Style.Px(100), Style.Px(800), Style.Px(80))
});
}
}
}
game.RegisterScreen("gift_picker", request => new GiftPicker(request));
call screen gift_picker(["hairpin", "fan", "book"])
$ gift = _return
- The factory receives a
ScreenRequestwith the screen'sName, its evaluatedArguments, andIsCall. - A called screen answers with
Session.Game.ReturnFromScreen(this, value), which closes it and puts the value in_return. Closed any other way (Escape, which the defaultBackhandles by closing the screen, or the screen's ownClose), it answers none, so the story never waits on a screen that is gone. - A shown screen is drawn over the game, gets the pointer, and stays until
hide screen; because the stage records it, a save or rollback restores it. - A screen builds its elements in
OpenedwithUi.Add(UiButton,UiSlider,UiTextField,UiDrawing), draws extra things by overridingDraw, animates inUpdate, and rebuilds inRefreshafter a load, rollback or language change.Stylegives the theme's fonts, colours and scaled sizes (Style.Px), andSession.Translatethe interface translation of a word.
Stories as code
Everything a script says can be built as objects. The story builder is the comfortable way to do it in C#; the node classes underneath the Divooka nodes are another, and what the functional nodes call:
using Novella.Model;
using Novella.Nodes;
using Novella.Scripting;
Sequence scene = SequenceNodes.StartSequence();
scene = SequenceNodes.Scene(scene, "bg garden", "fade");
scene = SequenceNodes.Say(scene, "q", "You came.", "smile");
Story story = StoryNodes.AddLabel(StoryNodes.StartStory(), StoryNodes.MakeLabel("start", scene));
story = StoryNodes.AddCharacter(story, CharacterNodes.MakeCharacter("q", "Qinglan", "#c0392b", "qinglan"));
// Or parse script text, and write a story back as script.
Story parsed = ScriptParser.Parse(File.ReadAllText("game.novella"), "game.novella");
string text = ScriptWriter.Write(story);
A GameDefinition holds a story with its configuration, theme and asset sources; new NovellaGame(definition) plays one.
Headless runs and tests
The runner alone
Novella.Core runs a story with no window, no audio and no clock, which is how a story's logic is unit tested:
using Novella.Model;
using Novella.Runtime;
using Novella.Scripting;
const string Script = """
character q "Qinglan"
default affection = 0
label start:
q "You came."
menu:
"I did.":
$ affection += 1
"I was passing.":
pass
q "Listen, then."
""";
StoryRunner runner = new(ScriptParser.Parse(Script, "test.novella")) { Seed = 12345 };
runner.Start();
Assert.Equal("You came.", ((SayInteraction)runner.Pending!).Marked.PlainText);
runner.Continue();
MenuInteraction menu = (MenuInteraction)runner.Pending!;
runner.Choose(menu.Options[0].Index);
Assert.Equal(1.0, runner.GetVariable("affection").AsNumber());
Assert.True(runner.Rollback());
The runner waits on Pending (a SayInteraction, MenuInteraction, InputInteraction, PauseInteraction, TransitionInteraction, MovieInteraction or ScreenInteraction) and is answered with Continue, Choose, SubmitInput or Respond; a transition or timed pause is answered with Continue. Rollback, RollForward, CanSave, SnapshotForSave and Load exercise rollback and saves, and Seed makes random draws reproducible. Images need not exist: a missing one is a placeholder name on the stage, which State.Stage shows. The runner raises LabelEntered, AudioChanged, Notified, AchievementUnlocked, ChoiceMade, ScreenChanged, StateRestored and Ended.
The whole game, headless
RunHeadless builds the real session on ShadowGUI's headless host, and AutoPlayer plays it through the real controls (Space to advance, number keys to choose, typing for inputs, Escape to close a called screen), so a playthrough exercises the screens a player uses:
using Divooka.Graphics.ShadowGUI.Headless;
using Novella.Hosting;
using Novella.Persistence;
HeadlessHost host = game.RunHeadless(new MemorySaveStorage());
NovellaSession session = game.Session!;
AutoPlayer player = new(session, host)
{
ScreenshotFolder = "shots",
ScreenshotEvery = 20,
InputText = "Lu Wenjing"
};
player.QueueChoices([0, 1, 1]);
PlaythroughReport report = player.Play(900);
File.WriteAllLines(Path.Combine("shots", "transcript.txt"), report.Transcript);
session.Dispose();
return report.Problems.Count > 0 ? 3 : 0;
Each screenshot gets a text file beside it naming the step, the label, what the story waited on and what stood on the stage with each image's rectangle, so a picture can be checked and reproduced without guessing what it was meant to show. The report lists the labels visited, the interactions, whether the end was reached, and every problem met: a missing image or sound, a script error. Queued choices are used in order, then the first option is chosen. A movie is pictured like any other step, and a skippable one is clicked through after 1.5 seconds (MovieTime); a transition, timed pause or unskippable movie that holds the story for more than 120 seconds (WaitLimit) is reported as stuck.
A quicker check needs no code: with NOVELLA_HEADLESS set to a number of interactions, Run plays the game headless through its real controls, choosing the first option of every menu, prints what was said and reports problems on the error stream. It is how a Divooka example document or a build is checked without a desktop.
The Red Thread exposes all of this on its command line:
TheRedThread [assets folder]
TheRedThread --lint [assets folder]
TheRedThread --shoot out-folder [--choices 0,1,2] [--language zh-Hans] [--steps 900] [--every 20] [assets folder]
NovellaPlayer lint, shoot and bench do the same for any script folder.
Sessions and hosting
Run is CreateSession plus NovellaHosting.RunWindow. A host that wants more control can make the session itself: NovellaSession owns the flow (splash, main menu, game, game menu), the saves (Saves), the persistent data, the audio mixer, the screens and the notifications, and raises GameEnded and QuitRequested. Saves go to FolderSaveStorage.ForGame(config id), under %AppData%/Novella, unless another ISaveStorage is given; MemorySaveStorage keeps them in memory, as previews and tests do.