Making a Very Bad Scripting Language

Back in March 2024, I was working on my Relics game, which remains unfinished. Something that has often been a drag on my development velocity is that in order to set up game state, I often need to leave play mode or complicate code in ways that make it harder to maintain.

Then, it occurred to me, why not yield to my Quake influences and make a console in which I could edit values and even trigger commands? So, I made a console that I could easily toss into games. Different scripts register commands under a given name and receive an array of strings representing the invocation (the first being the command name) that they could then operate on. The tokenization would be quote-aware, allowing whitespace within tokens as long as it was quoted.

If I see a use case for a command, I toss it in. It works pretty well.

As an example, this command could schedule text lines to be shown in a text box:

say_text "First line" 'Second line' Third!

Which would get split into three arguments: First line, Second line, and Third!.

When I was working on Unwon, Undone and dreading doing some one-off scripting to handle what happens when you run off of a cliff, I was incredibly unmotivated. And when I’m unmotivated, I often find it useful to try to piggyback that on something I find more engaging. In this case, my own scripting language. I also had a ticket in my backlog for supporting multiple lines in my Talkable MonoBehaviour.

Well, I already had my console, which could run arbitrary commands, and what is a scripting language if not just running a sequence of arbitrary commands? So why not make my own bad scripting language named, say, Kevin’s Very Bad Scripting Language (KVBSL)?

I split out the command registration core from ConsoleManager and made a quick ScriptRunner that would run each command, one after the other.

Then, because that’s pretty weak in and of itself, I added two keywords:

  • and <cmd>: This runs <cmd> concurrently with the previous line, with the script waiting until both are complete before continuing.1
  • bg <cmd>: This runs <cmd> without waiting for it to finish (in the background).

and some built-in commands:

  • wait <X> - This waits for X seconds (game time)
  • waitrt <X> - This waits for X seconds (real time)

And that’s it! For this project, I made a few tie-in commands that let me set ValueReferences in my Ratferences library, ones that let me raise signals, one that let me ramp a FloatReference from one value to another, one that let me play arbitrary AudioEvents (in 2D space), and one that shove dialogue lines into my text box animation queue.

Example of a script:

set_bool_so PlayerFrozen true
play_sound FallChasm
and ramp_float_so FaderRef 0 1 0.1
and wait 0.2
bg play_sound WhoopsyDaisy
say_text FaderTextQueue "Not yet."
teleport_to chasm
set_float_so FaderRef 0
set_bool_so PlayerFrozen false

The Scripting Languages I Already Have

Boutique C# Scripts

Unity’s all about C#, and at any given moment there’s the opportunity to create a new MonoBehaviour and slap it onto a game object.

Advantages

  • Type safety.
  • Objects of any type can be hooked up.
  • Has branching and conditionals.
  • The most flexible option—it’s a fully featured language.
  • Probably the most performant.
  • Autocomplete (in Visual Studio).

Disadvantages

  • The more specific the script, the less transferrable.
  • As it gets more composable, it gets towards the latter approach.
  • The most verbose approach.
  • Either it’s very configurable and hard to set up, or very specific and I need to write many of them.

These boutique scripts are generally what I use for scene-scoped scripts that are continually driving behavior, not one-off actions the player can trigger.

Composable MonoBehaviours

I have a mechanism for composing actions (discrete MonoBehaviour code units on GameObjects), which I normally use for simple tasks. For instance, you can have an ActionSequence, which takes in a list of Actions; ActionSetInt, which sets an IntReference to a value; and ActionFader, which fades out the screen.

Advantages

  • Type safety.
  • Objects of any type can be hooked up.
  • Has branching and conditionals.
  • More reusable than boutique scripts.
  • Supports asynchronous and background actions.
  • Probably pretty performant.
  • Autocomplete (in Visual Studio).

Disadvantages

  • Since they’re all put on one object and chained together, it’s hard to get a high level view of what any script does. Multiple times I’ve realized that script calls to another aren’t linked up, which isn’t immediately noticeable at runtime.
  • Have to choose between having multiple specific actions or one overly general action that’s uglier in the inspector.
  • Have to have a different command or an additional argument to distinguish between running a command in the background or synchronously.

These scripts were previously my go-to for triggering scripts on interactions with objects or when the player entered a trigger. If it gets to the point where I feel I need a conditional, I usually just go to the boutique script option.

ink

ink is a narrative scripting language, and it offers some fairly nice scripting functionality which, similar to KVBSL, allows external command registration.

Advantages

  • Has branching and conditionals.
  • More reusable than boutique scripts.
  • Generally fairly simple to follow.
  • Autocomplete (in inky)

Disadvantages

  • Requires including the ink library, which feels heavyweight if I’m just using it for scripting.
  • No type safety.
  • Some types (like lists and dictionaries) which work in odd ways.
  • A lot of work to make commands run in parallel.
  • Have to have a different command or an additional argument to distinguish between running a command in the background or synchronously.
  • External function registration requires a good deal of boilerplate.

