Open-Source Project
WarScript
A lightweight scripting language with a bytecode VM, written in C#. Designed for embedding in games.
What is WarScript?
Scripting That Compiles to Bytecode
WarScript is a scripting language with an interpreter and bytecode compiler written in C#. Source code is parsed, compiled to bytecode, and executed on a stack-based virtual machine. The AST is discarded after compilation so only the bytecode stays in memory.
The language has its own clean syntax with classes, inheritance, coroutines, exception handling, and an import system. But the real power is the native binding layer: your C# game code and WarScript talk to each other directly, with no serialisation or translation in between.
Born out of Warman's development and tested in a shipped game, WarScript handles everything from event hooks and gameplay logic to spawning units and applying modifiers.
# Classes with properties and methods
class Entity [name, hp]
fun take_damage [amount]
this :: hp = this :: hp - amount
end
fun is_alive []
return this :: hp > 0
end
end
hero = new Entity ["Hero", 100]
hero :: take_damage [30]
assert hero :: hp == 70
# Exception handling
begin
risky_operation []
rescue err
print err :: message
ensure
print "cleanup done"
end # Import shared libraries
import "lib/vectors.ws"
# Coroutine with yield
fun patrol_loop [unit, waypoints]
loop wp in waypoints
Unit_move_to [unit, wp]
yield wait 2.0
end
end
# Start a looping coroutine
start_coroutine_loop ["patrol_loop",
{guard, {pos_a, pos_b, pos_c}}]
# Called each tick by the host
fun tick [dt]
players = get_players []
loop player in players
if Unit_get_hp [player] < 20
Unit_apply_modifier [player, "Regen"]
end
end
end The Language
Clean Syntax, Real Features
WarScript uses fun/end blocks, square-bracket arguments, loop/in iteration, and # comments. The syntax is deliberately simple so modders can pick it up quickly.
But it is not a toy language. WarScript has classes with inheritance, is and as operators for type checking and casting, begin/rescue/ensure exception handling, an import system with circular dependency detection and caching, coroutines with yield and yield wait, plus a full set of arithmetic, comparison, and logical operators.
The native binding layer lets scripts call directly into your C# game code and vice versa. Functions like Unit_apply_modifier and spawn_unit are C# methods that scripts invoke natively. Your game API becomes the scripting API.
Architecture
From Source to Bytecode
Bytecode Compiler
Source code is lexed, parsed into an AST, then compiled to bytecode. The AST is discarded after compilation to free memory. The bytecode is cached and reused for every subsequent call.
Stack-Based VM
A purpose-built virtual machine executes the bytecode. It features superinstructions (fused opcode pairs like compare-and-jump) to reduce dispatch overhead on hot paths.
Hot Reload
Swap source code at runtime while preserving all global variable state. Function definitions update immediately. Existing class instances keep working. Active coroutines are stopped cleanly.
Bytecode Serialisation
Compile once, load instantly. Save compiled bytecode to a binary stream at build time, then load it at runtime to skip the lexer, parser, and compiler entirely.
Sandboxing
Set per-call limits on bytecode instructions and heap allocations. When a budget is exceeded, the VM raises a catchable exception and stops cleanly. Essential for untrusted mod scripts.
Source-Map Debugger
Set breakpoints by line number, step through execution, and inspect local variables. The debug hook receives function name, line, and all locals at each pause point. Zero overhead when disabled.
Native Bindings
Source Generator Does the Wiring
WarScript ships with a Roslyn source generator. Mark a C# class with [WsModule] and its methods with [WsFunction], and the generator produces all the marshalling code automatically. Parameter types (double, int, string, bool, WarValue) are converted at the boundary. Return types are wrapped. No manual glue code.
For instance methods, the generator captures this in the lambda. For variadic functions, mark a parameter with [WsRawArgs] to receive the raw argument list. The generated code feeds directly into the library registry, which the docs exporter also reads.
You can still register native functions manually if you prefer. The NativeFunctionDefinition API takes a name, argument list, and a lambda. Both approaches coexist.
// C# with source generator attributes
[WsModule("math",
Description = "Math functions")]
public static partial class MathModule
{
[WsFunction("pow")]
public static double Pow(
double @base, double exp)
=> Math.Pow(@base, exp);
[WsFunction("lerp")]
public static double Lerp(
double a, double b, double t)
=> a + (b - a) * t;
[WsFunction("clamp")]
public static double Clamp(
double n, double lo, double hi)
=> Math.Max(lo, Math.Min(hi, n));
} Math
pow, sqrt, floor, ceil, round, abs, min, max, clamp, sign, lerp.
Array
length, contains, index_of, remove, remove_at, pop, insert, copy, clear, append.
Coroutine
start_coroutine, start_coroutine_loop, stop_coroutine, stop_all_coroutines. Yield with wait durations.
Utility
is_null and type-checking helpers. The registry is designed so adding new libraries is straightforward.
Built-In Libraries
Batteries Included
WarScript ships with four standard libraries registered automatically through the WarScriptLibraryRegistry. Every library exposes its functions with full descriptions and return type annotations, so the docs exporter can generate reference documentation for them alongside your game-specific bindings.
The coroutine library deserves special mention. Coroutines in WarScript use bytecode-level suspend and resume. A coroutine can yield to pause until the next tick, yield wait 2.0 to pause for a duration, or run as a looping coroutine that restarts after each completion. The host calls TickCoroutines(dt) every frame to advance them.
Adding your own library follows the same pattern: create a static class with a Register(script, scope) method and add it to the registry. Or use the [WsModule] source generator and it is handled for you.
Integration
How Your Game Uses WarScript
Create a WarScriptLanguage instance with your source code, a file resolver for imports, and an optional logger. Call Run() to parse, compile, and execute the top-level code. Then use GetFunction() and Call() to invoke script functions from your game loop.
The ScriptRunner base class handles the common setup: registering standard libraries, running the script, and providing a CallDynamic() method for calling functions by name. Override it to add your game-specific native bindings and import logic.
For production, compile at build time with SaveBytecode() and load at runtime with LoadBytecode(). This skips parsing and compilation entirely. For development, use Reload() to swap source code while keeping all global state alive.
// Create and run a script
var script = new WarScriptLanguage(
"patrol", source, ImportFile, Log);
script.InstructionBudget = 1_000_000;
script.Run();
// Get a function handle (cached)
var tick = script.GetFunction("tick", 1);
// Game loop — call every frame
script.Call(tick, WarValue.FromNumeric(dt));
script.TickCoroutines(dt);
// Hot reload during development
script.Reload(newSource);
tick = script.GetFunction("tick", 1); Installation
Add WarScript to Your Project
For Unity projects, open the Package Manager and add WarScript from a git URL. The package targets Unity 6000.3+ and has no external dependencies.
https://github.com/nine9123/WarScript.git#upm For standalone C# projects, clone the repository or add it as a submodule. The runtime has no Unity dependencies and can be used in any .NET application.
Support the Project
Help WarScript Grow
WarScript is free and open-source. If you find it useful and want to support development, consider becoming a patron. But most importantly — just use it. Try WarScript in your game, report issues, suggest features, and help build the community.