WarScript is not Warman's code. It is an external language runtime assembled as a separate C# project. Warman integrates it by declaring a boundary: a set of C# methods that scripts are allowed to call, nothing more. The interesting question is not what WarScript is, but how that boundary is defined and enforced.
Attributes instead of manual bindings
Exposing a C# method to WarScript requires two annotations. [WsModule] marks a class as a module with a namespace. [WsFunction] marks an individual method as callable, with a documentation string and a declared return type.
[WsModule("companion", Description = "AI companion control.")]
public partial class AICompanionModule
{
[WsFunction("companion_move_to",
Doc = "Move toward a world position. The companion pathfinds automatically.",
Returns = "LogicalValue")]
public bool MoveTo(double x, double z)
{
return _brain.CommandMoveTo(new Vector3((float)x, 0, (float)z));
}
[WsFunction("companion_attack_nearest",
Doc = "Face and attack the nearest enemy in the room.",
Returns = "LogicalValue")]
public bool AttackNearest()
{
return _brain.CommandAttackNearest();
}
}
A source generator, WsBindingGenerator, reads these attributes at build time and emits a companion .g.cs file. The file contains a Register method that wires every annotated function into WarScript's DefinitionScope. No binding code is written by hand. Adding a new scriptable function means adding one annotation; the generator handles the rest.
What the generated code does
The .g.cs file implements the Register method on the same partial class. For each annotated function, it creates a NativeFunctionDefinition that extracts typed arguments from the WarScript value list, calls the C# method, and wraps the return value back into a WarValue:
__scope.AddFunction(new NativeFunctionDefinition(
new FunctionDetails("companion_move_to", new List<string> { "x", "z" }),
(__args) =>
{
double @x = NativeHelper.NumericArg(__args, 0);
double @z = NativeHelper.NumericArg(__args, 1);
var __result = this.MoveTo(@x, @z);
return WarValue.FromLogical(__result);
},
"Move toward a world position. The companion pathfinds automatically.",
"LogicalValue"));
The doc string from the annotation is passed directly into the NativeFunctionDefinition. The WarScript API documentation is generated from these strings, so the annotations are the single source of truth for both the binding and the docs.
Scripts set intent, not state
Companion scripts run inside ForwardSimulation. That makes them subject to the same rule as all simulation code: they must be deterministic and must not use Unity APIs that are non-deterministic or frame-dependent. The deeper constraint is that scripts cannot directly modify the simulation. An on_tick function that called unit.TeleportTo() directly would bypass the input pipeline that keeps all clients in sync.
The module design enforces this. If module functions called simulation methods like unit.TeleportTo() directly, they would bypass the input pipeline that keeps all clients in sync. Instead, module functions set desired state on AICompanionBrain: private fields like _desiredMoveTarget, _wantsAttackNearest, _wantsSkillSlot. After the script returns, BuildInput() reads those fields and constructs a PlayerInputCommand, the same structure a human player produces from their keyboard and mouse. The simulation processes the command.
This design means a buggy companion script, even one with an infinite loop or corrupted state, cannot corrupt the simulation. The worst it can do is produce a nonsensical PlayerInputCommand, which the simulation handles the same way it handles any input.
Per-companion isolation
Each companion gets its own AICompanionScriptRunner instance. The runner creates a fresh DefinitionScope, calls SetupGlobalScope to register modules, and compiles the script. Script global variables are scoped to that runner, so two companions running the same script file do not share state.
protected override void SetupGlobalScope(DefinitionScope scope)
{
base.SetupGlobalScope(scope); // base Warman modules
var companionModule = new AICompanionModule(_brain);
companionModule.Register(Script, scope);
MasterRandom.Register(Script, scope);
}
MasterRandom is registered as a module too, using the same attribute pattern. Scripts that need random numbers call through it, and those calls use the simulation's seeded random number generator. Script randomness is deterministic across all clients.
The four events a companion script can implement are declared as constants in the runner:
public const string EventOnReady = "on_ready";
public const string EventOnTick = "on_tick";
public const string EventOnEnterRoom = "on_enter_room";
public const string EventOnOwnerEnterRoom = "on_owner_enter_room";
The runner checks whether each event function exists in the script at initialization time and caches the result. If on_enter_room is not defined, it does not call into the script when the companion enters a room. Events that are not implemented cost nothing.
A companion script in practice
The module API produces script code that reads like intent rather than implementation detail. A basic companion that fights nearby enemies and follows its owner otherwise looks like this:
function on_tick(dt)
if companion_is_in_combat()
companion_attack_nearest()
else
if companion_same_room_as_owner()
companion_follow_owner()
else
companion_use_new_portal()
end
end
end
Each function call in the script maps to a [WsFunction]-annotated method on AICompanionModule. The module translates it into brain state. The brain produces movement velocity, cursor position, and button flags. The simulation advances. The companion appears to fight, chase portals, and follow its owner without a single line of C# written for this specific behavior.
Bootstrap: scripts that configure the game
Companion AI is one use of WarScript. A second is bootstrapping. A bootstrap.ws file runs once when a session starts. It uses a separate module, BootstrapCompanionModule, to configure which companion scripts the game uses:
fun bootstrap[client]
companion_slot_clear[]
companion_slot_add["ai/companion_melee.ws", 0, ""]
companion_slot_add["ai/companion_ranged.ws", 0, ""]
companion_slot_add["ai/companion_solo.ws", 2, "ai/companion.ws"]
end
The slot configuration is written into a static list on BootstrapCompanionModule. When WarmanServer spawns companions, it checks this list and uses it instead of the inspector configuration. A mod author can change which AI companions appear, how many sub-companions each has, and which script each runs, without touching any C# code.
Live editing without restarting
ScriptBridge handles the development loop for mod authors working on scripts. When the editor is active, it extracts .ws files from the mod workspace to a temporary folder on disk, then starts a FileSystemWatcher on that folder. Any save from an external editor (VS Code, Notepad, or anything else) triggers a sync back into the workspace. The updated script takes effect on the next map load without restarting the game.
This works because scripts are compiled and registered at load time, not at game start. Reloading a map re-runs SetupGlobalScope, re-registers modules, and recompiles the script. The turnaround from save to testable behavior is a map reload.
The boundary is explicit
The module system makes the integration boundary visible and auditable. Every C# method a script can call has a [WsFunction] attribute. Every module a script can access is explicitly registered in the runner's SetupGlobalScope. If a method is not annotated, it is not reachable. If a module is not registered, its namespace does not exist.
This makes the modding surface self-documenting. The doc strings on [WsFunction] annotations populate the WarScript API reference. A mod author reads the reference and knows exactly what is available. There is no reflection, no dynamic method lookup, and no way for a script to reach C# code that was not deliberately exposed.