Imagine an NPC patrolling an area. The NPC notices an enemy and switches to combat. During combat, it takes damage and plays a hit reaction. Once the reaction is over, it resumes fighting. When combat ends, it returns to its patrol.
This is ordinary behavior in a game. But implementing it can become surprisingly complicated.
Where should each action be interrupted? Who remembers what the character was doing? How should an interrupted action resume?
I wanted to find a simpler way to describe these behaviors.
That idea eventually led me to develop Mana, a scripting language for game characters and gameplay logic.
I've been working on it as a personal project since 2016, and finally reached version 1.0 in 2026.
Now that I've reached that milestone, I'd like to write about some of the decisions I made while developing the language, the problems those decisions created, and the questions I still haven't resolved.
This is not intended to be a language tutorial or a feature announcement. It's a series of development notes about the difficulties of designing a scripting language specifically for games.
For this first article, I'll focus on one of Mana's central ideas: requesting an action instead of directly calling it.
First, Try Running It
Before getting into the design, it may be easier to see how the language behaves.
Mana Playground — Try it in your browser
The Playground runs the compiler and virtual machine through WebAssembly, so nothing needs to be installed.
There are three examples:
- Hello: A minimal script that prints a message.
- Actor: A lamp that remembers its brightness and changes its state when asked.
- Request: A conversation interrupted by an alarm, followed by a return to the conversation.
The Request example is particularly relevant to this article.
Try changing the alarm priority from 100 to 5 and observing how the execution order changes.
You can also replace request with await to see what happens when one actor waits for another.
You don't need to understand the syntax yet. The interesting part is how changing a small piece of code changes the order in which actions execute.
Who Should Own an Action?
In Mana, game characters and other participants are represented as Actor objects.
Each Actor owns its behavior, represented by Action definitions.
The basic idea is simple:
Character = Actor
Behavior = Action
Interaction = Request
For example, to ask a guard to move:
request(3, Guard->move());
This means: ask the Guard actor to execute its move action with priority 3.
The syntax may look similar to a function call, but the execution model is different.
The idea was inspired by the Actor Model, although Mana is not intended to be a conventional implementation of it.
I wanted to adapt some of its concepts to the specific problems of game behavior: actions that take time, can be interrupted, and may eventually resume.
That raised a question:
Should one character execute another character's behavior, or should it simply ask that character to perform the behavior?
I chose the latter.
Why Not Just Call a Function?
In a conventional synchronous function call, the caller waits until the function returns.
For short operations, this is usually exactly what we want.
Game actions, however, often take place over multiple frames.
A character may walk toward a destination. A door may take several seconds to open. A conversation may pause while waiting for player input.
Of course, none of this requires a new programming language.
State machines, coroutines, asynchronous functions, and task schedulers can all handle such behavior.
The difficulty I wanted to address was not asynchronous execution itself.
It was the responsibility for interrupting, preserving, and resuming an action.
Consider the patrol example again.
If the character switches from patrol to combat, something must remember the patrol state. If a damage reaction interrupts combat, the combat state must also be preserved.
When the damage reaction finishes, combat should resume. When combat finishes, patrol should resume.
As more behaviors interact, the code managing these transitions can become more complicated than the behaviors themselves.
I began wondering whether the scripting runtime could manage these execution states instead of requiring every game system to do so independently.
That was one of the motivations behind Mana's Action model.
A Request Is Not a Direct Execution
In Mana, request sends an action request to another Actor.
It does not immediately execute the requested Action as an ordinary function call.
For example:
request(10, NPC->talk());
print("continue\n");
The requesting Actor continues without waiting for the NPC's conversation to finish.
The NPC manages the execution of its own actions.
This separation was important to me.
The requester should be able to express what it wants another Actor to do without managing exactly how that Actor executes the behavior.
For example, an event controller should not need to know every detail of the NPC's current movement, conversation, or combat state.
It can simply request an action.
The runtime then determines when that action can execute, based on the target Actor's current state and priorities.
This does not eliminate coordination problems, but it changes where those problems are handled.
Interrupting and Resuming Actions
Mana associates a priority with each action request.
A higher-priority request can interrupt a lower-priority Action.
For example:
Priority 1: patrol
|
| Priority 5 request arrives
v
Priority 5: damage
|
| damage finishes
v
Priority 1: patrol resumes
The interrupted Action retains its execution state, allowing it to continue after the higher-priority Action finishes.
The script does not need to explicitly jump back to the patrol behavior.
The Request example in the Playground demonstrates this with a conversation.
const int kTalk = 10;
const int kAlarm = 100;
actor Guard
{
action talk()
{
print("Guard: Halt! Who goes there?\n");
request(kAlarm, self->alarm());
print("Guard: False alarm. You may pass.\n");
}
action alarm()
{
print("Guard: (The bell rings!)\n");
}
}
The guard requests its own alarm action while executing talk.
Because the alarm has a higher priority, it interrupts the conversation. Once the alarm finishes, the conversation resumes.
If the alarm priority is changed to a value lower than the conversation priority, the execution order changes.
The action code itself does not need to manage the interruption.
That was the behavior I wanted from the runtime.
However, once I implemented this model, new design problems started to appear.
Was Using Priorities as Execution Slots a Good Decision?
One important limitation of Mana's current implementation is that each Actor can hold only one request at a given priority level.
For example, if an Actor already has an Action registered at priority 3, another request at priority 3 will not be accepted.
This is different from a conventional task queue.
The reason is that Mana manages Action execution states by priority. Each priority effectively acts as an execution slot.
This makes interruption and resumption relatively straightforward to manage.
But it creates a restriction.
Imagine several game events trying to start conversations with the same NPC.
Should those requests be queued and processed in order?
Should later requests be rejected?
Should a newer request replace an older one?
All of these behaviors could be useful, depending on the game.
Allowing multiple requests at the same priority might seem like an obvious improvement. But then I would also need to decide how requests are ordered, cancelled, replaced, or discarded when they become irrelevant.
What started as a simple priority system could gradually become a much more complicated scheduling system.
I still haven't decided whether the current restriction is the right trade-off.
Sometimes the Requester Needs to Wait
Non-blocking requests are useful when Actors should operate independently.
But game events often require a specific sequence.
For example:
- Talk to the guard.
- Open the gate.
- Let the traveler enter.
For this reason, Mana also provides await, which sends a request and waits for the requested Action to complete.
await(10, Guard->talk());
await(10, Gate->open());
request(10, Traveler->enter());
The event waits for the guard's conversation before opening the gate.
Once the gate has opened, it requests the traveler's entry without waiting for that action to finish.
An important detail is that waiting suspends only the requesting Actor's execution, not the entire game.
Other Actors continue running.
This allows sequential event scripts and independent character behaviors to coexist within the same execution model.
It also introduces another distinction that becomes important later: requesting an action, waiting for it to begin, and waiting for it to complete are not necessarily the same operation.
What Happens When a Request Is Rejected?
This is one of the design problems I am still thinking about.
In the current implementation, a request may be rejected for several reasons.
The target Actor might be stopped. It might be configured to reject requests. The requested Action might not exist, or its priority slot might already be occupied.
However, Mana's request statement does not directly return a result indicating whether the request was accepted.
That can be inconvenient.
Suppose a character asks an NPC to begin a conversation, but the request is rejected. The requesting script may need to choose another action.
How should it learn about the failure?
One possibility is to return a result from request.
But what exactly should that result represent?
There are several distinct events:
- The request was accepted.
- The requested Action started executing.
- The Action completed successfully.
These are not equivalent.
An accepted request may execute later. An Action may begin and then be interrupted. It may never reach normal completion.
This becomes even more complicated if Actions are allowed to accept arguments or return values.
With a regular function call, arguments and return values have relatively straightforward semantics.
With a deferred, interruptible Action, questions arise about the lifetime of arguments, when a result becomes available, and what happens if execution never completes.
Adding a return value to request does not solve all of those problems.
It may simply expose more of them.
How Much Should the Language Handle?
Looking back, I did not introduce request because I believed function calls were unsuitable for games.
I introduced it because I wanted the execution model to understand a particular kind of behavior:
An action can take time, be interrupted, preserve its state, and resume later.
By managing these behaviors in the runtime, I hoped to make gameplay scripts easier to write and reason about.
In that sense, the design has achieved part of what I wanted.
But every responsibility moved into the language becomes another responsibility of the language designer.
Supporting priorities raises questions about queues and scheduling.
Supporting non-blocking requests raises questions about completion and failure.
Supporting Actions with arguments or return values raises questions about data lifetime and synchronization.
The more the language handles automatically, the more complicated its semantics can become.
This has been one of the recurring difficulties throughout Mana's development:
Making the user's code simpler often means making the language itself more complicated.
I don't think there is a universal answer to where that balance should be.
It depends on what problems the language is intended to solve, and which problems should remain the responsibility of the application.
For Mana, that boundary is still evolving.
I have reached version 1.0, but that doesn't mean every design question has been settled.
There are still decisions to make about the relationship between Actions and ordinary functions, how much of game AI behavior the runtime should manage, and how responsibilities should be divided between C++ and scripts.
Those unresolved questions are part of what I want to document in this series.
I would also be interested in how other language and game-engine developers approach this problem.
Should a scripting language manage the lifecycle of gameplay actions, or is that responsibility better left to the game engine and its task system?
That's a question I am still trying to answer.
Top comments (0)