Historically, games where I use ink tend to cannibalize most of the cases where I’d normally use composable MonoBehaviours.

The Niche for KVBSL

KVBSL shines for linear scripts where I want simple concurrency and easy timing tweaks. Anything beyond primitives usually needs a boutique command wrapper. As an additional bonus: if I wanted to add modding, I could load these files from disk.

Downsides

No autocomplete.

Since commands can vary not only per-project, but also per-scene or within a scene, there is no autocompletion of command names or subcommands. So far, this hasn’t proven problematic, as the number of commands is fairly small. If I build up an extensive suite of commands to register, this could become onerous.

Command registration is a pain

As I have it now, ScriptingEngine lives at a scene scope. Due to how registration works, a ScriptingEngine cannot be a subset of command registrar lifecycles. If that happens, commands will not be registered correctly. This issue is mostly obviated by my ScenePrefabLoader script, which allows me to easily instantiate commands that are supposed to be present on all scenes consistently.

Registering ValueReference objects currently uses a script that needs to be manually updated to hold all the relevant references. If I forget to update it, that ref will not be available. If I want to make transforms available to commands, they probably need to be keyed off of some dictionary. The composable actions are effectively still here, but mostly hidden behind a layer of indirection, which isn’t desirable.

No compile-time checks

If there’s an error in the command name, it will fail. If an argument is not the expected type, it will fail. Not a huge problem, since these scripts are small and don’t have conditionals, but it runs the risk of me making a last minute change to the script, not testing it properly, and breaking the game.

Performance

I haven’t profiled it, but these scripts will almost certainly be less performant than C# scripts or composed MonoBehaviours—it reparses the string every time a script is run, and commands often coerce strings to other variable types. Some preprocessing would give improvements here (I could pretokenize the scripts, or even precompute all the types a value could convert to), but doing it in advance seems premature, especially since it would complicate the code (handling a string or a tokenized string, which would probably actually be fine since it’d be just exposing an internal method anyway) and precomputing conversion types would consume more memory (although probably not a lot). I don’t think it’s worth doing anything until it starts causing problems, especially since all of the cases that I’m replacing aren’t frequently run.

Some Implementation Details

How scripts are run

I mentioned above that I had an “action” pattern for running composable scripts previously. These actions have two methods: Begin(), which tells the script to start running, and Finished(), which Begin (or some method downstream of it) calls when the action is finished. They naturally support asynchronicity. I made a new action, deliciously named ActionScript, which runs the script.

This is a simplified version of ActionScript:2

public class ActionScript : Action {
    [SerializeField] KVBSAsset ScriptAsset;
    [TextArea][SerializeField] string Script;

    public override void Begin() {
        StartCoroutine(Run());
    }

    IEnumerator Run() {
        var script = ScriptAsset != null ? ScriptAsset.Text : Script;
        yield return ScriptingEngine.INSTANCE.RunAndAwait(script);
        Finished();
    }
}

It should be pretty straightforward—it just delegates the running to ScriptingEngine.

How commands are registered

Here are two built-ins for ScriptingEngine:

IEnumerator WaitScaled(float amt) {
    yield return new WaitForSeconds(amt);
}

_runner.RegisterHandler(new Command {
    Name = "wait",
    Description = "wait <seconds> in scaled time.",
    RunCallbackAsync = (args, setOutput) => { 
        // ExpectX calls use 0 for the first *argument* (args[1]).
        return WaitScaled(ScriptRunner.ExpectFloat(args, 0));
    }
});

_runner.RegisterHandler(new Command {
    Name = "log",
    Description = "Log messages.",
    RunCallback = (args) => {
        return string.Join(" ", args.Skip(1));
    }
});

The first is an asynchronous command that waits a given time and then lets the engine run the next command.3 The second just logs a message to the console.4

Due to eccentricities with the implementation of IEnumerable and lambdas, I need the level of indirection with the WaitForSeconds helper function.

How files are imported

Unity is a bit finicky with its file types by default, so it won’t recognize my custom suffix kvbs automatically. Thankfully, it’s pretty easy to import this in such a way that I can use it as a KVBSAsset in my scripts:

[ScriptedImporter(1, "kvbs", AllowCaching = true)]
public class KHScriptImporter : ScriptedImporter {
    public override void OnImportAsset(AssetImportContext ctx) {
        var bytes = File.ReadAllBytes(ctx.assetPath);
        var text = Encoding.UTF8.GetString(bytes);

        var asset = ScriptableObject.CreateInstance<KVBSAsset>();
        asset.name = Path.GetFileNameWithoutExtension(ctx.assetPath);
        asset.SetText(text);

        ctx.AddObjectToAsset("KHScriptAsset", asset);
        ctx.SetMainObject(asset);
    }
}

At a high level, what this does is read the script file, make a scriptable object for it, and then associates that scriptable object as the main object for the kvbs file.

