FS2 Open Lua Scripting
This is an updated tutorial intended to give an introduction and overview into using Lua for scripting in FSO. It is split into three sections. Scripting with FSO, examples for common pitfalls or special situations, and best practices (both for Lua in general and in conjunction with FSO). It is written as of January 2023, for FSO 23.0.0, and some scripting functions or names used may have changed since then. If you spot something that does not work or exist any more, please let us know!
Contents
Requirements and Resources
One of the most important resources for scripting in FSO is the scripting documentation provided by FSO. To access it, check the FSO launch flag "Output scripting to scripting.html" under "Dev Tool" in Knossos (or pass -output_scripting with the launcher of your choice) and launch the mod. Note that the mod has to actually launch. If FSO throws an error on startup, it'll not generate scripting.html. This will generate a file called scripting.html. It will probably be located in the same folder where your retail FS2 root.vp is located. If you open the file in your browser, you will see a long list of various different things. It'll be confusing now, but don't worry, we'll get to it in time. Furthermore, there exists a public repository of available scripts for FSO. It may be worth checking what is already available, or how certain problems have been solved by other people if you are stuck. Also, if you have completed and tested a script, you are welcome to add it to the scripting repository.
Lua Basics
For a more professional, long term look at Lua I recommend having a look at Programming in Lua, but it certainly isn’t a required read to do scripting. For a slightly shorter text, see the following:
Lua Basics
So, in order to be able to script with Lua, the first question obviously has to be "how does one even Lua?". At its very basic steps, programming, in most languages, is a combination of calculating things, and making decisions from it. And Lua is no different from that. So to start off, let’s learn to calculate a simple addition and to save that. Now, the calculation part is pretty straightforward.
1 + 1
That is enough to tell Lua to calculate 1 + 1. But we don’t save it, so it’s not very useful to us.
calculation = 1 + 1
But that is also not too hard. This has now created something called a variable. If we later say something like calculation + 1 it will yield 3, because calculation is now 2. This variable we have now called "calculation", but any name consisting of letters and underscores are allowed. Other special characters, especially spaces, must not be part of a variable name. Something you should remember is that in Lua, variables can be local and global. What we did above is make a global variable. That means that it can be accessed from everywhere, including other scripts. But we could have also written the following
local calculation = 1 + 1
That would have made calculation a local variable. The difference is, that a local variable is only available where you created it. It cannot be accessed by another script. And if you created the variable for example in a loop, or in an if (we will learn what these are shortly), then you cannot access the variable after this loop or if as well. As a rule of thumb, try to make something a local variable if possible. Now, variables are called variable for a reason: You can also change them after you have created them:
calculation = calculation + 1
This increases the calculation variable by one. Unpacking what this line of code says one by one, we can also describe it as follows: Set the value of calculation to the what the value of calculation was plus one. Variables can store more than numbers, however. Everything you can do stuff with in Lua can be made into a variable. Some examples below:
local number = 1 local number_with_decimal_point = 1.5 local string = "Can be any text" local boolean = true --true or false, used for logical operations
But what do we do now with these variables? One of the most important things in programming is having our program make decisions. In Lua, one way of doing this is an if statement. The way it is programmed is almost like you would phrase a conditional sentence in English: If some condition is true, then do something, else do something different. Let’s say we want to add 1 to calculation if it is smaller than 2, and remove 1 otherwise. In Lua, it looks like this:
if calculation < 2 then calculation = calculation + 1 else calculation = calculation - 1 end
Basically the same, except that after the thing to do when the condition is not fulfilled must be an end. The condition can be anything really, from comparing if two things are the same (using ==, with two equals signs), or if something is smaller as something else like in the example. You can even combine multiple conditions using and or or if it is needed.
Similarly to if, there are loops to repeat things. If you write while instead of if, Lua will repeat the content of the while loop as long as the condition is true.
a = 5 b = 5 result = 0 while a > 0 do a = a - 1 result = result + b end
This is a very simple multiplication program that uses a while loop and basic arithmetics to calculate the product of a and b and saves it in result.
Often, you will end up in a situation where you want a variable to be global. But if you have many variables, at some point it becomes hard to keep track what is what. One tool to help you organize your variables are tables. These can serve as a container to keep multiple variables together. The following example shows how you can create a table and store a variable inside it:
table1 = { variable1 = 1 } table2 = { variable1 = 2, variable2 = 3 } table1.variable2 = 4 --Both tables now have entries called variable1 and variable2, both with different values --Access for reading works the same as for writing: table1.variable1 = table1.variable2 + table2.variable1
But that is not the only think tables can do. They can also help you to keep a list of values, even if you don't know how many values you will have. We can make a table that is basically a list, and then do something for all elements in the list.
list = {} --Now the list is still empty table.insert(list, 5) table.insert(list, 2) --Now we have two elements in our list, 5 and 2 for i, value in ipairs(list) do --This piece of code will be called once for each element in the list, no matter how many elements are in the list. --i is the number of the element in the list, and value is the value of the element --In this example, this piece of code will be called with i = 1 and value = 5 as well as with i = 2 and value = 2 end
There is one more helpful use of tables. You can also use them to store values for a certain name or index. Consider the use case that we want to store the balance of multiple people. For this, we could use the following:
accounts = {} --Now the list is still empty accounts["John"] = 5 accounts["Frank"] = 20 --Now the list has two elements, one stored for the name John and one for Frank. --We can also access them again: local johnsMoney = accounts["John"] --using a loop for all elements works too, but a bit differently: for name, value in pairs(list) do --Name will be the name, and value the value stored for this name end
Finally, we need to have a look at one last tool: Functions. In many cases, when you write Lua code, there will be parts of your code that you want to reuse somewhere else. But instead of copying the code, we can make it a function. Let us consider the multiplication example from before. Instead of copying that to everywhere we would need it, we can convert it to a function.
function multiply(a, b) result = 0 while a > 0 do a = a - 1 result = result + b end return result end
As you can see, the function consist of three main parts. First, the function declaration. After function, you write the name you want to use for the function, followed by its parameters in parenthesis. This list of parameters can be as long as you want, including having no parameters. The names you use for the parameters are then available to use in your functions like a variable would be. The second part is the actual code of the function. This code can contain the word return. Return means that the function will be quit, and if there is a value or a variable named after return, it will be returned to the function caller as the reuslt. Finally, the function is terminated with an end To use the function, simply call its name and fill in any arguments you may have added:
local multiplicationResult = multiply(2, 4) --multiplicationResult will be 8
For organization, functions can also be part of tables. For this, just put a colon between the table name and the function name:
table = {} function table:functionInTable() --Do something end
One special thing when using functions in tables is that the function has access to other values in the table by using the name self. It can be used like any other variable, and will be the table that contains the function. See the following example:
table = { test = 5 } function table:functionInTable() return self.test --This will return 5 end
Interactions with FSO
Knowing Lua we can now consider how we can use that to make FSO do things.
Running Code / Conditional Hooks
Now, we know how to write very basic code. But how do we actually run it? For this, FSO uses conditional hooks, specified in the scripting table (*-sct.tbm). At the basic level, conditional hooks are made up of three parts, action, condition and code.
- First, the action: An action is effectively an event in FSO at which we can run our custom code. The very basic example is On Game Init. This action is run once when FSO starts up, and can be used by us to run scripts that need to happen during this start up. A list of all available actions and when they trigger can be found in the scripting.html, reasonably far up, under the section Conditional Hooks and then Actions.
- Second, one or more conditions: The condition is a tool to have more fine-grain control over when exactly hooks are run. Imagine you want to script a special afterburner effect using the On Afterburner Engage action. But since this new effect should only apply to one ship class, we don't want our script to run for other ship classes. In this case, we could use the Ship class condition to limit the hook to a specific ship class. Similarly, since most scripts should run just in FSO and not in FRED, setting the Application condition to FS2_Open is a good idea in most cases as well. A list of conditions that can be used for all hooks is listed in scripting.html under Conditional Hooks and then Conditions, with further explanation here. Some conditions are only available for some specific actions, and may mean slightly different things for different actions. These are documented for each action individually in scripting.html.
- Third, scripting code that is run. This can be specified in two different styles. Either, just write a block of Lua code in between two brackets [ like this ], or specify a file in the scripts folder that contains the code in double brackets [[sample.lua]].
When specifying these conditional hooks in the scripting table, you first specify one or more conditions. Then you can specify one or more pairs of an action, and the code to be run. Until you specify the next set of conditions, all actions will have the last condition.
Now we can use this to run some code:
$Application: FS2_Open $On Game Init: [ --This is now Lua code that is run. local variable = 1 + 1 ]
One thing to note is that all hooks share the same global space. This effectively means that all Lua functions we define and all variables we write are shared among all hooks. This is why it is a very common pattern to define the bulk of any script's code in a function in the On Game Init action, and then just call these functions from the specific conditional hooks for a cleaner script style.
Making FSO do Stuff / Library Calls and Variables
In order to actually have any useful effect through our scripts, we need to be able to both tell FSO to do certain things, as well as be able to check on the state of certain gameplay elements.
In order to know what is possible, we need to reference scripting.html. Below the section listing the hooks is a listing of libraries. These libraries are (usually) the first point of interfacing with FSO. You can click the library names to jump to the point in scripting.html where they are documented. Searching through these functions and variables will likely take a significant amount of time during scripting, so it’s best to get familiar with how these functions are documented.
For a very basic script, we will just want to output some message to the screen to make sure we were able to correctly run code. Since that is a very basic operation, it’s located in the base library. We’ll use the warning function, so that we can see the message when using a debug build. The documentation tells us that the function takes a message as a string, and will return nothing.
$Application: FS2_Open $On Game Init: [ ba.warning("The script successfully ran!") ]
When running this in a debug build, we can now see our message at startup!
Some things are exposed to Lua not as functions, but as variables. See for example XAxisInverted in the controls (io) library. You can access io.XAxisInverted like any other boolean with Lua. Reading this boolean will give you the current state, and writing to it will change it. Some of these values, notably those that are loaded from tables, cannot be written to and changed. In that case the documentation will be missing the = <typename> after the variable name.
Many of the values you will access from FSO will not be standard Lua types, but special FSO types, like for example a ship. For example, mn.Ships[1] will return the first ship in a mission, with the ship type. To learn what the ship type is, you can click it in scripting.html to see the documentation. We can see that it inherits object (which means that you can use any ship the same way you can also use an object), and that it has a number of properties and functions, just as the libraries had. One thing that is important, is that if you call functions on FSO types, you must use : before the function name, while you need to use . for functions directly within a library. Let's say that ship were an object of the type ship. We can use the following code to clear its orders and call it a new name:
ship:clearOrders() ship.Name = "Renamed Ship"
Custom SEXPs
In a lot of cases, we don’t actually want to define our code in hooks. Often we want much more fine grain control when things are run, depending on events in FRED and so on. For this, we can make custom SEXPs that call scripting code.
To define the SEXP, first we need an entry in a *-sexp.tbm. See this page for details on how the table is formatted. For our very simple example, we will use this as the SEXP table:
$Operator: example-script-sexp $Category: Change $Subcategory: Scripted $Minimum Arguments: 1 $Maximum Arguments: 1 $Description: Prints out the string passed. $Parameter: +Description: The string that is printed +Type: string
This table basically means that we now have a new SEXP called example-script-sexp which takes a single string as an argument. To actually run scripting code, we now need to register a function with the SEXP. This is done by assigning mn.LuaSEXPs[<SEXP name>] a function that takes the same parameters as specified in the SEXP table. For this example, this would be:
mn.LuaSEXPs["example-script-sexp"] = function(message) --message is a string ba.warning(message) end
Now, if we call the SEXP from an event in FRED, we should see a warning pop up, with the message that we specified in the event.
Examples and Common Situations / Issues
FSO scripting has a few peculiarities that you should know about if you want to script certain things. This chapter aims to highlight most of them. But first, let us build one complete example.
A full example
The goal for this small example is to make a weapon that saps afterburner energy from a target it hits. For this, I assume that you know how to make a custom weapon in the weapons table, which I assume we call "ABDrain" for the purposes of this.
Since this is something that should happen on a trigger, and not from an event, it’s more suited for a conditional hook than for a custom SEXP. Looking at scripting.html, a suited action would seem to be On Weapon Collision, since that triggers every time that a weapon collides with something. But we don’t want our script to be draining the afterburner every time any weapon collides with something, but only when our new weapon does so. So let us add the Weapon class: ABDrain condition. In addition, since only ships have afterburners, we need to specify the condition Object type: Ship. All in all, these conditions now result in this hook running every time our weapon collides with (aka hits) a ship. Now we need to write the code to be executed. For this, we first need to consider what our weapon should actually do. Let's define it as "drains a third of the ship’s maximum afterburner fuel". The steps to then actually perform this action are thus: Find out what a third of the ships maximum afterburner actually is, subtract that from the current afterburner level, but don’t decrease the afterburner below 0 (since after all, we cannot have negative afterburner) Checking scripting.html, we find that the conditional hook will provide the hook variable Ship. We also find that its type, ship, has a variable called AfterburnerFuelMax. That means a third of the ship’s afterburner fuel can be calculated with the following:
local fuel_third = hv.Ship.AfterburnerFuelMax / 3.0
The next step is to subtract it from the current fuel that the ship has. In scripting.h, we see that next to AfterburnerFuelMax there is the variable AfterburnerFuelLeft:
local fuel_new = hv.Ship.AfterburnerFuelLeft - fuel_third
Finally, we want to set AfterburnerFuelLeft to the result of that, but not lower than 0:
hv.Ship.AfterburnerFuelLeft = math.max(fuel_new, 0)
Putting it all together and in the correct syntax for a scripting table then gives:
$Weapon class: ABDrain $Object type: Ship $On Weapon Collision: [ local fuel_third = hv.Ship.AfterburnerFuelMax / 3.0 local fuel_new = hv.Ship.AfterburnerFuelLeft - fuel_third hv.Ship.AfterburnerFuelLeft = math.max(fuel_new, 0) ]
While this script would work, it’s not very pretty or well optimized. Let’s clean it up a bit. First, we index into hv.Ship three separate times. That means it’s probably worth saving as a local variable. Then, we don’t actually need to save fuel_third and fuel_new in variables. Since we use them only once, there is no harm in just copying the code to where it’s used. That gives us the following script:
$Weapon class: ABDrain $Object type: Ship $On Weapon Collision: [ local ship = hv.Ship ship.AfterburnerFuelLeft = math.max(ship.AfterburnerFuelLeft - ship.AfterburnerFuelMax / 3.0, 0) ]
Common Situations with Special Handling Required
This section details situations where certain FSO-specific tricks are required or very helpful to complete a certain task.
+Override: Make FSO ignore things
As you may have noticed, the hooks in scripting.html specify whether they are overridable or not. In this context, overridable means that we can tell FSO to pretend that whatever caused the action to activate didn’t actually occur. In the example with the afterburner weapon, an override would cause the weapon to not actually collide with the ship it hit, just passing through it. To override a hook, after your normal script block, add +Override:, and then add another block of code. This block of code should return a boolean. If it returns true, the hook will be overridden. If it returns false, FSO proceeds as normal.
Repeating SEXP Arguments
The SEXP table can contain the $Repeat tag. It implies that all arguments following the tag can be repeated in FRED up to the argument maximum. If that is the case, special care needs to be taken on the Lua side to accommodate the repeating arguments. The function supplied to the custom SEXP needs to take the non-repeating arguments as usual, and then use Lua’s ipairs function to iterate through a list of tuples containing the repeating arguments. For an example, take this function which has the non-repeating arguments arg1 and arg2, and then two repeating arguments rep1 and rep2:
function(arg1, arg2, …) --arg1 and arg2 can already be used as usual for number, repeating in ipairs(arg) do local rep1 = repeating[1] local rep2 = repeating[2] end end
The three dots (...) in the parameter list indicate that the function has a variable number of arguments. When this function is called, all its arguments are collected in a single table, which the function accesses as a hidden parameter named arg. Besides those arguments, the arg table has an extra field, n, with the actual number of arguments collected.
Version-Dependent Scripting
When you script within a -sct.tbm, and not in a .lua file, the script is processed as a table before it is processed as Lua. This gives the scripter the ability to use FSO’s version-dependent comments in Lua. This can be used to enable certain behaviour of the script only when a sufficient FSO version is used, while allowing older versions of FSO to also load the script without the behaviour. An example of this is a version dependent function which chooses whether to use a newly-introduced random function of FSO’s provided libraries, or whether to emulate a slower, less well randomized version using Lua’s math functions if that is unavailable:
;;FSO 20.1.0.20200727;; rand32 = ba.rand32 ;;FSO 20.1.0.20200727;; !* rand32 = function(a, b) if a and b then return math.random(a, b) elseif a then return math.random(a) - 1 else return math.random(0, 0x7fffffff) end end ;;FSO 20.1.0.20200727;; *!
See Version-specific_commenting for details.
Doing something for all Ships
Performing some sort of operation for all ships is a reasonably common occurrence. It might be tempting to just build a for loop and access mn.Ships[i], but this is very slow for technical reasons. A much faster alternative is to use the ship iterator, and build a for each loop:
for ship in mn.getShipList() do --use ship as desired end
The same kind of iterator is also available for all missiles, and all parse objects (yet-to-spawn ships)
Making things take Time / Waiting for Stuff
A few situations require code along the lines of "keep this state for two seconds". Or, "wait four seconds and then do something". Naively, we’d be adding a global variable, saving the time that the wait started, then repeatedly checking if we’ve passed our wait time since then. This is very tedious to write and easy to mess up. FSO provides asynchronous coroutines to make this a very easy process. First, start up an asynchronous function using async.run, then wait in that function using async.await on mn.awaitAsync, and do whatever you had to wait for. An example:
async.run(function() --This code will run immediately async.await(mn.awaitAsync(4)) --This code will run 4 seconds later end, async.OnFrameExecutor) --Use OnFrameExecutor for things that need to draw, OnSimulationExecutor for things that just calculate physics --This code will run immediately
Similarly, it’s reasonably easy to wait for certain things to happen. For this, you can use a loop to wait for a condition, and then yield to the game in your loop. If you don’t yield in the loop, the game cannot process any new changes, and your code will be stuck in the loop. See the example:
async.run(function() --This code will run immediately while mn.Ships["Alpha 1"].HitpointsLeft > 10 do async.await(async.yield()) end --This code will run once Alpha 1 has less or equal to 10 hitpoints left end, async.OnFrameExecutor)
It is also possible to do other things in addition to yielding within loops in which you wait for certain things, such as drawing to the screen. This can be used to make certain visual effects that are available only for a certain time or until certain conditions are fulfilled.
Listening to Input
Listening to control input is closer to making custom SEXPs than to writing scripting hooks. While the On Action hook action exists, it is limited for certain types of controls. For listening to player-rebindable controls, the io library should be used. Call io.Keybinding[<Keybind name>].Bind:registerHook(...) with a function which will be called when the action is pressed. Certain types of controls have special things that need to be considered. If you registered the function for an axis control, it will be called every time the axis is changed. In addition, the Value hook variable will be set to the value of the axis. If it is a continuous button (such as holding down primary fire), the function will be called up to once per frame, with the hook variable Pressed set to whether or not the button currently is pressed down. An example of how that can be used:
pressed = false io.Keybinding["CUSTOM_CONTROL_1"].Bind:registerHook(function() if hv.Pressed ~= pressed then pressed = hv.Pressed if hv.Pressed then --Button is pressed else --Button is released end end end, true)
Parsing Config Files
Oftentimes, scripts that are made to be generic and not developed for one mod in particular want configurable settings without the need to change the scripts code. For this, loading configs is desirable. The current accepted standard to do so is to use the functions built by Axem. Merge the data folder of AxBase with your data folder, and you are good to go. Then, just call axemParse’s ReadJSON method to load JSON-formatted .cfg files from the config folder without issue. An example how to use it to load a file called config.cfg into a variable:
if cf.fileExists("config.cfg", "data/config", true) then config = axemParse:ReadJSON("config.cfg") else ba.error("Config file missing! Cannot proceed!\n") return end
Scripted Ship AI
It is also possible to script ship AI using Lua. It is very similar to how SEXPs are scripted, except that it needs two functions supplied instead of just one. One that is executed once the AI mode is started, and one that is called each frame the AI mode is active. In comparison to a normal SEXP, these AI functions are also passed an additional parameter in the form of an ai_helper object, which can be used to perform several actions with the ship the AI mode is active on. Additionally, both functions need to return a boolean. If they return true, the AI mode is completed and the ship will proceed with the next highest order, if false the ship will continue with this AI mode. A minimal example for the scripting side of LuaAI is the following:
mn.LuaAISEXPs["lua-ai-sexp"].ActionFrame = function(ai_helper) ai_helper.ForwardThrust = 1 return false end mn.LuaAISEXPs["lua-ai-sexp"].ActionEnter = function(ai_helper) return false end
For the SEXP-Table side, see [Dynamic_SEXPs#.23Lua_AI here].
Common Pitfalls
In this section we will take a very brief look at several common errors people make while scripting.
Removing Elements during Iteration
When you are using a for loop to iterate through a table, you must not remove elements from it. This will invalidate the iterators and cause unexpected behaviour. Instead, you must keep a list of elements to remove and then remove the elements from the table after you have completed iterating the table.
Object Validity
Most types of values provided by FSO's API have a function akin to isValid or similar. Especially for objects that are kept around for multiple frames, and then especially ships or weapons, it is prudent to check whether isValid is still true before operating on the values. It can happen quickly that a value becomes invalid (such as a ship dying and getting cleaned up), either causing an error or returning an invalid or unexpected value when using it.
Function Calling Syntax
Lua has two ways of calling functions. Using a colon before the function name, and using a dot before the function name. For things provided by FSO’s API, all functions that are available through a library must be called with a dot, while all functions available through an object must be called using a colon. For custom functions, the call syntax should match the syntax used when declaring the function. If you keep to the recommendations made in the Lua explanation chapter and the style guide of this document, custom functions should almost all be called using a colon.
Calling functions with Arguments: When will it copy?
If you call a function in Lua that has arguments, some of these arguments will be copied to the function, while others will not be. To demonstrate the difference, see these two code snippets:
function add1(value) value = value + 1 end local value = 1 add1(value) --value will be 1
and
function add1(value) value[1] = value[1] + 1 end local value = 1 add1( {value} ) --value will be 2
In the first code snippet, value was copied when it was passed to the function, so the function added 1 to the copy and not the original value. The second code snippet however, passed a list containing value to the function. Lists (and all other tables) are passed by reference, so Lua did not make a copy and the function modified the original value. In Lua, only numbers, booleans and strings (and some other technicalities unimportant to us) are copied when calling a function. Tables, functions, and all of FSO's custom data types are passed by reference and will not be copied. The exception to this is usually values taken from FSO's custom data types. To illustrate, see the following example:
local ship = mn.Ships["Alpha 1"] --This will change the hitpoints of Alpha 1 ship.HitpointsLeft = 1 --Accessing a value of a FSO type will create a copy local position = ship.Position --This will create a reference to position local position2 = position --This will NOT change the ships position, but it will change position2 position[1] = 1
In general, it is most safe to assume that FSO's data types are processed as references, but to push them back to FSO, they need to be assigned back to the API.
Style Guide
This is a short section on how we prefer to style our scripts for FSO. Some of these are performance relevant, while others are more for a better level of organization.
Avoid runSEXP
If you can, avoid evaluateSEXP, evaluateNumericSEXP and runSEXP. These are comparably slow, as they require a lot of text processing, and are a more error prone than just doing everything in script. If you find you cannot avoid them for one reason or another, consider adding a feature request on the SCP's GitHub page to add the missing functionality to the scripting API.
Make everything a local
If you have a function or a piece of code that regularly accesses the same variable, consider making a local copy of it. For things such as access to FSO’s API, this can be a lot faster since calls to the API are generally accompanied by safety checks or even expensive calculations. But even when accessing a subtable of a Lua object or a global variable, it can often be faster to copy it to a local and then use that local
Setup functions in On Game Init
This one is less performance relevant, but it has been somewhat established that scripts try to keep most of their code in function definitions that are run in On Game Init, which are then just called in each hook’s code, instead of having all the code in the hook itself.
Keep your Script’s Code in a Table
Try to avoid saving a lot of variables or functions to the global variable space. Since the global namespace is shared among all scripts, it is easy to accidentally overwrite another script’s variable or function otherwise. It is recommended that all scripts have exactly one overarching table, and then declare functions and variables for the script as objects of this table.