Skip to content

Latest commit

 

History

History
2725 lines (2305 loc) · 137 KB

scripting.md

File metadata and controls

2725 lines (2305 loc) · 137 KB

Scripting

Mods can include scripts, written in Luau, a version of Lua 5.1:

  • Load scripts change the game's records, such as a gun's damage, as OpenReliant starts.
  • Global and mission scripts run as the game plays. They react to what happens and change it, through hooks on the game's functions and events.
  • Object scripts run on the ships and other objects of a mission, each on its own object; missile scripts on each missile in flight; and turret scripts on each turret.
  • Player and menu scripts decide what the player sees, hears and does. They draw over the flight display and the menus, react to the keys, and add camera views, game modes and shader effects.

This page explains how to write them. The scripting reference lists everything they can use, What mods can do shows each feature with an example, and examples/mods holds example mods for mod makers, which show how the scripting works and aren't supported mods.

New to scripting? Start with a first script, then read Kinds of scripts and the example closest to what you want to do. Luau's website explains the language itself. The sections after that can be read in any order, as you need them.

Improvement: the original has no scripting apart from its mission scripts.

A first script

Scripts live in a mod (Modding); a folder mod is the easiest to work on. This one makes the player's ship tougher. Make the folder mods/tough in the game folder, with this manifest, mod.ini:

[Mod]
Name=Tough
OpenReliant=0.7

[Scripts]
Global=tough.luau

and this script, tough.luau:

local hooks = require("openreliant.hooks")

-- Every hit on the player's ship lands at half strength.
hooks.after("damage_by_difficulty", function(e)
    if e.object.is_player then
        e.result *= 0.5
    end
end)

Start OpenReliant from a terminal and fly a mission (--mission 0 flies the sandbox at once). The log in the terminal shows info(scripts): tough: started tough.luau, and the player's ship takes half the damage.

print output and script errors go to the log, after the mod's name. In the developer mode, they go to the console too, and a folder mod's scripts reload as soon as one is saved, carrying on from where they were (The console). Otherwise, go back to the main menu and start a game again. After changing mod.ini, adding a file or changing a load script, restart OpenReliant.

The example mods

Each example mod shows one part of the scripting, with comments in its files:

Example What it shows
arena A game mode with its own rules, a HUD display fed from storage, and the radio_say hook
balance The records, changed from a load script
bananas A mod's own gun, missile, pilot and ship type, tuned in the records, and a pilot set on enemy ships
campaign A campaign with a briefing screen, a movie and an ending screen
cel-shading Surface and lighting functions
crt A post effect
custom-order A custom AI order started by a mod action
drawing-assets A mod's pictures, the game's shapes and fonts
hud-layout A HUD display that stands in for the game's radar, moves and scales other instruments, and reads what they show
dvd A menu script that draws over the menus
interceptor A mod's ship type, flown in a game mode
main-menu Replacing a screen of the front end
rules Hooks on the game's functions
strafe-run A custom order, a HUD display, a camera view, a screen and actions, through the built-in interfaces
tally Saved games, storage and timers
teapot A mod's ship type with its own model, and the radio_say hook
trent The game's text in the records, changed from a load script, beside face films that replace the game's
wingmen Object scripts, events, interfaces, nearby and an options page

Kinds of scripts

Kind In mod.ini When it runs What it's for
Load Load= under [Scripts] Once, when OpenReliant starts, before the main menu Changing the records, declaring options and game modes
Global Global= under [Scripts] For the whole game Hooks, orders and the mission's objects
Mission The mission's file name under [Missions] While that mission runs The same as global scripts, for one mission
Object A class, such as Fighter=, or a type, such as Type.predator=, under [Scripts] On each object of that class or type, while it's in the mission Object scripts
Missile Missile= under [Scripts] On each missile, from its launch to the end of its flight Missile scripts
Turret Turret= under [Scripts] On each turret of each object, while the object is in the mission Turret scripts
Player Player= under [Scripts] For the whole game, even while it's paused What the player sees and does in flight
Menu Menu= under [Scripts] From OpenReliant's start until it quits: in the menus, the rooms, the movies and the loading screens, and over the missions Drawing over the menus, replacing screens, actions, options and game modes
[Scripts]
Load=balance.luau
Global=rules.luau, wingmen.luau
Fighter=wingman.luau
Type.reliant=reliant.luau

[Missions]
mission2.dte=escort.luau
  • A key can list several scripts, separated by commas. They run in that order, after the scripts of the mods before (Load order).
  • A game starts when you start a campaign, load a saved game, or fly a mission on its own (INSTANT ACTION, the simulator, a game mode or --mission), and ends at the main menu. Global scripts start again with each game. For a loaded game, they start from the state they saved with it (Saved games).
  • [Missions] matches the mission's file name in any case: mission2.dte is mission 2, and mission251.dte the second part of mission 25. A mission script starts as its mission begins, before its ships appear, and stops as it ends. Each attempt starts it again. Mission scripts are global scripts in everything else.
  • The classes are Fighter, Capital, Support, Torpedo, Mine, Planet, Debris and Other. A type is Type. and its name (ShipType) or its number, such as Type.12. A ship type a mod adds is named by its qualified name, Type.teapot:teapot, or in the mod that adds it by its own name, Type.teapot. Its number changes with the mods that are on, so don't name it by number.
  • Each script has its own global variables, and the scripts on each object have their own.

Some functions only work in some kinds of script:

Function Scripts
core.register_game_mode, options.register_page Load and menu, as OpenReliant starts
input.register_action, ui.replace_screen, ui.go_to and the other front end functions Menu
ui.register_screen Player and menu
camera.register_view, hud.register_display, postprocessing, shaders, debug Player
orders.register, object:add_script, object:remove_script Global and mission
Changing records Load

Packages lists which scripts can use each package.

In a network game

