Starship Horizons
Log In
☰
Knowledge Base›Mission Scripting

Mission Scripting

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

  1. The mission is launched and its ships are placed.
  2. 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.
  3. Initialize(Vessel vessel) is called once, before the mission's events are armed. vessel is the session's player vessel.
  4. The mission's OnStart is raised at the end of the start, after events and objectives are set up.
  5. While the mission runs, OnUpdate and the other hooks fire, and events can call the script's functions.
  6. 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

HookHandlerWhen
Initializepublic void Initialize(Vessel vessel)Once, as the mission starts. Subscribe to the rest here.
GameSession.CurrentMission.OnStartvoid (Mission mission)At the end of the mission's start.
GameSession.CurrentMission.OnUpdatevoid (Mission mission)Every pass of the server's update while the mission runs: many times a second.
GameSession.CurrentMission.OnEndvoid (Mission mission)When the mission ends.
vessel.OnAfterUpdatevoid (Vessel vessel)After each update of that vessel.
GameSession.OnKillvoid (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.OnUpdatevoid ()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:

MemberWhat it does
GameSession.CurrentMissionThe running mission: its Name, State, the hooks above, and End(reason) to end it.
GameSession.PlayerThe 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.RandomizerThe 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 methodsPush a packet or a message to consoles. See Packets And Topics. The rest of Comm is closed to scripts.
ModuleFiles.Exists, ReadAllText, ReadAllBytesRead 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.

Last updated 19 October 2026