Wait States Are the Whole Point

Here is a rock, in the scripting language from my current engine:

function OnInteract()
{
    if (HeldItem() == 'Pickaxe')
    {
        i = 0
        while (i < 10)
        {
            DropItem('Rock', 1, GetPosition(this))
            WaitSeconds(0.1)
            i = i + 1
        }
        DestroyEntity(this)
    }
    else
    {
        QueueDialog('It wont budge')
    }
}

Hit the rock with a pickaxe and ten rocks fall out, one every tenth of a second, then the rock disappears. Hit it with anything else and it says so.

The interesting line is WaitSeconds(0.1), sitting inside a loop, in the middle of a function. That single capability is the reason the language exists.

This is the seventh post taking one system at a time out of my custom engines.

The Gap It Closes

Write that rock in C++ against a frame loop and the whole structure changes. The function can't block, so the loop has to be turned inside out: a member holding how many rocks have dropped, another holding time until the next one, a state enum saying whether this rock is mid-drop, and an update function running every frame that checks all of it.

Roughly twenty lines of state machine for four lines of intent, and the intent is no longer visible anywhere. A reader has to reconstruct "drop ten rocks, one every tenth of a second" from a counter, a timer, and a switch.

That mismatch is the defining property of entity behavior. Almost all of it is sequential when described and almost none of it can be written sequentially, because everything has to yield to the frame. Designers describe behavior as a sequence because that's what it is. Programmers implement it as a state machine because that's what the frame loop demands. The translation between those two is where the bugs and the arguments live.

A language with suspension deletes the translation. The script says what happens in the order it happens, and the runtime handles the frames.

Suspension Without Threads

WaitSeconds doesn't block. The interpreter records where the program was, returns, and resumes from that point when the time has elapsed. Same idea as WaitUntil for a condition, and yield for giving up the rest of a frame.

This is a coroutine, implemented by owning the interpreter. Because the language runs on a virtual machine whose state is data, saving and restoring an execution point is a matter of keeping a structure rather than doing anything with the C++ stack.

That's the strongest argument for writing an interpreter rather than compiling to native code for this use case. A hand-written interpreter gets suspension nearly for free, because the program counter is already a field. Getting the same behavior in compiled C++ means either real threads, with the locking and lifetime problems that brings for thousands of entities, or the state machine the script was meant to replace.

The engine also handles a case that's easy to miss: scripts have a persistence mode, so a program can be started as persistent or transient. A rock that gets destroyed halfway through dropping its rocks needs its suspended program cleaned up, and that isn't something the script author should have to think about.

Scopes Decide What a Script Can Touch

The other half of the design is what a script is allowed to do. Contexts are typed:

enum class ScriptContextType { None, System, Game, Entity, Trigger, UI };

and each one gets a binding scope that inherits from a parent scope. Functions are registered into a scope, so an entity script sees entity functions plus everything the game and system scopes expose, while a UI script sees a different set.

This is capability-based sandboxing arrived at through ordinary API design. A trigger script physically cannot call an entity-only function, because that name isn't bound in its scope, and the failure is at parse time rather than as a runtime error deep in a dungeon.

It also documents itself. The question "what can a trigger script do" has an answer that's a list of registrations, rather than requiring someone to read every binding and work out which ones make sense in which context.

Inheritance through parent scopes is what keeps it from becoming tedious. Common functions are registered once at the system scope and every context gets them. Without inheritance, five contexts would mean five copies of every shared registration, and they'd drift.

What Owning a Language Actually Costs

I've argued elsewhere that writing this language was defensible for one person and would be indefensible on a team, and the cost should be itemized rather than asserted.

There's no editor support. No syntax highlighting, no autocomplete, no go-to-definition, no debugger. Every one of those exists for Lua.

Error messages are whatever the author wrote, and the author writes them while thinking about the interpreter rather than about someone else's Tuesday afternoon.

Nobody arrives knowing it. Lua is a line on a résumé and a language with books about it. A bespoke language is a document nobody has read.

And every feature is a decision. Lua's semantics for scoping and numbers are settled, and settled questions cost nothing. In an owned language, every one is open, and each is a chance to pick something that seems fine and turns out to have been wrong three hundred scripts later.

Against all that, the language does exactly one thing better: its suspension model and its scoping match the engine's entity model precisely, because both were designed together. Embedding Lua and getting the same integration means writing the coroutine bridge, the scoped environment, and the type marshalling anyway. The gap between "embed Lua and integrate it properly" and "write a small language" is narrower than it looks, and it narrows further the more specific the integration needs to be.

The calculation still inverts on a team, and it inverts on team size rather than on project size. One person who knows every rule pays none of the documentation cost. Five programmers pay it continuously.

The commit history puts a number on the building half of that. The expression evaluator lands on 2021-09-14, for use in GUI files rather than for scripting. yield lands on 2021-09-19. Five days from an expression parser to a suspending script, and nine to something with compiled expressions, local scopes, and program contexts carrying their own time deltas. The number is small partly because it excludes cparse, which handles the expression grammar and is somebody else's finished work.

That nine days is the only cost here paid once. Everything above it, the missing debugger, the error messages, the onboarding, recurs for as long as the language is used, which by now is five years.

What Transfers

Look for the places where intent is sequential and implementation is a state machine. That gap is where a suspension primitive pays, and it's usually behavior code: NPCs, cutscenes, tutorials, quest steps, anything with the word "then" in its description.

If a system already has an interpreter, suspension is nearly free. If it doesn't, coroutines in the host language are the next thing to reach for, and a state machine is the answer of last resort rather than the default.

Scope the bindings by context rather than exposing one global set. It turns a class of runtime error into a name that doesn't resolve, and it makes the capabilities of each context readable.

And be clear-eyed that a bespoke language is a permanent maintenance commitment whose cost scales with how many people have to learn it, not with how much it's used.