Network games come after 1.0 (#55). The scripts are already split the way they will run in them, so a mod written now works there too if it keeps to these rules:

  • Global, mission, object, missile and turret scripts run where the game is hosted, and in a network game only there.
  • Load scripts run on every machine, and must give the same records on each one with the same mods. So they mustn't depend on the time, on storage, or on anything else that differs between machines. Their math.random has a fixed seed, so it gives the same numbers everywhere. A load script that changes the records by one of its mod's options gives different records where the options differ, and a network game will need them to match.
  • Player and menu scripts run on each player's machine. They read the game, and change it only by sending events to the global scripts (core.send_global_event), whose data is copied as it would be sent to another machine. They can't change objects or records. Menu scripts drive the menus of the machine they run on.
  • Interfaces and timers stay within one side: the game's (load, global, mission, object, missile and turret scripts) or the presentation's (player and menu scripts).
  • Storage is each machine's own, so keep game rules out of it. So are a mod's options: the global scripts read the host's.

Packages

require("name") runs another script of the same mod once and returns what it returns. The name is the file name, with or without .luau, in any case. require("openreliant.<name>") gives one of OpenReliant's packages, such as openreliant.hooks or openreliant.world. Packages lists them, with what each holds and the kinds of script that can use it. require("openreliant.core").version is OpenReliant's version, such as "0.7.0", for a mod that needs to know which features it has.

Values

  • Numbers use the game's units (developer documentation). Positions and velocities are Luau vectors, and a velocity is the distance moved in a simulation step, of which there are 25 a second.
  • Values OpenReliant has names for are strings, such as "fighter" or "laser_cannon". One without a name is a number (Names of values).
  • math.random gives the same numbers on every computer for the same game: it is seeded again as each mission starts, from the game's random numbers. math.randomseed and os aren't there.

Qualified names

What a mod registers or adds gets a qualified name: the mod's folder name, or its archive's name without .hog, a colon, and the name the mod gave it. A gun banana_gun in the mod folder bananas, or in bananas.hog, is bananas:banana_gun, and the camera view chase of the mod strafe-run is strafe-run:chase. A mod's names stay the same whether it's packed or not.

  • Two mods can use the same name for their own things; the qualified names keep them apart.
  • The functions that register something return its qualified name.
  • The functions that take something registered by its name, such as camera.set_view, hud.set_display_enabled or orders.info, take the qualified name, and within the mod also the name without the prefix. The game's own names come first, so a mod's view called cockpit is named strafe-run:cockpit even within the mod.
  • The records, and values such as an object's type, take what a mod adds by its qualified name.
  • Keep names, not numbers. The numbers OpenReliant gives what mods add change with the mods that are on.

Engine handlers

A script returns a table, whose engine_handlers holds the functions OpenReliant calls, such as on_update each frame, or on_mission_start. Each one is optional:

return {
    engine_handlers = {
        on_mission_start = function(mission)
            print("mission " .. mission.number .. " starts")
        end,
        on_update = function(seconds) end,
    },
}
Handler Scripts When
on_init(data) Global, object, player, menu The script starts
on_save(), on_load(saved) Global, player The game is saved, or loaded (Saved games)
on_records_loaded() Load Every mod's load scripts have run
on_update(seconds) Global, object Each frame in which game time passes
on_step() Global, object Each simulation step, 25 a second
on_frame(seconds) Player, menu Each frame drawn, even while paused
on_mission_start(mission), on_mission_end(outcome) Global, player, menu A mission starts or ends
on_object_added(object), on_object_removed(object) Global An object joins or leaves the mission
on_added(), on_removed() Object The script's object joins or leaves the mission
on_key_press(key), on_key_release(key), on_action(action) Player, menu Keys and actions
on_console_command(text) Player, menu A line typed in the console that isn't one of its commands
on_window_resized(width, height) Player, menu The window changes size
on_interface_override(base) Global, object, player, menu Interfaces
on_option_changed(key, value) Menu The player sets one of the mod's options

Engine handlers says what each one gets. They're called in the order the scripts started: the global and mission scripts' first, in load order, then each object's. A handler that raises an error is logged and isn't called again, and so is one that runs out of time three times in a row (Limits).

on_mission_end gets the mission's outcome: its ending for the player, such as "destroyed" or "left" (Ending), and the rating the mission's script gave it, such as "success" (Rating).

Hooks

Global, mission and object scripts add handlers to hooks with require("openreliant.hooks"). A hook is one of:

  • a function of the original game, by its name, such as object_damage or bullet_fire. The developer documentation describes them.
  • a function of OpenReliant's own: a step that the original takes inside a larger function, such as mission_lost, which OpenReliant makes a function of so that scripts can hook it.
  • an order routine: what a ship does each frame for an order, such as order_fight.
  • a mission event, which a mission's triggers can wait for, such as destroyed or launched.
  • an engine event, such as mission_started or object_added.

The scripting reference, or openreliant hooks in a terminal, lists them with the fields each handler sees.

Adding a handler

local hooks = require("openreliant.hooks")

-- Each enemy the player destroys makes the player's shots hit 10% harder.
local bonus = 1
hooks.add("destroyed", function(e)
    local by_player = e.attacker and e.attacker.is_player
    if e.component == nil and by_player and e.object.side == "hostile" then
        bonus += 0.1
    end
end)
hooks.add("object_damage", function(e)
    if e.kind == "bullet" and e.attacker.is_player then
        e.value *= bonus
    end
end)

A handler gets one value, e, with the hook's fields. e can only be used while the handler runs. examples/mods/rules has more.

Changing a function

For a function, e holds its arguments, and changing a field changes what it does. A handler that returns false stops the call: the function doesn't run, and neither do the handlers after it.

-- Every hit does twice the damage, but collisions don't damage the player's ship at all.
hooks.add("object_damage", function(e)
    if e.object.is_player and e.kind == "collision" then
        return false
    end
    e.value *= 2
end)

hooks.after adds a handler that runs after the function. For a function with a result, it sees the result in e.result and can change it. A handler added with hooks.add that stops the call can set e.result too, which is then the function's result.

-- Hits on the player's ship land softer.
hooks.after("damage_by_difficulty", function(e)
    if e.object.is_player then
        e.result *= 0.75
    end
end)

e:original() runs the rest of the call at once (the handlers after this one, then the function, with the values in e) and returns the function's result, so a handler can do something both before and after it. The function runs only once: where a handler after this one calls e:original() too, the function runs there, and the earlier call returns its result. If a handler after this one stops the call before the function runs, e:original() returns nil.

The game's functions that have hooks:

Hook What it is Filter tests
object_damage Damage to an object's shield, and what gets through to its armour object
object_armor_damage Damage to an object's armour, once its shield is down object
component_damage Damage to a component, such as a capital ship's turret object
damage_by_difficulty How hard damage lands at the game's difficulty; its result is the damage object
object_destroyed An object's pilot ejects, or it explodes object
bullet_fire A ship fires a shot owner
missile_launch A ship launches a missile at a target launcher
missile_launch_turret A missile turret launches a Screamer at a target launcher
order_push An object is given an order, aimed at a target; its result says whether it took object
order_pop An object ends the order it runs; its result says whether it had one object
object_orders An object runs its order, as it does each frame object
order_retaliate A fighter turns on whoever last hit it object
fight_choose_maneuver A ship under the Fight order chooses its next combat maneuver; its result is the maneuver object
maneuver_run A ship under the Fight order flies its combat maneuver for a frame object
radio_say The radio says a line Only a function filter
vm_command The mission's script runs one of its commands; its result is what the command gives Only a function filter
restart_screen The restart screen after a mission is lost or left; its result is the player's choice Only a function filter

OpenReliant's functions that have hooks choose the movies that play as a mission ends. Each takes the movie the game would play as e.movie, which a handler can change to the name of another Bink file, the game's or a mod's, or set to nil for none. They run once the mission is over, so only the handlers of global scripts see them, as restart_screen's do:

Hook What it is
mission_lost A mission is lost or left, before the restart screen: the campaign's funeral, capture or execution, and nothing for a game mode's mission
career_over The pilot's career in the campaign ends, before the main menu
medal_ceremony A mission of the campaign awards a medal, and its ceremony plays
local hooks = require("openreliant.hooks")

-- A funeral of the mod's own when the player's ship is destroyed.
hooks.add("mission_lost", function(e)
    if e.ending == "destroyed" then
        e.movie = "my_funeral.bik"
    end
end)

-- No restart screen: a lost mission is flown again from its launch at once.
hooks.add("restart_screen", function(e)
    e.result = "replay_from_launch"
    return false
end)

Targets

An order's or a missile's target is a table (Target): object and component for a ship, whole where component is nil, or the index of one of the mission's flight_groups or squads, and every field nil for nothing. The table can't be changed in place; a handler sets the field to a new one.

local hooks = require("openreliant.hooks")

-- Missiles fired at the player's ship are aimed at nothing instead.
hooks.add("missile_launch", function(e)
    if e.target.object and e.target.object.is_player then
        e.target = {}
    end
end)

-- No ship is told to run away.
hooks.add("order_push", function(e)
    if e.order == "run_away" then
        return false
    end
end)

order_push's result is "taken", "refused" or "conflict" (OrderPushed). A handler that stops it leaves "refused", and the object's orders stay as they were.

Changing what the radio says

radio_say runs as the radio says a line: from a mission's script, the simulator, the game's chatter or a mod's script (The radio). e.speech is the file of the line, such as ms_hudtr_001.ut, and e.film the film of the speaker's face. e.mode says when it's said: "now", "queued" after the lines before it, or "if_idle", only if the radio has nothing else to say.

  • Changing speech or film says another line, or shows another face.
  • Returning false drops the line. Whatever waits for it, such as a mission script that waits for each line to end, carries on at once.
local core = require("openreliant.core")
local hooks = require("openreliant.hooks")

-- The simulator's talk about the display (ms_hudtr_001.ut and the lines after it) is skipped in
-- this mod's game mode.
hooks.add("radio_say", function(e)
    if core.game_mode == "teapot:arena" and e.speech:find("ms_hudtr", 1, true) then
        return false
    end
end)

examples/mods/arena and examples/mods/teapot do this.

The mission script's commands

vm_command runs as a mission's script runs one of its commands. e.command names it, such as "set_invulnerability" or "play_music" (MissionCommand), and e.arguments holds its arguments, the first first, with 0 past the ones it takes.

  • Returning false stops the command, and the script goes on after it.
  • The arguments are the script's own values. A ship or a text is the offset of its record in the mission's file, so most handlers only look at e.command, or change a number such as a time. The list can't be changed in place; a handler sets e.arguments to a new one.
  • e.result is what the command gives: "run_on", "wait", which ends the script's run until it runs next, or a number, the command's value (MissionCommandResult).
  • A handler that stops a command and sets e.result = "hold" holds it: the script's thread waits, and runs the command again, on the same arguments and through the handlers, the next time it runs. A mission's own waiting commands, such as WaitForSpeech, wait this way.
local hooks = require("openreliant.hooks")

-- The missions' scripts can't make ships invulnerable.
hooks.add("vm_command", function(e)
    if e.command == "set_invulnerability" then
        return false
    end
end)

Order routines

Each order a ship follows runs routines of the original game: an init as the order starts, an update each frame, and for a few an exit as it ends. Each routine is a hook, named after the order: order_fight_init, order_fight and so on (The order routines). Their handlers see the ship as e.object, and e.object.order is the order.

-- Hostile fighters never run away: their Run Away routine does nothing.
hooks.add("order_run_away", function(e)
    return false
end, { side = "hostile", class = "fighter" })
  • Returning false from an update skips the routine for that frame. The ship keeps the order.
  • Launching, landing, docking, jumps, gates and explosions are all orders, so their routines' hooks change them, such as order_jump_out_init or order_explode. order_push can refuse the order before it starts.
  • Where OpenReliant doesn't run a routine yet, its handlers still run.
  • These hooks change the game's orders. To add an order of your own, see Custom AI orders.

Mission and engine events

An event's fields can only be read, and a handler returning false only stops the handlers after it. Events have no hooks.after.

  • Mission events (The mission's events), such as destroyed, launched and docked, come for the ships the mission's file lists, whether or not a trigger waits for them. The exceptions are proximity_close and proximity_general, which tell of a ship close to another: the game looks for them once a second, and only while one of the ship's triggers waits for them.
  • Engine events (The engine's events) are mission_started, mission_ended, object_added, object_removed, order_started, order_ended and trigger_fired, the last for each of the mission's triggers that fires.

Filters

A third argument limits a handler to the objects it's for. The engine checks it without calling the handler, so the game doesn't slow down for the rest:

-- The Reliant and the Yamato take half the damage.
hooks.add("object_damage", function(e)
    e.value *= 0.5
end, { type = { "reliant", "yamato" } })

The filter takes object (a handle), type, class and side, each a name or a list of names; every test given must hold. It tests the hook's main object, which the table above names: object for most hooks. A filter can also be a function, which gets e and returns true for a call the handler is for. radio_say and vm_command concern no object, so they only take a function.

Order, removal and errors

  • Handlers run newest mod first (the mod that loads last), and within a mod in the order they were added. So a later mod's handler that returns false stops an earlier mod's.
  • hooks.add and hooks.after return a handle, and handle:remove() removes the handler.
  • A handler that raises an error is logged with its file and line, and removed, and the changes it made to e are undone. One that runs out of time is removed once it has three times in a row, since a call can be slow once for reasons that aren't its own.

Objects

Scripts see objects (ships, stations, nav points) as handles, such as e.object. Every script can read their fields, such as type, side, position or hull. Objects lists the fields and the methods.

-- In a global script: every hostile fighter turns on the player.
local world = require("openreliant.world")
for _, object in world.objects() do
    if object.class == "fighter" and object.side == "hostile" then
        object:give_order("fight", world.player)
    end
end
  • The same object always gives the same handle, so == compares objects, and handles work as table keys.
  • A handle is valid until its object leaves the mission or the mission ends. Reading a field of one that isn't valid is an error. object:is_valid() tells which.
  • openreliant.world gives global scripts world.objects(), the missiles in flight as world.missiles(), the player's ship as world.player, and the mission as world.mission, with its number and its file's name.
  • type is a name such as "predator", or the qualified name of a mod's type, such as "teapot:teapot".
  • shields and armor give each quadrant: left, right, fore and aft. hull is the share of armour left in the weakest quadrant, from 1 down to 0.

Global scripts can change many of an object's fields on any object, and an object script can change them on its own object. The reference marks each with Changes:

  • throttle: 1 is full, 2 the afterburner and -1 reverse thrust.
  • roll_input, pitch_input and yaw_input: how hard it turns, from -1 to 1.
  • pilot: the pilot who flies it, whose record sets how it flies and fights. A pilot of the game's by number, a mod's pilot by its qualified name, or "none".
  • position, orientation and velocity: setting the first two moves or turns it at once, and setting its velocity pushes it.
  • side, shields, armor, afterburner_fuel and countermeasures. Shields and armour go from 0 up to what a whole ship of the type has.
  • What a mission's commands set: invulnerable, cloaked, targetable, lights, disabled, guns_disabled, missiles_disabled, engines_disabled, eject_disabled, do_not_disturb and avoidance_disabled.

An order usually sets the throttle and the turning each frame, so a change to those lasts until the order sets them again. To change what a ship does, give it an order (Orders).

-- In a global script: a mod's pilot flies every enemy fighter.
local world = require("openreliant.world")
for _, object in world.objects() do
    if object.class == "fighter" and object.side == "hostile" then
        object.pilot = "bananas:trooper"
    end
end

examples/mods/bananas does this every half second in its game mode, so that fighters that join the mission later get the pilot too.

Where things are

orientation is where an object's axes point: right, down and forward, in the game's frame, where Y points down. openreliant.util turns points between the world and an object's own frame, and works with orientations and angles:

local util = require("openreliant.util")
local world = require("openreliant.world")

-- How far off the player's nose its last attacker is, in degrees, and whether it's above.
local player = world.player
local attacker = player.last_attacker
if attacker then
    local off = math.deg(util.angle_off(player.position, player.orientation, attacker.position))
    local above = util.to_local(player.position, player.orientation, attacker.position).y < 0
end

-- A point 100 units ahead of the player, and an orientation that looks at the attacker.
local ahead = util.to_world(player.position, player.orientation, vector.create(0, 0, 100))
local facing = util.look_at(attacker.position - player.position)
-- The same orientation turned 10 degrees to the right, about its own Y axis.
local right = util.turn(facing, "y", math.rad(10))

util.angles and util.from_angles turn an orientation into pitch, yaw and roll and back, and util.normalize_angle brings an angle within half a turn either way.

A ship's parts

object:parts() lists the parts of an object's model, destroyed ones included, in the order the game numbers them: each part, followed by the parts of the models it carries, such as a turret's gun or a missile on its rail. object:attachments() lists the attachment points on those parts, such as engine glows, gun muzzles and missile hardpoints (ShipPart, ShipAttachment). Every script can read them.

-- Whether a ship can still fly: it has no engines, or one that isn't destroyed.
local function can_fly(ship)
    local engines, working = 0, 0
    for _, part in ship:parts() do
        if part.class == "engine" and not part.damaged then
            engines += 1
            if not part.destroyed then
                working += 1
            end
        end
    end
    return engines == 0 or working > 0
end
  • class says what a part is, such as "hull", "engine" or "shield_generator" (PartClass). Most parts, such as a hull's plating, have none.
  • parent is the index in the list of the part it hangs from, and an attachment's part the index of the part that carries it.
  • Components are the parts a ship lists by number, such as a capital ship's engines, shield generators and turrets. Missions, the destroyed event and object:give_order name them by that number, from 0. component gives it, and a component keeps it once it's destroyed. armor and full_armor are what a component has left and what it starts with, and invulnerable what a mission's SetInvulnerability gave it: "full" keeps off every hit.
  • damaged marks the parts of a component's damaged model, which stay hidden until the component is destroyed.
  • position is the current position of a part or an attachment in the world, and nil once its part is destroyed.
  • Each call gives a new list, which doesn't change as the ship does.

Global scripts can destroy any object, and an object script its own. object:destroy_component(component) destroys a component the same way a hit that takes its last armour does: it blows up, takes the rest of its assembly with it, such as a turret's barrels, shows its damaged model and sends its destroyed event. Losing a hull part ends the ship. object:destroy() destroys a whole object the same way running out of armour does: a ship that lists components loses its hull components, and any other ship explodes, unless its pilot ejects first. Both return false when there's nothing to destroy, such as for a nav point, a component that's already destroyed, or a ship that is already exploding or jumping.

-- Every shield generator the convoy has left goes down at once.
for _, part in convoy:parts() do
    if part.class == "shield_generator" and part.component and not part.destroyed then
        convoy:destroy_component(part.component)
    end
end

The player's target

world.set_player_target(target, component) makes a ship, or its component number component, the player's target, as a mission's SetPlayerTarget does. It returns false for a target the player can't aim at, such as a destroyed component or a ship that can't be targeted. world.set_primary_target(target, component) makes it the mission's primary target, as SetPrimaryTarget does. The PRIMARY TARGET key selects the primary target. Nil leaves the mission without one.

local hooks = require("openreliant.hooks")
local world = require("openreliant.world")

-- As each shield generator goes down, the nearest one left that can be hit becomes the player's
-- target.
local function nearest_generator()
    local nearest, ship, distance = nil, nil, math.huge
    for _, object in world.objects() do
        if object.side == "hostile" then
            for _, part in object:parts() do
                if part.class == "shield_generator" and part.component and not part.destroyed
                    and part.invulnerable ~= "full" then
                    local away = vector.magnitude(part.position - world.player.position)
                    if away < distance then
                        nearest, ship, distance = part, object, away
                    end
                end
            end
        end
    end
    return ship, nearest
end

local function class_of(ship, component)
    for _, part in ship:parts() do
        if part.component == component then
            return part.class
        end
    end
end

hooks.add("destroyed", function(e)
    if e.component == nil or class_of(e.object, e.component) ~= "shield_generator" then
        return
    end
    local ship, generator = nearest_generator()
    if ship then
        world.set_player_target(ship, generator.component)
        world.set_primary_target(ship, generator.component)
    end
end)

Object scripts

An object script runs on one object, from when the object is added to the mission until it leaves, or the mission ends. The manifest starts it on every object of a class or a type, and a global script starts it on one object with object:add_script(name, data), which passes data to its on_init. object:remove_script(name) stops it. require("openreliant.self") gives the script its own object.

-- wingman.luau, listed as Fighter=wingman.luau: a badly damaged wingman runs from its attacker.
local self = require("openreliant.self")

return {
    engine_handlers = {
        on_update = function()
            if self.is_player or self.side ~= "friendly" or self.order == "run_away" then return end
            if self.hull < 0.3 and self.last_attacker then
                self:give_order("run_away", self.last_attacker)
            end
        end,
    },
}
  • The script gets on_init and then on_added as it starts, and on_removed as its object leaves the mission. As a mission ends, its objects' scripts stop without on_removed.
  • Each object's scripts have their own globals, so the same script on two ships keeps two sets of variables.
  • self:hook(name, handler) adds a handler for the calls that concern the object only, like hooks.add with the object as the filter.
  • require("openreliant.nearby").objects(radius) gives the objects within radius of the script's object, nearest first.

A global script can start an object script on any object it chooses, such as each ship of a type a mod adds:

return {
    engine_handlers = {
        on_object_added = function(object)
            if object.type == "teapot:teapot" then
                object:add_script("teapot_ship.luau")
            end
        end,
    },
}

Missile scripts

A missile script runs on one missile, from its launch until its flight ends, as it strikes something, runs out of time or is set off, or until the mission ends. Missile= starts it on every missile, and require("openreliant.self") gives it its missile, a handle (Missiles) with the missile's type, launcher, target, position and velocity.

-- Raptors fired at the player's ship turn to the nearest other ship of the player's side, if any.
local self = require("openreliant.self")
local nearby = require("openreliant.nearby")

return {
    engine_handlers = {
        on_added = function()
            local player = self.target.object
            if self.type ~= "raptor" or not (player and player.is_player) then return end
            for _, other in nearby.objects(20000) do
                if other.side == player.side and not other.is_player then
                    self.target = { object = other }
                    return
                end
            end
        end,
    },
}
  • The script gets on_init and on_added as the missile is launched, its target set, and on_removed as its flight ends. As a mission ends, its missiles' scripts stop without on_removed.
  • Each missile's scripts have their own globals, as each object's have.
  • self.target can be set to aim the missile elsewhere, position, orientation and velocity to move, turn or push it, and self:detonate() ends its flight at once. Global scripts can do all of these to any missile.
  • nearby.objects(radius) gives the objects around the missile, nearest first.
  • Global scripts hear of each missile with the events missile_added and missile_removed.

Turret scripts

A turret is one of a ship's guns that turns to aim ("aimed"), spins its barrels while the ship fires ("spinning"), or launches missiles ("launcher"). A turret script runs on one turret while its ship is in the mission: Turret= starts it on every turret of every ship as the ship is added, after the ship's own scripts. require("openreliant.self") gives it its turret, a handle (Turrets) with its object, its kind, its gun_type, its position and its target.

-- Missile turrets on the Coalition's capital ships hold their fire for the player's ship.
local self = require("openreliant.self")

return {
    engine_handlers = {
        on_update = function()
            if self.kind ~= "launcher" or self.object.side ~= "hostile" then return end
            local aimed = self.target and self.target.object
            if aimed and aimed.is_player then self.target = nil end
        end,
    },
}
  • The script gets on_init and on_added as its ship is added, and on_removed as the ship leaves the mission. A turret destroyed with its base stays: self.destroyed becomes true, and it turns and fires no more.
  • Each turret's scripts have their own globals.
  • Setting self.target aims an aimed turret or a missile turret; nil leaves it to look for a target of its own. A spinning gun fires where its ship points, so it has no target.
  • object:turrets() gives global scripts the turrets of any object.

Orders

A ship does what its orders say. object:give_order(order, target) gives it one, by the order's name (Order), a mod's order by its qualified name, or an order with no name, such as 200, by its number, aimed at target or at nothing. The new order goes on top of the ship's orders, as a mission's SetAI does, and the ones below carry on as it ends. Global scripts can give any object orders, and an object script its own object.

A third argument aims the order at one part of target, as a mission's orders can: for a Launch, the carrier's launch gate, counting from 0, and for a Dock, its port. A ship given a Launch waits at its gate until object:start_launch() starts it, as a mission's StartLaunch does:

local ship, carrier = world.objects()[2], world.objects()[1]
ship:give_order("launch", carrier, 1)  -- waits at the carrier's second gate
ship:start_launch()                     -- and goes, after a short random wait

openreliant.orders shows and ends them:

local orders = require("openreliant.orders")
local world = require("openreliant.world")

-- What a ship is doing, from the top order down.
local ship = world.objects()[2]
for _, entry in orders.stack(ship) do
    print(entry.order, entry.target.object)
end
orders.cancel(ship)  -- ends the top order; the one below carries on
orders.clear(ship)   -- drops all of them, as a mission's ClearAI does
print(orders.info("fight").priority)

Custom AI orders

Global and mission scripts can register an order of their own with orders.register(name, definition). It returns the order's qualified name, which give_order takes. A mission script's orders end with its mission.

-- pulse.luau, from examples/mods/custom-order: a short burst of throttle.
local orders = require("openreliant.orders")
local world = require("openreliant.world")
local elapsed = {}

local pulse = orders.register("pulse", {
    flags = { avoidance = true },
    init = function(ship)
        elapsed[ship] = 0
    end,
    update = function(ship, target, seconds)
        elapsed[ship] += seconds
        ship.throttle = 0.25
        return elapsed[ship] < 2
    end,
    exit = function(ship)
        elapsed[ship] = nil
        ship.throttle = 0
    end,
})

-- Elsewhere: world.objects()[2]:give_order(pulse)
  • update is required. It runs each frame for each ship that follows the order, with the ship, its target (nil for none, or for a flight group), and the seconds of game time. Returning false ends the order; returning nothing or true carries on.
  • init runs as the order starts or starts again, and exit as it ends or another order replaces it. A one-shot order runs only update.
  • priority is 0 by default, for an order that any other order replaces. Once an order with a higher priority has started, only an order of higher priority, a one-shot order or explode replaces it, as with the game's orders.
  • flags (OrderFlags) are each false by default: players, for an order the player's ship can be given; one_shot, for one that runs its update once and ends; retaliate, to let the ship turn on whoever hits it hard enough; avoidance, to watch for objects the ship could hit while it follows the order; and send_flight, to send the ship's steering and speed to the other players in a multiplayer game.
  • The functions can steer the ship, but can't give or end orders or register another. To do something later, send an event.
  • Keep each ship's state in a table keyed by the ship, as above, and clear it in exit.
  • A function that fails turns its order off: the ships following it drop it, and their orders below carry on.
  • The order goes away when the scripts that registered it stop or reload. A loaded saved game starts the global scripts again, and they register their orders again.

The radio

openreliant.radio says lines on the radio, as a mission's comms commands do. Global and object scripts can use it while a mission runs.

local radio = require("openreliant.radio")

-- As CommsFromPilot: Diceman says the mod's line, with his face.
radio.say_pilot("diceman", "ms_dice22_001.ut")
-- As CommsFromShip: the ship says it with its pilot's face, after the lines before it.
radio.say(ship, "ms_dice22_002.ut", { mode = "queued", face = "laughing" })
  • say takes a ship and shows its pilot's face. say_pilot takes a pilot by its name or number, or one a mod adds by its qualified name.
  • The speech file is the game's or a mod's (Lines).
  • A line is said at once, ending the line playing, as the comms commands say theirs. With mode = "queued" it's said after the lines before it, and with "if_idle" only if the radio has nothing else to say.
  • face picks the film of the speaker's face: "talking", "laughing", "squadron" or "dying". With once = true the film plays once, then the dead channel's while the line goes on, as the commands' Once forms play it.
  • Each line goes through the radio_say hook, so other mods can change or drop it (Changing what the radio says).
  • Between missions nothing is said, and both functions return false.
  • radio.busy() says whether the radio is saying a line, or has lines waiting.

A mission that prints its radio as debug text, such as the Dreamcast's mission 22, can be voiced from the vm_command hook (The mission script's commands). The argument of a text is its offset in the mission's file, which is the same each time the mission runs, so a mod can keep a table that gives the line for each text. print(e.arguments[1]) shows the offsets as the mission runs. To work one out ahead, add the script section's offset in the file (sltool dte sections), the offset of the text's push_string in the script (sltool dte script), and 2.

A script whose waits were timed for reading text runs ahead of the voices. Holding the wait that follows each voiced text while the radio is busy keeps the mission in step with what is said.

local hooks = require("openreliant.hooks")
local radio = require("openreliant.radio")

-- The mod's table of lines, by the offset of the text each one replaces:
-- { [place] = { "diceman", "ms_dice22_016.ut" }, ... }
local lines = require("lines")
local voiced = false

hooks.add("vm_command", function(e)
    if e.command == "print_debug_message" then
        local line = lines[e.arguments[1]]
        voiced = line ~= nil
        if line then
            radio.say_pilot(line[1], line[2], { mode = "queued" })
        end
    elseif e.command == "wait" and voiced then
        -- The script waits on after the line, once the radio has said it.
        if radio.busy() then
            e.result = "hold"
            return false
        end
        voiced = false
    end
end)

A mission's objectives

A mission's script shows its objectives in the objectives window with SetObjective, which numbers them from 0 to 9. world.set_objective(objective, state) does the same from a global or mission script: "listed" shows the objective, "current" shows it as the current one, which the window then shows, and "hidden" takes it away. It returns false between missions, and in a mission whose objectives nothing names.

local world = require("openreliant.world")

-- The convoy's escort is down: on to the troop carriers.
world.set_objective(0, "listed")
world.set_objective(1, "current")

The objectives' names come from the game's table, by the mission's number. A campaign mission's can come from its record (Each mission of the campaign), and a game mode's mission's from the mode (Game modes).

Events

Scripts send each other events: core.send_global_event(name, data) to the global and mission scripts, and object:send_event(name, data) to the scripts of one object. Global, mission and object scripts handle them in the event_handlers they return, by the event's name:

-- The wingman's script tells the global scripts it has fled.
local core = require("openreliant.core")
local self = require("openreliant.self")
core.send_global_event("WingmanFled", { ship = self })

-- A global script hears it.
return {
    event_handlers = {
        WingmanFled = function(data)
            print(tostring(data.ship) .. " has fled")
        end,
    },
}
  • An event arrives at the next update, before the scripts' on_update.
  • Player and menu scripts send events to the global scripts too, which is how they change the game. They don't receive events: to show the game's state, a global script writes it to a game section, and the player script reads it each frame, as examples/mods/arena does.
  • data must be plain data: nil, booleans, numbers, strings, vectors, objects, and tables of these. It's copied as it's sent, so changing the table afterwards changes nothing.
  • Each script with a handler for the event gets it, newest mod first. A handler that returns false stops the rest.

Interfaces

A script offers functions to other scripts by returning interface_name and interface. Other scripts reach it through require("openreliant.interfaces"), as I.<name>:

-- In mod A's global script.
local fled = 0
return {
    interface_name = "Wingmen",
    interface = { fled = function() return fled end },
    event_handlers = { WingmanFled = function() fled += 1 end },
}

-- In another mod's global script.
local I = require("openreliant.interfaces")
if I.Wingmen and I.Wingmen.fled() > 2 then
    -- ...
end
  • Global and mission scripts see each other's interfaces. The scripts on an object see the interfaces of the other scripts on that object, and player and menu scripts see each other's.
  • An interface that nobody offers is nil.
  • Other scripts get a read-only copy of interface, taken as the script starts. Keep what changes in the script's own variables, as fled above, not in the table.
  • A later script that offers the same name takes its place, and gets the earlier interface in its on_interface_override(base) handler, so it can call through to it.

Built-in interfaces

I also holds groups of the packages' functions, under names that suit what a mod does:

Group What it holds Scripts
Flight The orientation and frame functions of util All
AI orders, and give_order(ship, order, target, component) Global, object
Combat add_hook and after_hook, which are hooks.add and hooks.after Global, object
Carriers give_order, start_launch, add_hook and after_hook Global, object
Camera, HUD The camera and hud packages Player
Controls, Audio The input and audio packages Player, menu
FrontEnd The ui package Player, menu
Missions The world package Global
Campaign world.mission and core.send_global_event Global

Campaign is about the mission that runs, not about a mod's campaigns. A group a script can't use is nil, and its functions keep the rules of their packages. A mod can offer an interface under a group's name, such as interface_name = "Flight", which then takes its place for the scripts that see it, and gets the built-in group in on_interface_override(base). When it stops, the built-in group comes back. Built-in interfaces lists each group's functions, and examples/mods/strafe-run uses them.

The records

require("openreliant.records") gives the game's records. Load scripts can change them; other scripts can only read them.

Table Contents First number Names
ships Ship stats, shipstats.bin, then the types the mods add 0 Every ship type's name, such as predator, and the qualified names of the types the mods add, such as teapot:teapot
ship_types What the executable holds for each ship type, then for the types the mods add 0 As ships
guns Gun stats, gunstats.bin, then the guns the mods add 1 laser_cannon, pulse_cannon and the rest, and the qualified names of the mods' guns
missiles Missile stats, missilestats.bin, then the missiles the mods add 0 screamer, raptor and the rest, and the qualified names of the mods' missiles
pilots Pilot stats, pilotstats.bin, then the pilots the mods add 0 frenchy, viper, ronin_leader and the other pilots the game's code singles out, and the qualified names of the mods' pilots
faces The pilots' faces, which the game keeps in its executable, then those of the pilots the mods add 0 As pilots
text The game's text, language.dll, by string id 1
itac_text The ITAC's text, itaclang.dll, by string id 1

The package also holds the campaign's order of missions, campaign (The campaign's missions), each mission's settings, missions (Each mission of the campaign), the KILLBOARD's pilots, killboard (The KILLBOARD's pilots), the combat maneuvers, maneuvers (Combat maneuvers), and the planets, planets (Planets).

Records are looked up by number or by name, with the field names of the stat tables. The definitions file for editors (Editors) lists every field of Ship, Gun, Missile and Pilot.

A ship type's record (ShipTypeRecord) holds what the game keeps in its executable rather than in shipstats.bin: targetable, whether ships can target it; name, the id of the string that names it; class, such as fighter or support; side, the side its objects start on; and display, how the target display draws it. The class decides more than how the type is targeted: fighters fly with the fighters' flight model, and only fighters turn on a fighter that hits them (Retaliation).

A pilot's face (Face) has name, the id of the string the radio's window shows over the face, and the film the face plays for each way it moves as the pilot speaks: talking, laughing, squadron (the 45th's own pilot, in most faces) and dying. A film is the name of a face film pilots\<film>.fm8 in pilots.hog, or <film>.fm8 in a mod (Face films), at most 116 characters long. An empty name plays the dead channel's static.

local records = require("openreliant.records")

records.guns.laser_cannon.damage.hull = 30   -- by name
records.ships[12].max_speed *= 1.1           -- by number
records.pilots[66].skill = "high"            -- values with names use their names
records.text[568] = "Laser Cannon Mk II"     -- text is a string
records.faces.frenchy.talking = "45Tigers_Plt"  -- a face's film

for number, missile in records.missiles do  -- every record, in order
    missile.lock_time *= 0.8
end
  • Assigning a table to a record changes only the fields in it. With a template record, the record is first copied from the template: records.guns[2] = { template = records.guns[1], speed = 5 }.
  • A wrong field name or a value of the wrong type is an error.
  • Text is UTF-8; characters the game can't show become ?.
  • Records can't be removed, because missions refer to them by number.
  • A load script that fails has its changes undone, and the next one runs.
  • on_records_loaded runs once every mod's load scripts have run, so a mod can adjust what the mods before and after it changed (examples/mods/balance).

Mods' records

A mod adds a record by adding a ship type, a gun, a missile or a pilot in its manifest (New ships, guns, missiles and pilots). The record starts as a copy of its base's, and a load script tunes it by its qualified name:

-- records.luau, from examples/mods/bananas.
local records = require("openreliant.records")

-- The Banana Gun's shots fly faster and hit harder than the Pulse Cannon's.
local gun = records.guns["bananas:banana_gun"]
gun.speed *= 1.5
gun.damage.shield *= 1.5
gun.damage.hull *= 1.5

-- A Banana locks on in half the time of a Bandit.
local missile = records.missiles["bananas:banana"]
missile.lock_time = math.floor(missile.lock_time / 2)

-- The Trooper flies like a beginner.
local trooper = records.pilots["bananas:trooper"]
trooper.tier_b = "level_0"
trooper.skill = "low"

Wherever scripts see a ship type, a gun, a missile or a pilot, a built-in one is OpenReliant's name for it, such as "predator", or a number where it has none. Every ship type has a name. One a mod adds is its qualified name, such as "teapot:teapot". A pilot can also be "none". examples/mods/interceptor tunes a mod's ship type.

The campaign's missions

records.campaign is the list of missions the game's campaign flies, by their numbers, in order. The game's list is 1 to 11, 14, 15, 16, 18 to 21, and 23 to 28: the campaign has no missions 12, 13, 17 and 22. Reading it gives a new list each time, and a load script changes the campaign by assigning a list back. With mission files of its own, a mod can put missions into the campaign, or make a campaign of its own.

local records = require("openreliant.records")

-- Missions 12 and 13 after 11, 17 after 16, and 22 after 21, from the mod's own mission files.
local campaign = records.campaign
for _, mission in { 12, 13, 17, 22 } do
    table.insert(campaign, mission)
end
table.sort(campaign)
records.campaign = campaign
  • The list holds numbers from 1 to 28, rising, each at most once, and at least one. The saved game keeps a record for each of those 28 missions. To fly missions in another order, number their files in that order.
  • The campaign starts at the list's first mission, goes on to the next one after each, and comes to the story's end after the last.
  • The number the player sees for a mission, in the autosave's name and the saved games' list, is its place on the list. The ITAC's debriefings list the missions on it.
  • What the campaign makes of each mission, such as its briefing and its carrier, is in records.missions (Each mission of the campaign).
  • The campaign takes the list once, as OpenReliant starts, after every mod's load scripts and their on_records_loaded handlers. Changing it in a game mode's records script does nothing.

Each mission of the campaign

records.missions holds the campaign's settings for each of its missions, indexed by the mission's number: what the briefing room plays before it, the carrier it's flown from, the names of its objectives, the date the launch shows, what it awards, Enriquez's report and debriefing, the ITAC's news, the landing and the chapter's news after it, the wing's pilots, and the rules the game applies to particular missions. The game decides each of these by the mission's number. A mission's fields start with the game's values, and load scripts change them in place, as they do a record's. The reference lists the fields.

A mod that changes one thing changes one field:

local records = require("openreliant.records")

records.missions[5].objectives = { "Destroy the convoy", "Protect the Patriot" }

A mod that puts back missions the campaign doesn't fly gives them what the game lacks. A mod can't make a Bink movie for the briefing room's screen, so Enriquez can speak the briefing over the room instead, as she does at the campaign's end, while another mission's movie that fits plays on the screen without its sound:

local mission = records.missions[12]
mission.hologram = "new_m05.bik"
mission.speech = "dreamcast_brief12.ut"
mission.date = "February 2, 2161"

A campaign of the mod's own sets each of its missions. Assigning a table to a mission changes the fields the table gives:

records.missions[3] = { carrier = "yamato", hologram = "mycampaign_m03.bik", last_word = "ms_speech\\mycampaign_tag03.ut" }

A restored mission can also have its own report on the rooms' television, its own debriefing and its own news in the ITAC:

local mission = records.missions[12]
mission.television_report = { { scene = "dreamcast_0115.box" } }
local debriefing = mission.debriefing
debriefing.success = { "Good work out there.", "The Reliant is safe for now." }
mission.debriefing = debriefing
-- The news after mission 12, in the rooms before the next mission.
records.missions[14].news = { { title = "Reliant survives ambush", paragraphs = { "..." }, picture = 13 } }

It can award a medal, land on the other carrier, play a report of its own after the chapter's movie, and bring a pilot into the wing:

local mission = records.missions[13]
mission.medal = "valour"
mission.landing_carrier = "yamato"
mission.chapter = 2
mission.chapter_reports = { { movie = "mymod_news13.bik", unless = { "krasnaya_alive" } } }
mission.alpha_5_pilot = "diceman"

A campaign of the mod's own turns off the game's rules for particular missions where its missions don't want them:

-- Mission 25 is an ordinary mission here: no second part, no Kamovs, and a landing whatever
-- happens to the Yamato.
local mission = records.missions[25]
mission.second_part = false
mission.kamov_wing = false
mission.fort_bear_ending = false
  • Each mod changes only the fields it sets, in load order, so mods that change different fields of the same mission don't undo each other's changes.
  • Reading objectives gives a new list each time. To change the names, assign a list. Nil gives back the names the game's own table has for the mission's number.
  • Where a mission has speech, Enriquez speaks it over the room, and the briefing ends when she does. The hologram plays on the room's screen meanwhile without its sound, and starts over if it ends first, so another mission's movie can show something fitting. A hologram of nil leaves the screen empty.
  • The carrier decides the rooms the player walks, the briefing room, the loadout's backdrop and the hangar the launch starts in.
  • The game reads the missions as it goes, so a game mode's records script changes them for the mode's missions alone.
  • A mission's awards are fields too: the loadout tier it unlocks, the chapter it ends, which gives a ribbon and a movie, and its medal. So are the special cases the game ties to missions 1 and 23: the new pilot's induction and the loadout's lesson before mission 1, and the loadout's only_ship, the Shroud, before mission 23. A mod that moves or adds missions can move these with them.
  • television_report is Enriquez's report on the rooms' television before the mission, and debriefing her debriefing in the ITAC after it, with paragraphs for each rating.
  • news and video_reports are the ITAC's news items and video reports that appear in the rooms before the mission, and stay listed after it. So the news of how a mission went goes on the mission after it.
  • Reading television_report, debriefing, news or video_reports gives a new table with the game's text in it, which you can change and assign back.
  • The rules the game applies to particular missions are fields too, each true or false: the wing's t_ twins from mission 14 (wing_twins), the Flying Tigers' name after mission 13 (flying_tigers), mission 25's second_part and Kamovs (kamov_wing), mission 28's close_ion_cannons, hurried_turrets and terminate_ends_well, mission 26's ripper_from_below, mission 8's wide_advanced_gate, counts_kills up to mission 27, missions 25 and 27's fort_bear_ending, and the ITAC's cobras_inquiry from mission 7 and fifty_first_listed up to mission 9. A replacement campaign turns off the ones its missions don't want, such as mission 25's second part.
  • landing_carrier is where the landing plays after the mission, and yamato_visit whether the ship lands on the Yamato instead, as after missions 7 and 8. chapter_reports are the news reports after the chapter's movie, each waiting on the game's variables, such as rameses_alive, and alpha_5_pilot and alpha_6_pilot the pilots the wing takes on.

The KILLBOARD's pilots

records.killboard holds the pilots of the ITAC's KILLBOARD, numbered from 1, in the game's order. Each has the text the board shows, the kills it starts with and adds, its portrait, and the missions it joins the board at, leaves it after and sits out. A mod that restores or moves missions keeps the board in step with its campaign, and a campaign of its own gives the board's places pilots of its own:

local records = require("openreliant.records")

for _, pilot in records.killboard do
    if pilot.name == "Klaus Steiner" then
        -- He flies the restored mission 22 after all.
        pilot.sits_out = { 19, 20, 21, 23 }
    end
end
records.killboard[19] = { name = "Trent Ramsey", squadron = "(45th Volunteers)", kills = 12, joins_at = 12 }
  • The board always has the game's 19 places: a script changes pilots but can't add or remove them. A pilot who should never show can leave after mission 0.
  • Text is read from the ITAC's strings and can be changed to any text, in the game's code page.
  • A pilot with in_45th set shows as one of the 45th Flying Tigers in the missions whose flying_tigers rule is on (Each mission of the campaign).

Combat maneuvers

records.maneuvers holds the combat maneuvers that ships fly under the Fight order, numbered from 0 as the game numbers them, from defend_dodge1 to run_to_ship (9). Each has a name, the turning inputs it may mirror, the range of its length in ticks (min_ticks and max_ticks), and its script: a list of lines in the maneuvers' language (Combat maneuvers). A load script can change a maneuver, or add one by assigning to the number after the last:

local records = require("openreliant.records")

-- A shorter loop the loop.
records.maneuvers[8].max_ticks = 2000

-- A barrel roll, number 10.
records.maneuvers[#records.maneuvers] = {
    name = "barrel roll",
    mirror = { roll = true },
    min_ticks = 1500,
    max_ticks = 3000,
    script = { "Cloak(off)", "SetSpeed(1)", "SetPitch(0.2)", "loop:", "SetRoll(1)", "Wait(500)", "Goto loop" },
}

The game never chooses a maneuver that a mod adds. A handler of fight_choose_maneuver chooses one by setting e.result, and object.maneuver is the one a ship flies: the game's by name, a mod's by number, or nil while the ship isn't fighting.

local hooks = require("openreliant.hooks")

-- One time in four, a hostile fighter rolls instead.
hooks.add("fight_choose_maneuver", function(e)
    if math.random(4) == 1 then
        e.result = 10
        return false
    end
end, { side = "hostile", class = "fighter" })
  • A script is checked as it's set, and a line the language doesn't have is an error that gives its number. A script has 255 lines at most, and there can be 255 maneuvers.
  • A game mode's records script can change and add maneuvers for the mode's missions alone (Game modes).
  • A maneuver that a handler chooses runs for a length drawn from its range. A number with no maneuver runs nothing, and the ship chooses again on its next frame. So does a handler that stops the choice without setting e.result.
  • A handler of maneuver_run that stops it flies the ship itself, by setting its yaw_input, pitch_input, roll_input and throttle.

Planets

records.planets holds the object types that the game sets up as planets when a mission makes them: lit by the sun alone, always drawn, and colliding with nothing (Planets). The original's are Neptune to Venus and their far models, numbered from 1 in the order of their types. Each entry has a type, how fast the planet turns about its north pole (spin, in radians a second, positive eastward), how far and in which direction its pole leans (tilt and tilt_toward, in radians), and its atmosphere: the colour of the band round its rim (atmosphere_color, nil for none) and where the band starts and ends (atmosphere_inner and atmosphere_outer, as shares of its radius as the camera sees it; the planet hides the band within its edge), or a mod's surface function that draws the atmosphere instead, with the numbers it reads (atmosphere_surface and atmosphere_parameters, A planet's atmosphere). A load script can change a planet, or add one by assigning to the number after the last:

local records = require("openreliant.records")

-- Saturn, number 4, turns eastward once every ten minutes, its pole leaning 26.7 degrees toward +X.
records.planets[4].spin = 2 * math.pi / 600
records.planets[4].tilt = math.rad(26.7)
records.planets[4].tilt_toward = math.pi / 2

-- Earth, which the original leaves out, with a thin blue atmosphere.
records.planets[#records.planets + 1] = {
    type = "earth",
    spin = 2 * math.pi / 1440,
    tilt = math.rad(23.4),
    atmosphere_color = vector.create(0.3, 0.5, 1),
    atmosphere_inner = 0.97,
    atmosphere_outer = 1.02,
}
  • A planet's north pole is its model's -Y, which is up on the screen when the planet isn't turned. The pole leans from the up direction of the planet as the mission places it, and the planet spins about the leaning pole.
  • If a type has several entries, the first one counts. A type that a mod adds uses its base type's entry, unless the table has an entry for it.
  • The table holds up to 64 planets. A game mode's records script can change and add planets for that mode's missions only (Game modes).

The planets have a light of their own, which shines from the sun and reaches nothing else (Lights). It has the colour of the game's key lights, (1, 1, 0.8), which the original lights everything with, unless a load script gives it another in records.planet_light, such as white for planets whose textures hold their true colours. Nil gives it the key lights' colour again. Ships and every other model keep their light.

local records = require("openreliant.records")

records.planet_light = vector.create(1, 1, 1)

Saved games

The game is saved between missions: in the Reliant's rooms, and by the autosave as the campaign moves on. Global and player scripts save their state in a file next to the saved game (saves\<call sign>GAME<slot>.scripts), so the saved game itself stays as the original writes it:

local missions = 0

return {
    engine_handlers = {
        on_mission_start = function()
            missions += 1
        end,
        on_save = function()
            return { missions = missions }
        end,
        on_load = function(saved)
            missions = saved and saved.missions or 0
        end,
    },
}
  • on_save returns plain data (Events), which is kept with the saved game.
  • When a saved game is loaded, the scripts start again, and each gets on_load with what its on_save returned, in place of on_init. A script that didn't run when the game was saved, such as one of a mod added since, gets on_init instead.
  • The same goes for the restart point. The scripts' state is kept as each mission of the campaign starts, and a replay or the pause menu's RESTART puts it back, so that the scripts start the mission again as they were.
  • All the mods' state together, their game sections (Storage), what their scripts saved and their timers, can take up to 256 MiB. If it would take more, the mods that keep the most are left out until the rest fits, and the log names them. When the game is loaded, their scripts get on_init, like those of a mod added since.
  • Mission and object scripts don't run between missions, so they aren't kept. Menu scripts run across games, so they aren't kept either.

examples/mods/tally keeps a tally with each saved game.

Storage

openreliant.storage gives each mod named sections of plain data, which you read and change like tables:

local storage = require("openreliant.storage")
local tally = storage.game_section("tally")
local best = storage.global_section("best")

tally.kills = (tally.kills or 0) + 1
if tally.kills > (best.kills or 0) then
    best.kills = tally.kills
end
for name, value in tally do
    print(name, value)
end
  • A game section goes with the saved game and the restart point, and starts empty with each new game. Global and object scripts change it; the other scripts can only read it.
  • A global section is kept in the game folder, in storage\<mod>.data, across every game. Any script can change it. OpenReliant writes the sections that changed at most every 2 seconds, and as it quits. A crash or a full disk during a write leaves the file as it was before.
  • The global section called options holds the mod's options (Options), and global_section doesn't open it.
  • Each mod has its own sections: two mods' sections of the same name are separate. All of a mod's scripts see the same sections.
  • Values are plain data, copied as they're stored and as they're read, so changing a table read from a section changes nothing until it's stored again. Setting a field to nil removes it.
  • A mod's sections hold up to just under 15 MiB in each place, the game's and the global, measured as the memory they take. A change past that is an error, but one that makes them smaller works. A value stored at once can take up to 16 MiB, a table that holds the same table twice counting it twice.

Timers

openreliant.async runs one of the mod's functions after a delay:

local async = require("openreliant.async")

async.register_timer("reinforce", function(data)
    print("wave " .. data.wave)
end)

async.after(30, "reinforce", { wave = 2 })
  • A timer names a function rather than holding it, so that it can be kept with the saved game. Register the function when the script runs, at its top level, so that it's there again after a load.
  • A timer only runs a function registered by scripts of the same kind in the same mod, and for an object script, on the same object. A player script can't run a function the mod's global script registered.
  • Global and object scripts' timers count game time, which stops while the game is paused. Player and menu scripts' timers count real time.
  • A timer runs at the first update after its time is up, before on_update; for player and menu scripts, at the next frame, before on_frame. It gets its data, which must be plain data.
  • Global and player scripts' timers are kept with the saved game and the restart point. An object script's timers stop as its object leaves the mission.

Player and menu scripts

Player and menu scripts decide what the player sees, hears and does. They run separately from the game's scripts: they can read objects, but change the game only by sending events to the global scripts.

-- clock.luau, listed as Player=clock.luau: the time spent flying, over the flight display.
local hud = require("openreliant.hud")
local flown = 0

return {
    engine_handlers = {
        on_frame = function(seconds)
            if not hud.shown then return end
            flown += seconds
            hud.text(vector.create(16, 16, 0), string.format("%.0f s", flown), {
                color = vector.create(0.4, 1, 0.4),
            })
        end,
        on_key_press = function(key)
            if key == "f9" then flown = 0 end
        end,
    },
}
  • on_frame runs each frame drawn, even while the game is paused, with the seconds of real time since the last.
  • For a player script, require("openreliant.self") gives the player's ship (nil between games), and openreliant.nearby the objects around it.
  • Player and menu scripts run, and draw with ui over the screen, in the briefing, the loadout, the ITAC and the other rooms, over the movies and over the loading screens too.

examples/mods/dvd draws over the menus, and examples/mods/wingmen over the flight display.

Drawing

openreliant.hud draws over the flight display, and openreliant.ui over the menus: the front end's screens and the pause menu. Both have text, line, rectangle, picture and shape, and measure for the size of a text.

  • Draw in on_frame, or in a registered display's or screen's frame: each frame starts with nothing drawn. Drawing at any other time is an error.
  • shown says whether the display or the menu shows this frame. Check it before drawing.
  • Places are in the window's pixels from its top left corner, and width and height give the window's size.
  • Each function takes a style table, whose fields are all optional (Tables): color (a vector of red, green and blue from 0 to 1) and alpha for all of them, width for a line, scale, align ("left", "center" or "right"), font and base_font for text.
  • Text is drawn in the game's font, at the game's text size times the style's scale, unless the style picks another font (Pictures, shapes and fonts).

Pictures, shapes and fonts

hud and ui can draw the mod's PNG pictures and the game's shapes, and text in the mod's fonts:

local ui = require("openreliant.ui")

return {
    engine_handlers = {
        on_frame = function()
            if not ui.shown then return end
            ui.picture(vector.create(20, 30, 0), "badge.png", vector.create(64, 64, 0), { alpha = 0.8 })
            ui.shape(vector.create(100, 30, 0), 1, { scale = 1, color = vector.create(1, 1, 1) })
            local style = { font = "menu_large", scale = 1.5 }
            local size = ui.measure("Flight status", style)
            ui.text(vector.create(20, 110, 0), "Flight status", style)
        end,
    },
}
  • Pictures are PNG files in the mod, named without folders. size is in window pixels; without it, the picture is drawn at its own size. The style's color and alpha tint it.
  • Shapes. A shape number picks a shape from the game's current sprite set: the flight display's set for hud, or the current menu screen's set for ui (Shapes). Each shape keeps its own anchor point, and is drawn at the game's scale times the style's scale. A set that isn't loaded, or a shape it doesn't have, is an error.
  • Fonts. A text style's font is one of the game's, "default", "hud", "menu_small" or "menu_large", or a font file in the mod. Text is always drawn in the style's colour.
    • A .ttf or .otf file is drawn at the window's resolution. It takes the spacing of the game's font that base_font names ("default" unless given), and fits over it the way a mod's replacement for that font does (Fonts), so it works over any of them. The base font draws the characters the file doesn't have. With OUTLINE FONTS off under VIDEO, the text is drawn in the base font.
    • A .fnt file is drawn like the game's fonts. Most fonts store each pixel as a level of the text's colour, as the menus' fonts do. If most of a font's letters use colours of its palette instead, as the flight display's font does, each pixel is as strong as its colour is close to the letters' brightest colour.
  • measure takes a text style, or just a number for its scale, and measures as text draws: width and height lay the line out, and ink is the box its letters' pixels cover, from the point the text is drawn at as its align places it, or nil for text without any, such as spaces. An outline font's letters can start and end inside or outside the font's widths, so ink lines up what shows, such as columns of text whose first letters differ.
  • The pictures and fonts stay loaded once drawn, and a reload reads the files again. All the mods' pictures and fonts together can hold up to 128 files and 128 MiB, a picture taking 4 bytes a pixel: a 4096x4096 picture takes 64 MiB. When a new one doesn't fit, the pictures drawn longest ago make room for it, so a mod can cycle through more pictures than that, such as the frames of an animation. Only the fonts and the pictures drawn in the last two frames have to fit at once.
  • A missing or broken file is an error in the script, and so is a picture or font that doesn't fit.

examples/mods/drawing-assets draws each of them.

HUD displays

A player script can register a display, which draws over the flight display each frame:

local hud = require("openreliant.hud")

local status = hud.register_display("status", {
    frame = function(seconds)
        hud.text(vector.create(20, 30, 0), "Shift F12: strafe run")
    end,
})
hud.set_display_enabled(status, false)  -- hides it until it's turned on again

While the flight display shows, each enabled display draws in the order it was registered, after the scripts' on_frame. A display whose frame fails is turned off; the others carry on.

The game's instruments

A display can stand in for the game's own instruments, and move or scale them:

hud.register_display("radar", {
    replaces = { "radar" },  -- the game's radar isn't drawn; this display draws one
    layout = {
        gauges = { scale = 1.25 },                    -- the targeting cluster, drawn larger
        clock = { offset = vector.create(-120, 70, 0) },  -- the clock, moved
    },
    frame = function(seconds)
        local box = hud.bounds("radar")  -- where the game's radar would draw
        if box then
            -- draw a radar of the mod's own in the box
        end
    end,
})
  • replaces lists the instruments (HudInstrument) the display stands in for: the radar, the readouts, the targeting cluster, the reticle, the clock, the target's markers, the messages, each window and the rest. They aren't drawn while the display is on, but they keep working: RADAR RANGES still changes the radar's range, and the windows still open and close with their keys.

  • layout moves and scales the instruments it names. offset is in the game's pixels, which grow with the window as the flight display's own do, so a layout looks the same at any size. scale, above 0 and up to 8, draws the instrument that many times its size, sharp at the size it's drawn, growing from where it stands on the screen, as the whole display grows with the window.

  • Everything goes back as it was as soon as the display is turned off, its frame fails, or its mod stops. Where two displays place the same instrument, the one registered later does.

  • hud.bounds(instrument) gives where an instrument last drew, in the window's pixels, as the layouts place it, and also while a display stands in for it, so that a display can draw its own in the game's instrument's place.

  • hud.power_ball(at, size, style) draws the power window's ball, turning with the player's power setting, with its top left corner at at and size in window pixels (nil for its size on the game's display), for a display that stands in for the power window. Unlike the window's, it doesn't shake when the ship is hit.

  • What the instruments show can be read in any view during a mission, for a display that stands in for one or one drawn outside the game:

    • hud.guns, the gun group, how the guns fire, their charge, whether the gunnery window shows how a pair fires, and the rounds left on the ships whose guns fire them; hud.missiles, the ring of missiles, each in its place round the ring, and the armed one; hud.target, the target the display shows and its subtarget; and hud.open_windows, with hud.window_state(instrument) for how far a window has opened and whether it's opening or closing.
    • hud.radar, the radar's range and reach, where its rings stand as they move between the ranges', and its contacts: each object it shows, where its dot stands from the radar's middle in the game's pixels, how far below the rings' plane it stands, and how it shows: the target, the ship whose line the radio's window names, a hostile, the nav point or another.
    • hud.target_display, what the target display shows of its target in either form, open or not: the form, the type's and the pilot's names, the range and the speed, the arcs of its shields and armour, the quadrants that flash as its armour is hit, the class of the subtarget's part (PartClass), its name and the share of its armour's bar lit, and the share of the hull's bar lit.
    • The readouts: hud.fuel, the seconds of afterburner fuel; hud.kills; and hud.countermeasures, with hud.countermeasures_lit, false while a mission's script flashes the readout dark.
    • hud.gauges, the targeting cluster: the speed and the speed the throttle asks for, as their figures show them; where the speed's and the throttle's markers stand on the left arc, from 0 to 1, and how bright the throttle's is (nil while it doesn't show); and how far up the right arc is lit, from 0 to 1, and whether it shows the Nova Cannon's charge.
    • hud.ship_status: how many of the shields' and the armour's five arcs show in each quadrant, the arcs for what SHIELD BALANCING has shifted fore and aft, and the quadrants that flash as the armour is hit.
    • hud.lights, the status lights that show, steady or flashing; hud.lights_lit, those lit, which leaves out a flashing light while it's dark; hud.charges, the ECM's, the cloak's and the spectral shields' charges, which their lights show as bars; hud.clock, the minutes and seconds the clock shows; hud.view_name, the name written at the top of the screen in the views that have one; and hud.caption, the launch's date as far as it has typed it.
    • The windows, whether or not they're open: hud.damage, how well the weapons, engines and shields still work, from 0 to 1; hud.power, the shields', weapons' and engines' shares of the power as the percentages the power window writes; hud.wingmen, the wing's ships with their numbers and the share of their armour bars lit; hud.objectives, the objectives the window can show, with their names, which is current and which it's showing; and hud.comms, the items of the radio's menu.
    • The text the display writes: hud.messages, the message lines, oldest first; hud.subtitle, the line DisplaySubTitle shows; hud.speaker_name, the name the radio's window writes over the speaker's face while their line plays; hud.key_prompt, the action WaitForKey waits for; and hud.jump_prompt, jump or warp while the prompt for what the mission has ready flashes.

    hud.instruments_shown says whether the game's instruments show this frame, which is in the view ahead from the cockpit. Each reading is worked out as its instrument works it out, when the scripts run, before the flight display draws; what the display keeps between frames, such as its target, how far its windows have opened and what flashes, is as it last drew it. Each is nil, or empty, outside a mission.

examples/mods/hud-layout draws a radar of its own in the game's radar's place, with the guns and missiles beside it.

The instruments' parts

A display can also place the parts of an instrument on their own, without standing in for it: the text each instrument writes, the face in the radio's window, and the power ball.

hud.register_display("comms", {
    parts = {
        -- The speaker's name, ending over the face's right edge.
        radio_speaker = { offset = vector.create(132, 0, 0), align = "right" },
        -- The face, half as large again.
        radio_face = { scale = 1.5 },
        -- The power window's title, in other words.
        power_title = { text = "POWER" },
        -- The target's pilot, left out.
        target_display_pilot = { hidden = true },
    },
    frame = function(seconds) end,
})
  • offset and scale move and scale a part as layout does an instrument, on top of its instrument's layout. A part grows from its own place: a text from the point its alignment lines it up on, and a picture from its top left corner.
  • align lines a text part up on its point another way: "left", "center" or "right".
  • text gives a text part other words, at most 64 characters, written wherever it writes. A part that writes several lines, such as messages_text, writes them on each.
  • hidden leaves a part out. Its instrument still works, and hud.part_bounds(part) gives where the part last drew as the displays place it, also while it's hidden, so that a display can draw its own in its place.
  • A picture takes no align or text. Where two displays place the same part, the one registered later does.
Part What it is
caption_text, caption_cursor The date the launch types out, and the cursor after it while it types
key_prompt_press, key_prompt_action, key_prompt_key, key_prompt_modifier, key_prompt_plus WaitForKey's prompt: PRESS, the action's name, the key's name on its cap or the joystick's button, and the modifier's name on its cap with the plus after it
view_name_text The view's name at the top of the screen
subtitle_text The line DisplaySubTitle shows
messages_text The message lines
fuel_figure, kills_figure, countermeasures_figure The readouts' figures
gauges_speed, gauges_throttle The targeting cluster's figures: the speed the ship makes, and the speed the throttle asks
target_markers_range The target's range by its brackets, and by the arrow toward a target off the screen
clock_text The clock's figures
radio_speaker The speaker's name over the face in the radio's window
radio_face The face of whoever speaks, in the radio's window (a picture)
gunnery_gun, gunnery_rounds The gunnery window's gun name, or FULL GUNS, and its rounds left
missiles_count, missiles_name The missile window's count and missile name
target_display_name, target_display_pilot, target_display_range, target_display_speed, target_display_subtarget The target displays' lines, in either form: the target's name, its pilot's, its range and speed, and the subtarget's name
damage_title, damage_names The damage window's title, and the systems' names
power_title, power_figures The power window's title, and its percentages
power_ball The power window's ball (a picture)
objectives_title, objectives_heading, objectives_name The objectives window's title, the objective's heading, and its name
comms_title, comms_numbers, comms_items The radio's menu, in either window that shows it: its title, the items' numbers, and the items
wing_status_title, wing_status_numbers The wing status window's title, and the wingmen's numbers

Screens

A player or menu script can register a screen: a panel that draws with ui and gets the keys while it's shown.

local ui = require("openreliant.ui")

local help = ui.register_screen("help", {
    frame = function(seconds)
        ui.rectangle(vector.create(20, 60, 0), vector.create(500, 160, 0), { alpha = 0.8 })
        ui.text(vector.create(30, 70, 0), "Escape closes this panel.")
    end,
    key = function(key, down)
        if key == "escape" and down then ui.show_screen(nil) end
    end,
})
ui.show_screen(help)
  • ui.show_screen(name) shows a screen, and ui.show_screen(nil) closes it. One shows at a time.
  • In flight, a shown screen draws over the flight display. The game's controls still get the keys.
  • A screen whose frame or key fails closes, and so does a screen whose scripts stop.
  • A menu script can also make a screen take the place of one of the front end's (Replacing a screen).

Camera views

A player script can register a camera view, which places the camera each frame:

local camera = require("openreliant.camera")
local util = require("openreliant.util")

local chase = camera.register_view("chase", {
    frame = function(ship, seconds)
        return {
            position = util.to_world(ship.position, ship.orientation, vector.create(0, -200, -1000)),
            orientation = ship.orientation,
        }
    end,
    letterbox = false,
})
camera.set_view(chase)          -- looks at the player's ship
camera.set_view("cockpit")      -- back to the cockpit
  • frame gets the object the view looks at and the seconds since the last frame, and returns the camera's position and orientation. The orientation's axes must be unit length, at right angles and right-handed.
  • set_view(view, object) switches to a view of object, or of the player's ship. It takes the game's views by name, such as "cockpit", "chase" or "target" (View), and the mods' by their qualified names, or within the mod by its own names (Qualified names). camera.view is the view that shows.
  • A mod's view is an outside view: it draws no cockpit, and letterbox adds bars above and below.
  • The game's camera keys and a mission's cutaways override a script's view, and a script can't change the view while the mission holds the camera.
  • If frame fails, or the object leaves the mission, the camera goes back to the cockpit.
  • One run of OpenReliant has room for about 200 views, displays and screens together, counting the ones registered before a reload. Registering more is an error.

Keys and actions

on_key_press(key) and on_key_release(key) hear the keys, by name, such as "f9" or "escape" (Key). input.key_down(key) says whether a key is held.

In flight, on_action(action) hears the controls bound to the game's actions, by name, such as "fire_lasers" (Action), and input.action_down(action) says whether they're held.

A menu script can register an action of its own, which the controls screen lists after the game's, for the player to bind:

-- action.luau, from examples/mods/custom-order: Shift F12 starts the mod's order.
local input = require("openreliant.input")
local core = require("openreliant.core")

local pulse = input.register_action("pulse", {
    label = "Custom order: throttle pulse",
    key = "f12",
    modifier = "shift",
})

return {
    engine_handlers = {
        on_action = function(action)
            if action == pulse then
                core.send_global_event("CustomOrderPulse", {})
            end
        end,
    },
}
  • register_action returns the action's qualified name, such as custom-order:pulse, which on_action passes. action_down takes it too, or within the mod the name without the prefix, such as "pulse". Registering the same name twice is an error, even in another case.
  • The definition needs a label, and can give a default key with a modifier ("none", "shift" or "control"), a joystick button and a gamepad_button. A default that the game or another mod already uses stays unbound.
  • The player rebinds, clears and resets the actions on the controls screen, as the game's. starlancer.ini keeps the bindings by the actions' qualified names.
  • A control held when the action registers, or held outside flight, must be released before it counts as pressed again.

Sound

openreliant.audio plays sounds, music and Betty's lines:

local audio = require("openreliant.audio")

audio.play_sound(3, 0.5)              -- the game's standard sound 3, at half volume
audio.play_music("New_Pensive.wav")   -- from the music folder, or a mod's file of that name
audio.say("countermeasures_low")      -- Betty's line
  • play_sound takes the number of one of the game's standard sounds, the menus' and the display's. A mod replaces a sound by replacing its file (Modding).
  • play_music plays a piece from the game's music folder, looping, in place of the music that plays.
  • say takes one of Betty's lines by name (BettyLine).

Debug drawing

openreliant.debug draws lines and text at points of the world, over the flight display, where the camera sees them. It's for working out what a script does:

local debug = require("openreliant.debug")
local self = require("openreliant.self")

return {
    engine_handlers = {
        on_frame = function()
            local target = self and self.last_attacker
            if target then
                debug.line(self.position, target.position, { color = vector.create(1, 0, 0) })
                debug.text(target.position, "attacker")
            end
        end,
    },
}

Menus, game modes and campaigns

A menu script can replace the front end's screens with its own, and a load or menu script can add game modes, which the main menu's GAME MODES lists. A campaign is a game mode that flies its missions in order and remembers how far the player got.

Replacing a screen

ui.replace_screen(screen, name) replaces one of the front end's screens, given by its name (FrontEndScreen), such as "main_menu", with the mod's registered screen name (Screens). While the front end shows that screen, it runs the mod's screen in its place: the front end draws the screen's background, the mod's screen draws over it and takes the keys, and the pointer is drawn on top. ui.pointer gives the pointer's place and whether its left button is down.

local ui = require("openreliant.ui")

local screen = ui.register_screen("main_menu", {
    frame = function()
        ui.text(vector.create(ui.width / 2, ui.height / 2, 0), "PRESS ENTER", { align = "center" })
    end,
    key = function(key, down)
        if down and key == "enter" then ui.go_to("pilot_roster") end
        if down and key == "escape" then ui.quit() end
    end,
})
ui.replace_screen("main_menu", screen)
  • To move on, the mod's screen asks the front end: ui.go_to(screen) goes to another of its screens (or to the mod's screen that replaces it), ui.start_game_mode(name) starts a game mode, and ui.quit() quits the game. If the front end can't show the screen asked for, nothing happens and the log says so.
  • ui.play_movie(name) plays a Bink movie from the game folder or a mod, such as "thread01.bik", on a cleared screen. Escape or the pointer's right button ends it.
  • ui.replace_screen(screen, nil) gives the screen back to the front end. If the mod's script stops, or its screen's callback fails, the front end shows its own screen again.
  • These functions are for menu scripts only.

examples/mods/main-menu replaces the main menu.

Game modes

core.register_game_mode adds a game mode. The main menu then shows a GAME MODES button, which opens a list of every mod's modes (Front end).

local core = require("openreliant.core")

core.register_game_mode({
    name = "arena",
    label = "ARENA",
    description = "Wave after wave in a Phoenix.",
    missions = { 29 },
    ship = "phoenix",
    loop = true,
})
  • missions are mission numbers, flown in order. Each is a standard .DTE file, the game's or a mod's, so a mode doesn't change the mission format. A mod brings a mission of its own as a file such as mission90.dte (How files are replaced). A mode has up to 64 missions.
  • ship is the ship the player flies, one of the game's or a mod's by its qualified name, such as "teapot:teapot". Without it, each mission's own ship is used.
  • Without loop, the mode goes back to the main menu after its last mission. With loop, it starts again from its first mission, until the player leaves a mission from the pause menu.
  • Leaving a mission from the pause menu always ends the mode.
  • Only load and menu scripts register modes, and only as OpenReliant starts. A mod that is off has no modes.
  • core.game_mode gives the qualified name of the mode that runs, such as "arena:arena", and nil otherwise. core.game_mode_mission gives the mission the mode is at: its number, its place in the mode from 1, and the count of the mode's missions.

Every script can read core.game_mode, so a mod's global and player scripts apply its rules only while its mode runs:

-- rules.luau, a global script: in the arena, the player's hits count double.
local core = require("openreliant.core")
local hooks = require("openreliant.hooks")

hooks.add("object_damage", function(e)
    if core.game_mode == "arena:arena" and e.attacker.is_player then
        e.value *= 2
    end
end)

A mode can also change the records for its own missions (The records). records names a script of the mod that runs as a load script before each of the mode's missions. It can read core.game_mode_mission to know which mission is next. When the mission ends, the records go back to what they were. The game's campaign, the other modes and the mode's own briefing screens never see the changes.

-- menu.luau: a campaign of two missions the mod brings, mission91.dte and mission92.dte.
core.register_game_mode({
    name = "prequel",
    label = "PREQUEL",
    missions = { 91, 92 },
    campaign = true,
    records = "prequel_records.luau",
})
-- prequel_records.luau: in the prequel, the Yakob Shuttle flies as a pirate fighter.
local records = require("openreliant.records")

local shuttle = records.ships.yakob_shuttle
shuttle.max_speed = 300
shuttle.shield_power = 14
shuttle.armor_class = 16
records.ship_types.yakob_shuttle.class = "fighter"
records.text[1104] = "PIRATE"

examples/mods/arena adds a game mode with rules and a HUD of its own, and examples/mods/interceptor, teapot and bananas each fly a mode in a mod's ship.

Campaigns

A game mode with campaign = true is a campaign:

  • It flies its missions in order. When a mission is lost or left from the pause menu, the restart screen offers to fly it again from its briefing or from its launch, or to go back to the main menu. A mission is lost when the player's ship is destroyed, the pilot is captured or sent home for shooting a friend, or the mission's script rates it a total failure.
  • The mission the player has reached is kept in the mod's global storage, in the section campaigns, under the mode's name without the mod's prefix (Storage). GAME MODES shows it, and the campaign carries on from it the next time. After the last mission, the campaign starts from its first again. A script can set the value, from 0 for the first mission, to move the campaign on or back: storage.global_section("campaigns").tour = 1.
  • A campaign can't loop.

Any game mode can name a briefing: the name of one of the mod's registered screens, which the front end shows before each of the mode's missions. The briefing reads core.game_mode_mission to know which mission is next, flies it with ui.launch_mission(), and ends the mode with ui.go_to("main_menu"). Without a briefing, the next mission starts at once.

local core = require("openreliant.core")
local ui = require("openreliant.ui")

ui.register_screen("briefing", {
    frame = function()
        local mission = core.game_mode_mission
        ui.text(vector.create(ui.width / 2, 100, 0), `MISSION {mission.place} OF {mission.count}`, { align = "center" })
    end,
    key = function(key, down)
        if down and key == "enter" then ui.launch_mission() end
        if down and key == "escape" then ui.go_to("main_menu") end
    end,
})

core.register_game_mode({
    name = "tour",
    label = "FIRST TOUR",
    missions = { 1, 2, 3 },
    campaign = true,
    briefing = "briefing",
})

A game mode can also brief its missions in the game's briefing room, as the StarLancer trial briefs its two: briefing_room = "reliant" or "yamato". Before each mission, after the mod's briefing screen where the mode has one, the player sees the room's door, then Enriquez at the room's screen playing the mission's hologram, then the loadout on the same ship, and last her last_word. The hologram is a Bink file, the game's or the mod's, and the last word a speech file, such as ms_speech\enrbr_tag01.ut; without one, her animation plays without a line. Give a mod's movie a name of its own, such as prequel_m01.bik, since a file in a mod replaces the game's file with the same name everywhere.

The loadout offers what a new pilot gets for the number the mission flies as. It keeps its choices apart from the campaign's, and starts fresh as the mode starts. A mode can list the ships its loadout offers instead, in its own order, with loadout_ships, such as { "grendel", "predator" } or ship type numbers. The loadout then starts on the first, and later on the ship the player chose last. Ships the player can't fly, such as capital ships, are left out.

A mode's missions fly with a wing of their own, which starts anew as the mode starts, so the campaign's wing loses nobody to them. In missions flown as the campaign's numbers, 1 to 28, the game gives the player's wingmen, Alpha 2 to 6, the wing's pilots in place of those the mission file names. wing_pilots seats other pilots there, each a pilot of the game's by its name, such as "viper", or its number, or one a mod adds by its qualified name; none keeps a place's pilot. A pilot it lists flies every mission, even after dying in one.

core.register_game_mode({
    name = "prequel",
    label = "PREQUEL",
    missions = {
        { number = 91, as = 1, hologram = "prequel_m01.bik", last_word = "ms_speech\\enrbr_tag01.ut" },
        { number = 92, as = 2, hologram = "prequel_m02.bik", last_word = "ms_speech\\enrbr_tag02.ut" },
    },
    campaign = true,
    briefing_room = "yamato",
    debriefing = true,
    -- As the trial does, the Ronin wing's leader, Tanaka, flies Alpha 6 in Viper's place.
    wing_pilots = { "none", "none", "none", "none", "ronin_leader" },
})

With debriefing = true, the ITAC debriefs each mission that goes on to the next, as the trial's ITAC does. It opens DEBRIEFINGS alone, with the debriefing of the number the mission flies as for its rating, and REPLAY MISSION, which flies the mission again from its briefing. The mode's records still stand then, so its records script can give the debriefing's text (records.itac_text). The ITAC keeps the mode's missions apart from the campaign's.

An ending names one of the mod's registered screens, which the front end shows after the mode's last mission. It leaves for the main menu with ui.go_to("main_menu"). Without one, the main menu follows at once.

The missions are flown as INSTANT ACTION flies its mission: without the game's rooms, medals or saved games (#641).

examples/mods/campaign is a short campaign of the game's first three missions, with a briefing, a movie and an ending.

Options

A mod can offer the player options, which the player sets on the mods screen: GAME OPTIONS, then MODS, then OPTIONS with the mod chosen. A load or menu script declares the mod's page as OpenReliant starts, with openreliant.options, and any script of the mod reads the values:

local options = require("openreliant.options")

options.register_page({
    title = "WINGMEN",
    options = {
        { label = "THE PANEL", kind = "heading" },
        { key = "show_panel", label = "SHOW PANEL", kind = "toggle", default = true },
        { label = "IN A FIGHT", kind = "heading" },
        { key = "pull_out_below", label = "PULL OUT BELOW", kind = "choice", default = 0.3,
          choices = { { value = 0.2, label = "20%" }, { value = 0.3, label = "30%" } } },
        { key = "rejoin_after", label = "REJOIN AFTER", kind = "number",
          min = 5, max = 60, step = 5, default = 20,
          description = "The seconds a wingman stays out of the fight." },
        { key = "panel_reach", label = "PANEL REACH", kind = "slider",
          min = 5000, max = 100000, step = 5000, default = 50000 },
        { key = "panel_title", label = "PANEL TITLE", kind = "text", default = "WINGMEN" },
    },
})

local rejoin_after = options.get("rejoin_after")
  • A "toggle" is a check box, with a boolean default. A "choice" steps through its choices, each a number or a string value with the label the screen shows, and its default is one of the values. A "number" steps from min to max by step, and its default is in the range. A "slider" is a number set by dragging a knob, for a wide range: it takes the same fields, and the knob stops on the steps. A "text" is a line the player types in a box, of up to 24 characters, with a string default: a click in the box starts typing, Enter or a click elsewhere keeps the line, and Escape puts it back. Each of these needs a key, which scripts read it by.
  • A "heading" has only a label, which the list writes in white over the options after it, to split a long page.
  • An option's description shows under the list while the pointer is on it.
  • A page has up to 64 options, a choice up to 32 choices, and a mod one page. A mistake in the page is an error in the script that declares it.
  • Only load and menu scripts declare a page, and only as OpenReliant starts: the pages are fixed before the front end shows. A mod that is off has no page until it's turned on and OpenReliant has restarted.
  • options.get(key) gives what the player set, or the default. A value that no longer suits the option, such as a choice the mod has since dropped, reads as the default.
  • options.set(key, value) sets one of the mod's own options, as the player does on the mods screen, such as from a key the mod binds. A number is held to its range, and a value that doesn't suit the option otherwise is an error.
  • The player sets options in the front end, before a game starts. A script that runs in a game reads them as it starts. A menu script can also hear a change at once, with the engine handler on_option_changed(key, value).
  • The values are kept in the mod's global storage, in a section of its own that storage.global_section doesn't open. A value that is the default isn't kept.
  • A key the player sets is an action the mod registers, which the controls screen binds (Keys and actions), rather than an option.

examples/mods/wingmen offers five options under two headings.

Post effects

A player script can draw a post effect over the whole frame: a GLSL fragment shader from its mod, registered with openreliant.postprocessing.

local post = require("openreliant.postprocessing")

post.register({
    name = "crt",
    shader = "crt.frag",
    stage = "after_hud",
    order = 0,
    parameters = { 0.3, 0.08, 0.6 },
})
post.set_parameters("crt", { 0.5, 0.08, 0.6 })
post.set_enabled("crt", false)
  • stage is "before_hud" (the default), which draws over the scene before the flight display and the menus are drawn, or "after_hud", which draws over everything.
  • Within a stage, effects draw by order, lowest first. Effects with the same order draw in the order they were registered. Each effect reads what the one before it drew.
  • parameters holds up to four numbers, which the shader reads. Those left out are 0.
  • enabled = false registers the effect turned off, for set_enabled to turn on later.
  • register returns the effect's qualified name, such as crt:crt. set_enabled and set_parameters take the effect's own name or the qualified one.
  • The shader compiles when the script registers it. A shader that doesn't compile is an error in the script, with the file and the line.
  • A shader can take in another file of its mod with #include "name.glsl" on a line of its own, such as helpers that several shaders share. An included file can include others, and each file is taken in once, so two files can include the same helpers. A mistake in an included file names that file and its line, and so does an #include of a file the mod doesn't have. A shader and the files it includes hold at most 7 #include lines.
  • An effect is removed when the script that registered it stops. If a script fails to load, the effects it registered are removed.
  • The mods can register at most 64 effects at once.
  • Compiled shaders are kept in the game folder's cache/shaders, so a shader compiles again only when it or OpenReliant's shader compiler changes. The folder can be deleted at any time.
  • In the developer mode, saving a folder mod's shader reloads its scripts, which compiles the shader again (Reloading).
  • MOD EFFECTS on the VIDEO tab, ModEffects in starlancer.ini and --no-mod-effects turn all the mods' shaders off: their post effects, and their surface and lighting functions. Choosing a GRAPHICS preset doesn't change it.

The shader is GLSL 450, and reads:

#version 450
// The frame as the effects before this one left it.
layout(set = 2, binding = 0) uniform sampler2D source;
// The frame before any effect.
layout(set = 2, binding = 1) uniform sampler2D frame_image;
layout(set = 3, binding = 0, std140) uniform Frame {
    vec4 size_time;   // x and y: the frame's size in pixels; z: the seconds passed
    vec4 parameters;  // the script's numbers
} frame;
layout(location = 0) in vec2 uv;      // 0 to 1 across and down the frame
layout(location = 0) out vec4 color;

void main() {
    color = texture(source, uv);
}

A shader file's name ends in .frag or .glsl. Like scripts, shader files belong to the mod and don't replace game files. examples/mods/crt draws an old curved monitor over the game: Shift F8 turns it on and off, and Shift F7 changes the scanlines.

Surface and lighting functions

A player script can change how surfaces are lit, with GLSL functions from its mod, registered with openreliant.shaders. OpenReliant compiles each into a variant of its own surface shader.

  • A surface function changes a pixel's colour, normal, roughness, metalness, glow and alpha before it is lit. It applies to the textures it lists, by the names the models use, such as Pred_cp01, in any case. With everywhere = true, it also applies to every lit surface in the scene that has no surface function of its own. object:set_surface(name, parameters) gives one object's whole model a surface function, which wins over the functions on its textures, and object:set_surface(nil) takes it away.
  • A lighting function changes how much of each light reaches a pixel, on every surface lit for each pixel. One draws at a time: the enabled one registered last.
local shaders = require("openreliant.shaders")
local self = require("openreliant.self")

shaders.register_lighting({ name = "bands", shader = "bands.glsl", parameters = { 3 } })
shaders.register_surface({
    name = "ink",
    shader = "ink.glsl",
    textures = { "Pred_cp01" },
    everywhere = false,
    parameters = { 3, 8 },
})
shaders.set_enabled("bands", false)

return {
    engine_handlers = {
        on_mission_start = function()
            -- The player's ship, which is there once a mission has started.
            self:set_surface("ink", { 2, 8 })
        end,
    },
}

The file holds the function alone, without #version, and can define helpers before it, or take them in from another file of its mod with #include, as a post effect's shader can. A surface function is called surface, and a lighting function lighting:

// The pixel, which the function reads and sets.
struct Surface {
    vec3 color;      // the texture's colour, in its sRGB encoding
    float alpha;
    vec3 normal;     // in camera space, unit length; zero for an unlit pixel
    float roughness; // 1 and 0 where the texture has no material maps
    float metallic;
    vec3 glow;       // emitted light, added after lighting; starts as the emissive map's
    vec3 sunlight;   // how much of the sun lights it, in each channel; starts as OpenReliant's own
    vec3 scattered;  // sunlight that haze in front of it scatters toward the eye; starts as none
    vec2 uv;         // read only: the texture coordinates
    vec3 position;   // read only: its position in camera space
    vec3 toEye;      // read only: the direction toward the eye
    vec2 frameSize;  // read only: the frame's width and height in pixels
    vec3 toSun;      // read only: the direction toward the sun, unit length; zero where it doesn't reach
    vec3 local;      // read only: its position in its object's own frame, as the model holds it
    float bloom;     // how much of its light blooms, from 0 to 1; starts as the registration's `bloom`
};

void surface(inout Surface s, vec4 parameters, float time) {
    s.glow = vec3(0.0, 0.2, 0.0) * (0.5 + 0.5 * sin(time));
}

// cosine: from 0 to 1, between the pixel's normal and the light. Returns how much of the light
// reaches the pixel; `return cosine;` changes nothing.
float lighting(float cosine, vec4 parameters) {
    return ceil(cosine * parameters.x) / parameters.x;
}
  • time is the seconds passed, and parameters the script's numbers. Those left out are 0.
  • s.frameSize is the size of the frame the pixel is drawn into, which gl_FragCoord counts in. An effect in screen space, such as scan lines, divides by it to look the same at any resolution: gl_FragCoord.y / s.frameSize.y runs from 0 to 1 up the frame.
  • s.toSun points toward the sun: the light that casts shadows, which is the only light that reaches a planet. It needs PER-PIXEL LIGHTING, which gives each pixel its normal, and is zero without it. s.sunlight starts as how much of it OpenReliant lets through, the cosine between the normal and toSun, which a planet's atmosphere carries a little way past the terminator. Set it to light the pixel by a law of your own, such as one measured for a planet, or to shade it, as a ring shades its planet; the shadows of other objects still fall on it. Where the function leaves it as it was, the sun lights the pixel at the normal the function leaves.
  • s.scattered is sunlight that haze or air in front of the pixel scatters toward the eye, such as a planet's haze over its disk. Each channel is a share of the light that a white surface facing the sun sends back. The sun lights it with its colour and strength, and it is added to the pixel's light in linear light. s.glow is added to the encoded colour instead, which over a lit surface gives more light than adding the light itself.
  • s.local is where the pixel stands in its object's own frame, in the units and axes of the object's model. objectDirection(v) turns a direction such as s.toSun, s.normal or s.toEye into that frame, and objectPoint(p) a point such as s.position. With them, a function can work out shapes the model holds, such as where a planet's ring shades the planet.
  • s.bloom is how much of the pixel's light blooms, from 0 to 1. It starts as the function's bloom, 1 unless the registration gives one. Lower it for a glow or a haze that shouldn't glare like a light. It counts where the surface is drawn solid or added over what lies behind it, not where it blends by its alpha, and only while BLOOM is on. A bright thing behind the surface still blooms in full.
  • The functions can call the shader's own helpers, such as encoded and decoded, which turn a colour into and out of linear light.
  • If the function sets roughness or metalness on a surface without material maps, the surface is lit as a material (Material maps).
  • Alpha shows on surfaces the game blends, such as glass and effects. A function registered with see_through = true also makes the solid surfaces it draws on objects and textures blend by the alpha it sets, sorted with the game's other blended draws. They still write depth, as a solid model does, so that of two models that cut into each other the nearer hides the other. Set writes_depth = false for something with no solid shape, such as a glow or a cloud. A function that applies everywhere leaves the surfaces solid.
  • A lighting function only changes surfaces lit for each pixel, with PER-PIXEL LIGHTING. It changes the light falling on them, not their highlights.
  • Give helper functions names unique to your mod. The lighting function and a surface function can come from different mods, and they are compiled into one shader. If they don't compile together, or the GPU can't draw with them, the log says so, and that surface function's surfaces draw without it.
  • Each registration compiles the function on its own, and a mistake in it is an error in the script, with the file and the line. Compiled variants are kept in the shader cache.
  • enabled = false registers a function turned off.
  • The rules for names, removal, reloading and MOD EFFECTS are those of post effects. The mods can register at most 64 functions at once.

examples/mods/cel-shading draws the ships as a cartoon: a lighting function makes each light fall in flat bands, and a surface function on every lit surface draws a dark line round the outlines. Shift F6 turns it on and off, and Shift F5 changes the number of bands.

A planet's atmosphere

A load script can have one of its mod's surface functions draw a planet's atmosphere in place of the band, by the function's own name or the qualified one, with up to four numbers it reads (Planets):

local records = require("openreliant.records")

for n = 1, #records.planets do
    local planet = records.planets[n]
    if planet.type == "titan_hi" then
        planet.atmosphere_surface = "haze"
        planet.atmosphere_parameters = { 0.02 }
        planet.atmosphere_inner = 0.95
        planet.atmosphere_outer = 1.15
    end
end

A player script registers the function, as it registers any surface function, and each mission looks it up by its name as it makes the planet. The function draws on a ring that faces the camera through the middle of the planet's sphere, from atmosphere_inner to atmosphere_outer times the sphere's radius as the camera sees them, and its light is added to what lies behind it, such as the sun's glow, in linear light, as light adds. The function works in the planet's own frame, as the planet's model holds it, with the sphere's middle as the origin and the sphere's radius as the unit, so it can trace the line of sight through each pixel:

void surface(inout Surface s, vec4 parameters, float time) {
    // The camera, the line of sight through the pixel and the sun, in the planet's radii.
    vec3 eye = objectPoint(vec3(0.0));
    vec3 ray = normalize(s.local - eye);
    vec3 sun = objectDirection(s.toSun);
    // Where the line passes nearest the middle, and how high above the sphere that is.
    vec3 nearest = eye - dot(eye, ray) * ray;
    float height = length(nearest) - 1.0;
    // A haze that thins with height, on the side toward the sun. It ends a pixel inside the
    // planet's edge rather than at it, so that the edge's pixels, which show the planet and the sky
    // around it, get it too.
    float lit = max(dot(normalize(nearest), sun), 0.0);
    float edge = clamp(1.0 + height / fwidth(height), 0.0, 1.0);
    s.color = vec3(0.0);
    s.sunlight = vec3(0.0);
    s.scattered = vec3(0.3, 0.5, 1.0) * exp(-max(height, 0.0) / parameters.x) * lit * edge;
}
  • For a flattened planet, the unit is its equatorial radius. Its north pole is the frame's -Y, as in the planet's model, so a function can draw its haze round the flattened shape.
  • The sphere is half the widest span of the model's first part, so for a model that holds rings, such as Saturn's, the unit is the rings' outer edge, and atmosphere_inner and atmosphere_outer are shares of that.
  • A bright haze glares with the bloom like any bright light. Register its function with a lower bloom to keep more of it as it is.
  • The ring is drawn behind the planet, which hides it within the planet's edge. A sharp cut at the edge leaves the edge's pixels without the haze, as the function runs once for each pixel, at its middle.
  • Haze in front of the planet's disk belongs in a surface function on the planet's texture, which works out the same line of sight from s.local and sets s.scattered as the ring's function does. The sun lights both alike, so they meet at the edge.
  • The band's colour isn't used where a function draws the atmosphere. A name no mod registers leaves the planet its band, and the log says so.
  • The ring isn't drawn while the camera stands within its outer edge.

Replacing OpenReliant's shaders

A mod can replace one of OpenReliant's shaders with its own, without a script: a file at the top level of the mod with the same name as one of OpenReliant's shaders.

File What it draws
device.glsl The scene, the flight display and the menus
bloom.glsl The bloom, the frame's finish and the gamma ramp, and the vertex stage of post effects
shadow.glsl The shadow maps

Start from OpenReliant's own, in src/platform/shaders of the version you play. A replacement is at your mod's risk: OpenReliant's shaders change between versions, and a replacement made for one can stop fitting the next.

  • Each file holds a vertex stage and a fragment stage, which it picks with #ifdef VERTEX and #ifdef FRAGMENT, as OpenReliant's do. #include "color.glsl" takes the mod's own color.glsl, or OpenReliant's where it has none. Other includes are errors.
  • The last mod in the load order that has the file replaces OpenReliant's.
  • Both stages compile as OpenReliant starts, through the shader cache, and are checked against OpenReliant's own. A replacement may only use textures and uniform blocks that OpenReliant's shader binds, with the same types and no larger. It may only read inputs that OpenReliant's shader reads, and it must write every output that OpenReliant's shader writes. A fragment stage writes no others, and the fragment stage reads only what the vertex stage writes.
  • A replacement that doesn't compile or doesn't fit is left out, and OpenReliant uses its own shader instead. The log says which file, which stage, and why, with the line of a compile error.
  • A replaced device.glsl is also the shader the mods' surface and lighting functions are compiled into. Keep its hooks (MOD_SURFACE, MOD_LIGHTING) and the line // mod_functions for them; without that line the functions draw nothing, and the log says so.
  • Replacements are chosen as OpenReliant starts, while MOD EFFECTS is on. Changing MOD EFFECTS takes effect for them at the next start.

Files

openreliant.vfs reads files. A file comes as a string of its bytes.

  • vfs.read(name) reads a file the way the game does. It looks in three places in turn: the latest mod's copy, the file in the game folder, and the game's resource.hog.
    • A mod's file is found by its name alone, so missions\mission1.dte finds a mod's mission1.dte.
    • The game folder's file is found by its path from the game folder, in any case, such as missions\mission1.dte or music\theme.wav. A name without a folder, such as palette.tga, is looked up in the game folder itself, then in the archive.
  • vfs.read_mod(name) reads a file of the calling mod.
  • Both return nil if there is no such file, or if the name is a folder. vfs.exists(name) says whether vfs.read would find it.
  • A script can read a file of up to what its mod's scripts have left of their memory. A bigger file is an error.

The console

The developer mode turns on the tools for writing scripts: the console, and reloading. Turn it on with DeveloperMode=1 in the [OpenReliant] section of starlancer.ini, or with --developer-mode (Configuration); it's off by default.

F11 then brings up the console, in the menus, the Reliant's rooms and the briefing, and in flight, where any mod has scripts. It pauses the mission, and F11, Escape or CLOSE takes it away again. It shows what the scripts print and their errors, and runs the lines typed into it:

Command What it does
help Lists the commands
help <name> What a package (storage or openreliant.storage), an engine handler (on_update) or a hook (object_damage) is
mods Lists the mods, and their scripts that run
reload Reads the folder mods' scripts again, and starts them again from where they were
clear Empties the console
global <mod>, player <mod>, menu <mod> Runs Luau with the mod's global, player or menu scripts, until exit
> global wingmen
wingmen global> player = require("openreliant.world").player
wingmen global> player.hull, player.speed
0.8    120
wingmen global> require("openreliant.interfaces").Wingmen.pulled_out()
2
wingmen global> exit
  • In Luau, a line runs as an expression where it is one, and its values are shown; otherwise it runs as statements. Variables you set stay for the next line, but they are separate from the scripts' globals. require gives the mod's modules as its scripts have them, and the packages its scripts can use.
  • Enter or RUN runs the line, Up and Down bring back the lines typed before, and Page Up, Page Down, the mouse wheel and the arrows scroll the output.
  • Any other line goes to the player and menu scripts' on_console_command(text), so that a mod can add commands of its own.

Reloading

In the developer mode, a folder mod's scripts reload as soon as one of them or one of its shaders is saved, as reload reloads them:

  • Global and player scripts start again from their state, as a saved game would keep it (Saved games): on_save runs, and the new scripts get on_load.
  • Partway through a mission, its mission scripts start again, and so do the scripts on each of its objects, with on_init and then on_added, but no on_mission_start or on_object_added.
  • Menu scripts start again with on_init.
  • What the scripts registered (views, displays, screens, actions, orders, effects) goes away and is registered again as they start, so a changed shader compiles and draws (Post effects).
  • Load scripts and mod.ini are read only as OpenReliant starts, so they need a restart. So does a file added to a folder mod.

Editors

luau-lsp, the Luau language server, gives editors such as VS Code completion and type checks:

  1. Install its extension, and set its platform to Standard (luau-lsp.platform.type).

  2. Add openreliant.d.luau to its definition files (luau-lsp.types.definitionFiles). It comes with each release, and is in docs/guide.

  3. Give each package's variable its type:

    local hooks: Hooks = require("openreliant.hooks")

The editor then knows every hook with the fields of its e, and the fields of objects and records. It may report that it can't find the packages themselves, which OpenReliant provides.

Limits

Scripts run in a sandbox: they can't use the network or run programs, and the only files they can read are the game's and the mods' (Files). Luau's getfenv and setfenv aren't there, so no mod can reach another's globals, and getmetatable on a handle or a package gives "The metatable is locked". An error in a script never stops the game: it's logged with the file and the line, and the game carries on.

What Limit
A call into a script 1 second in a load script, and 100 milliseconds in any other
A mod's scripts' memory 64 MiB
A file a script reads What the mod's scripts have left of that memory (Files)
A mod's storage Just under 15 MiB in each place, the game's and the global (Storage)
The state kept with a saved game 256 MiB, all mods together. The mods that keep the most are left out until the rest fits (Saved games)
Drawing each frame 4096 things and 64 KiB of text over the flight display (hud and debug together), and as much over the menus (ui). What's past that isn't drawn
The pictures and fonts scripts draw 128 files and 128 MiB at once, all mods together, the pictures drawn longest ago making room for new ones (Pictures, shapes and fonts)
Camera views, HUD displays and screens About 200 together in one run of OpenReliant, counting the ones registered before a reload (Camera views)
Post effects 64 at once, all mods together (Post effects)
Surface and lighting functions 64 at once, all mods together (Surface and lighting functions)
A mod's options One page of up to 64 options, a choice of up to 32 choices, and a text of up to 24 characters (Options)
A game mode's missions 64 (Game modes)
An object's parts and attachment points object:parts() lists up to 256 parts, and object:attachments() up to 512 attachment points. The rest are left out (A ship's parts)

When something goes wrong

In the log What to check
skipping the mod ...: it needs OpenReliant 0.7.0 OpenReliant is older than the mod's OpenReliant=
No started line for the script mod.ini lists it under [Scripts], with the file's exact name
there's no hook named '...' The hook's name (the reference)
... has no field '...' or expected a number, got string The field's name and type
e can only be used while its handler runs Keep values from e in variables, not e itself
... is an event, whose fields can't be changed Events can only be read
... concerns no object to filter by That hook only takes a function as its filter
the object is no longer in the mission object:is_valid() before using a handle kept from earlier
script timed out A loop that doesn't end, or does too much at once. A handler that runs out of time three times in a row is removed
storage: the mod's ... sections would take more than The mod keeps too much: take fields out of its sections
too much data to keep A value that holds the same tables over and over, or too much data at once
... is not available to load scripts Load scripts can't use that package
object scripts can't change this object's ... An object's scripts can only change their own object
only plain data can be passed on An event's or a timer's data holds a function or something else that isn't plain data
only global scripts can add scripts Start object scripts from a global script, or list them in mod.ini
hud is only drawn while it's shown Check hud.shown or ui.shown before drawing or reading its size, and draw in on_frame
only plain data can be kept What on_save returns, or a value stored in a section, holds a function or something else that isn't plain data
a timer names ..., which no script registered async.register_timer runs as the script starts, before a timer can fire
player scripts can only read a game section Change game sections from a global or object script
orders.register requires a global script Register orders from a global or mission script

Compatibility

From OpenReliant 0.8 on, what scripts see stays stable, so that a mod keeps working as OpenReliant is updated. That is everything the reference lists, and what this page describes of the mods' shaders:

  • the packages, with their functions, fields and types;
  • the engine handlers, and what they're passed;
  • the hooks, their fields and their results;
  • the fields and methods of objects, missiles, turrets and the records;
  • the names of values, such as "cockpit" for a view or "laser_cannon" for a gun;
  • the manifest's keys and the kinds of scripts;
  • what post effects and surface and lighting functions read and write.

A replacement for one of OpenReliant's own shaders isn't covered (Replacing OpenReliant's shaders).

New versions add to all of this: packages, functions, fields, hooks, handlers and values. A mod that uses something new sets OpenReliant= in its manifest to the version that added it, so that an older OpenReliant skips the mod and says so, instead of running scripts that fail. A script can also read core.version and use a feature only where it's there.

A script that reads a value from a list, such as an object's type, can meet one it doesn't know: one a mod adds, or one a later version adds. A value without a name comes as its number, and a later version can give it a name.

When something has to change, it's deprecated first: the old name keeps working next to the new one until at least the next minor version, and the reference marks it and names what replaces it. The release notes list each deprecation and each removal.