A mission can carry a C# script. The server compiles it when the mission starts and runs
it inside the game, where it can see the mission, the ships and the session, react to what
happens, and feed values back to the mission's events.
Reach for it only when events cannot express something. Events are easier for the next
person to read, and they keep working when the engine changes underneath them; a script is
code written against the engine, and it has to be kept up with it. See Mission Editor
for events.
Where the script lives
In the mission editor, under Advanced, in the Scripting
section. The editor keeps the script inside the mission file, in its
<script> element.
Beside the mission file, when the mission file has no script of its
own. A mission saved as Missions/Salvage Run.xml reads
Missions/Salvage Run.cs if its <script> element is empty or
missing.
The copy inside the mission file wins. Once the editor has saved a mission, its script is
in the mission file, and a .cs beside it is no longer read. Edit the script in
the editor from then on, or empty the <script> element by hand if you
would rather work in the .cs file.
Shared code. Every .cs file under the module's
Scripts\Shared folder, subfolders included, is compiled together with each of
that module's mission scripts. Put helpers several missions use there, in classes of their
own. A shared file must not declare a class named Scripting: that name belongs
to the mission's script.
Nothing else is loaded. In particular, the base game module's
Scripts\global.cs is a leftover from an older design and does not run.
The shape of a script
A script is one public class Scripting, outside any namespace, with a
using Horizons; at the top. The server calls its Initialize method
when the mission starts. Everything else hangs off that.
using System;
using System.Linq;
using Horizons;
public class Scripting {
// The server makes a new Scripting object for every call it makes,
// so anything a Script: Function call needs is kept static.
static int kills = 0;
static DateTime lastCount = DateTime.MinValue;
public void Initialize(Vessel vessel) {
kills = 0;
Util.LogMessage("Mission script started for " + vessel.Name);
GameSession.CurrentMission.OnStart += MissionStart;
GameSession.CurrentMission.OnUpdate += MissionUpdate;
GameSession.CurrentMission.OnEnd += MissionEnd;
GameSession.OnKill += ObjectKilled;
}
public void MissionStart(Mission mission) {
GameSession.SetVariable("Kills", 0);
}
public void MissionUpdate(Mission mission) {
// Runs many times a second: do the real work on a clock.
if ((DateTime.Now - lastCount).TotalSeconds < 10)
return;
lastCount = DateTime.Now;
try {
int raiders = GameSession.FindObjects("Raiders").Count(o => o.Alive);
GameSession.SetVariable("Raiders Left", raiders);
} catch (Exception ex) {
Util.LogMessage("MissionUpdate: " + ex.Message);
}
}
public void ObjectKilled(GameObject victim) {
try {
if (victim is Vessel && victim.Tags.Contains("Raiders")) {
kills++;
GameSession.SetVariable("Kills", kills);
}
} catch (Exception ex) {
Util.LogMessage("ObjectKilled: " + ex.Message);
}
}
public void MissionEnd(Mission mission) {
Util.LogMessage(mission.Name + " ended with " + kills + " raiders destroyed");
}
// Called by an event's Script: Function action, with the player vessel.
public void CallForHelp(Vessel vessel) {
GameSession.AddSystemLog(vessel.Name + " is calling for help");
GameSession.TriggerEventActions("Reinforcements");
}
}
The pattern to notice is the one in the middle: the script counts, and it hands the count
to the mission as a variable. Events with a Variable condition on Kills or
Raiders Left then do the visible work — the message, the objective, the next
wave — where the next author can see it.
When it runs
- The mission is launched and its ships are placed.
- As the mission starts, the server compiles its script together with the module's shared
scripts. If that fails, the mission runs without a script.
Initialize(Vessel vessel) is called once, before the mission's events are
armed. vessel is the session's player vessel.
- The mission's
OnStart is raised at the end of the start, after events and
objectives are set up.
- While the mission runs,
OnUpdate and the other hooks fire, and events can
call the script's functions.
- When the mission ends, however it ends,
OnEnd is raised and the script is
unloaded. Handlers hooked onto the session are cleared when the mission is cleared away, so
nothing carries into the next mission.
Because the script is compiled at every start, an edit takes effect the next time the
mission is launched. Static fields start from scratch each time, too.
The hooks
| Hook | Handler | When |
Initialize | public void Initialize(Vessel vessel) | Once, as the mission starts. Subscribe to the rest here. |
GameSession.CurrentMission.OnStart | void (Mission mission) | At the end of the mission's start. |
GameSession.CurrentMission.OnUpdate | void (Mission mission) | Every pass of the server's update while the mission runs: many times a second. |
GameSession.CurrentMission.OnEnd | void (Mission mission) | When the mission ends. |
vessel.OnAfterUpdate | void (Vessel vessel) | After each update of that vessel. |
GameSession.OnKill | void (GameObject victim) | When an object is destroyed. Not raised for a player ship, or a seated bot's ship, that is going to respawn. |
GameSession.OnUpdate | void () | Every pass of the server's update. |
The mission also declares an OnTimeWarning event, but nothing raises it at
present; use a Duration or variable condition in an event instead.
Keep the frequent hooks cheap. OnUpdate and
OnAfterUpdate run inside the server's update, so whatever they cost, every
frame of the game pays. Do the real work on a clock, as the example does.
Catch your own exceptions. An exception that escapes a handler is not
caught for you: it cuts short the server update it was raised in. Wrap the body of each
handler in try and log what goes wrong.
Calling a script from an event
In an event's actions, add Script: Function and type the method's name in
the Function field. In the mission file that is:
<action type="Function" name="Call For Help" value="CallForHelp" />
The method has to look like this:
public void CallForHelp(Vessel vessel) { ... }
- It is called with exactly one argument, the session's player vessel. That is not
necessarily the ship that set off the event.
- Declare that one parameter as
Vessel (or GameObject). A method
with no parameters, or with a string, is not called.
- Give it a name no other method in the class has. Overloads are not told apart.
- Its return value is ignored.
- Each call gets a new
Scripting object, so instance fields set in
Initialize are not there. Keep shared state in static fields.
To go the other way, from the script to the events, set a variable with
GameSession.SetVariable and let an event wait on it, or fire an event by name
with GameSession.TriggerEventActions.
What a script works with
A script sees the engine's Horizons namespace, less the parts listed under
the next heading. These are the members most scripts need:
| Member | What it does |
GameSession.CurrentMission | The running mission: its Name, State, the hooks above, and End(reason) to end it. |
GameSession.Player | The session's player vessel. With several player ships it is one of them. |
GameSession.FindObject(name), FindObject(id) | One object, by name or by id. |
GameSession.FindObjects(tag), FindObjectByTag(tag) | Every object with a tag, or the first. Tags are what you set on mission objects in the editor. |
GameSession.GetVariable(name), SetVariable(name, value) | The mission's variables. Setting one re-checks every event with a Variable condition on it. A value of "++" or "--" counts an existing variable up or down. |
GameSession.TriggerEventActions(name) | Fires the named event now, whether or not its conditions hold, as the Game Master's button does. |
GameSession.AddSystemLog(text) | Adds a line to the session's system log, which the Game Master sees. |
Globals.Randomizer | The shared random number source: Next(min, max) (max not included), NextFloat(min, max). |
Util.LogMessage(text) | Writes a line to the server's debug.log. |
Util.Distance(a, b) | The distance between two positions. |
Comm.SendCommand, BroadcastCommand, SendConsoleMessage and the other send methods | Push a packet or a message to consoles. See Packets And Topics. The rest of Comm is closed to scripts. |
ModuleFiles.Exists, ReadAllText, ReadAllBytes | Read a file inside a module, by module id and a path within it, whether the module is a folder or a package. A script's only way to a file. A missing file, or a path that tries to leave the module, gives null (and Exists answers false). |
From a vessel or other object you have the properties the consoles show: Name,
Faction, Tags, Position, Alive,
IsPlayer, a vessel's Integrity, Alert,
Shields and so on.
A trap with GameSession.Vessels. The session's
Vessels and PlayerVessels are concurrent dictionaries, a type from
a namespace scripts may not use. .Values, .Count, a
foreach over them and lookups by key are all refused. LINQ is allowed, so go
through it, or find objects by tag:
// Refused: foreach (Vessel v in GameSession.Vessels.Values) { ... }
var hostiles = GameSession.Vessels
.Select(kv => kv.Value)
.Where(v => v.Alive && v.Faction == "Daichi")
.ToList();
What a script may not use
A script is C# compiled into the server and run inside it, so the server holds it to a
rule about what it may name. A script cannot touch the file system, use reflection, start
processes, open network connections, start threads, or change the server's own files and
settings.
.NET. A script may use types from these namespaces, and only these:
System, System.Collections,
System.Collections.Generic, System.Collections.ObjectModel,
System.Globalization, System.Linq, System.Numerics,
System.Text and System.Text.RegularExpressions, plus the engine's
Horizons and Hydra.Space.Tasks. The match is exact: allowing
System does not allow System.IO, System.Threading.Tasks
or System.Diagnostics. Within System, the types that reach outside
the process or back into reflection are refused too — Environment (bar
NewLine and TickCount), Console,
AppDomain, GC, Activator — and Type
offers only its name and comparisons. dynamic, unsafe and
extern are refused wherever they appear.
The engine. Its file, package and network machinery is closed:
FileOps, Assets, API, SaveFiles,
UserRegistry, the Steam and package classes, and Comm apart from
its send methods. So is every member that writes content to disk (the Save and
DeleteFile of missions, maps, vessel classes, components, factions, dialogs,
campaigns and modules), every Filename and Directory the engine
carries, starting and stopping the server, and loading saved games.
What you see when a script breaks the rule. In the mission editor, the
Scripting section's Test button compiles the script under the same rule
and lists each refusal as a compile error:
'System.IO.File' is not available to a script
A using line on its own is not refused; what counts is what the script
actually uses. A few things fail earlier, as a type that "could not be found", because the
server does not offer their libraries to a script at all — Process, sockets
and the like.
At mission start, a refused script is not loaded, the mission runs without it, and
debug.log says why:
Globals.LoadScripting: ...\Missions\Salvage Run.xml has a script that was NOT loaded.
'System.IO.File' is not available to a script (Salvage Run.cs line 14). See ScriptRules
for what a script may use.
The rule applies to every module's scripts, whether the module is a folder or a
package.
This is a check made when the script is compiled. It is not a wall around the script
while it runs: a script runs in the server's own update, so a slow one slows the game and
an endless loop stops it. Run scripts from authors you trust.
Scripts from the Steam Workshop
A module installed from the Steam Workshop runs its missions without their scripts unless
its package is signed by a key you have chosen to trust. To trust one, add its fingerprint to
config.xml:
<trusted-signers>
<signer fingerprint="..." />
</trusted-signers>
The fingerprint is the module's Signer in /api/modules (see
Web API Content), where ScriptsBlocked also shows which modules are
running without their scripts. A package whose contents do not match its signature is not
trusted, whoever signed it. The missions themselves load and play either way.
Testing and troubleshooting
- Press Test in the Scripting section after every change. It compiles
the script the way mission start will, shared scripts included, and an ordinary compile
error at mission start shows in
debug.log only as
Script Compiled: False.
- Log freely with
Util.LogMessage and read debug.log in the
server's folder.
- If nothing happens at all, check that the class is
public, is named
Scripting and is not inside a namespace, and that Initialize
takes one Vessel.
- If an event's function does nothing, check the name in the action against the method,
and that the method takes one
Vessel.
Older scripts
Not every script that ships with the game still compiles. Some call members that no
longer exist, such as Globals.CurrentGalaxy, Globals.CurrentMap,
Globals.AddKeyword and Comm.speechMgr, and some declare event
functions with no parameter. Use them for the shape of a script, not as a list of what to
call; the example on this page compiles under the current rule.