If I were to pre-tokenize the script to try to eke out some performance gains, it would happen at this importer.

The Future

Variables?

I’ve given the smallest amount of thought to this, but I’d like to add light variable support to this. Like how commands are registered, handlers (like my ValueReferences) would be able to register typed variables. Each handler would define a prefix used to disambiguate variables with identical names.5

For instance, I’d probably prefix my ValueReference objects with ref., and ink variables would be prefixed with ink.. They’d be in objects that would specify whether they were write-only, read-only, or read-write. My ValueReferences would be read-write. Signals would be write-only (you could raise a signal, but not read from a signal). Read-only I don’t have a direct use case for, but it’s easy to imagine providing some constant values there.6 Commands, when registered, could expect writable variable references (reading would probably be transparent to the command).

Maybe something like:

set $ref.FaderRef 0
set $ref.LevelSignal $ref.CurrentLevel

(This would be equivalent as long as there wasn’t collision.)

set $FaderRef 0
set $LevelSignal $CurrentLevel

I could also allow namespaces to generally register on a prefix and have runtime querying. This would be useful for things like ink, where having a second full list of variables is just a waste of resources.

I’m getting too deep into the weeds on this, but I’d probably allow spaces in the name, so $"ink.foo bar" would be a valid variable reference, but "$ink.foo bar" would just be a string reference. If I wanted to support string templating, I could take the backticks from JavaScript, but that’s thinking way too far ahead.

Some light branching?

Conditionals sure are nice. Having an if statement would probably capture another large group of use cases for this, at the cost of increasing the risk that a bug could slip through. I would certainly need variable support for this to be useful.

As for syntax, I would probably go with something basic, like:

if $ref.int == 5 && $ref.float > 6.2
    run_command 1
elif $ref.int == 5
    run_command 2
else
    run_command 3
end

This keeps the same-line parsing principle (mostly, except for bypassing commands that aren’t relevant), but without curly braces that encourage being placed on the same line.

Invoking actions?

One of the nice things about the composable MonoBehaviours I have above is that they’re type-safe. I could see wanting to invoke a specific action from a KVBSL script (say ActionTeleportTo which would have a GameObject and a Transform on the inspector). On its face, this seems pretty easy. Just have a string -> Action map on ActionScript and have an invoke command: invoke teleport_action.

Do you see the problem in this? Commands are scoped across all script executions. I’d have to register it with the scripting engine or it wouldn’t know about the action, but I couldn’t register it because that would allow other scripts running at the same time to also be able to invoke its actions (or collide on the registered command).

I’d need to add the ability to provide additional commands only on a script execution. Kind of a slanted version of variable scoping. It’s possible, but feels a little clumsy.

Alternatively, I could allow script-specific variables and have a generic invoke command take an Action type and then pass it in that way. So many options!

Syntax highlighting?

In my post about my data serialization language, I hoped to get syntax highlighting up in VSCode. That ended up being complicated due to that language keeping state across multiple lines. This one doesn’t, which should theoretically make syntax highlighting pretty easy. Hopefully I’ll figure out how to do that so my scripts look pretty.

Closing Thoughts

This was fun! Admittedly, I’d already done much of the work with my work on the console manager and command parsers, but the command parser7 is fewer than 100 lines, and most of the console manager work was on registering and unregistering commands, which isn’t particularly interesting.

Languages don’t need to be big. They don’t need to be maintainable. They can just be fun. Now, would I use this on some professional thing with that attitude? No, but this is me programming as a hobby. I can have a little fun to help me do the boring parts.

In my post about my very bad data serialization language, I said that I suspected I’d never use it. I actually am using it in some of my newer projects. I’m already using this in Unwon, Undone. Now, should you use this? Eh, it’s probably fine. Don’t worry about it.

This will just be another tool in my toolset as I chew away on making more games.


  1. All consecutive and ... lines attach to the most recent non-and line. ↩︎

  2. I have a context menu that will transition the inlined script string into a script asset file, but other than that, this is it. ↩︎

  3. Eagle-eyed readers will see that the ExpectX helper functions, perhaps misguidedly, have the zero index be the first argument to the command (index 1) instead of taking the command itself. ↩︎

  4. The returned string from sync calls is logged for historical reasons related to supporting the Quake-style console. ↩︎

  5. As I write this, I’m leaning towards requiring prefixes on all invocations, with scope checks being ordered from longest -> shortest. That would avoid cases where an unprefixed variable (e.g. $ink.foo) could collide with a variable named foo in the ink namespace (also $ink.foo). If I require this, it’s no longer ambiguous, and it’s easier to track down who should be providing the variable. ↩︎

  6. Imagine a wait_until command that waits on some derived state, like $player.IsGrounded↩︎

  7. I didn’t cover this here, primarily because I didn’t actually work on it for this project, but also because it’s just another boring text parser. The code is here if you’re curious. ↩︎