This guide is aimed at helping modders, both newcomers to Serious Engine and veterans, to improve their modding capabilities by incorporating lua scripting into their mods.
Introduction
imho this guide perfect for fundamental scripting knowledge – nano prefetcher, CTO and co-founder of Agiriko
First, I would like to note that this guide is aimed at people who are
(1) at least minimally competent in operating Serious Editor;
(2) at least minimally competent in programming (in general) and have the ability to learn and research by themselves.
I also won’t be teaching you the basics of the Lua scripting language. Reality is, the number of native Lua constructs which are available in Serious Engine can be counted on both hands:
you have your basic if-elseif-else, for and while loops, base types, functions, tables and table-related functions, strings and string-related functions, and that’s it. Almost everything else is a Croteam-added construct or function, which you can find in the Serious Editor using suggestions box (section “Suggestion box”).
The guide is intended to be fundamental. So if it feels too much for you at first, I recommend lurking in original CT scripts (both on levels and used for, say, enemy AI). Checking other people’s scripts is a good way of expanding your understanding of the capabilities of the scripting in the game, and just a source of (possibly) valuable techniques.
You can also find some code snippets which can help you familiarize with the syntax here: https://steamcommunity.com/sharedfiles/filedetails/?id=916575958
I also won’t be teaching you the basics of the Lua scripting language. Reality is, the number of native Lua constructs which are available in Serious Engine can be counted on both hands:
you have your basic if-elseif-else, for and while loops, base types, functions, tables and table-related functions, strings and string-related functions, and that’s it. Almost everything else is a Croteam-added construct or function, which you can find in the Serious Editor using suggestions box (section “Suggestion box”).
The guide is intended to be fundamental. So if it feels too much for you at first, I recommend lurking in original CT scripts (both on levels and used for, say, enemy AI). Checking other people’s scripts is a good way of expanding your understanding of the capabilities of the scripting in the game, and just a source of (possibly) valuable techniques.
You can also find some code snippets which can help you familiarize with the syntax here:
The basics
In this chapter I will cover some basic ideas and principles in a semi-structured manner.
World scripts vs Console scripts
The very first characteristic there is to make about scripts in Serious Engine is that there are world scripts and console scripts (I’m not sure that’s what they are called, but I call them that myself).
World scripts run in a world, i.e. a running level, and are “tied” to it
Console scripts, on the other hand, are running continuously while the game is running, independently from the levels and what happens in them, and they can interact with the cvars (console variables) and call a number of functions, including some console-only functions (like loading/saving external text files). Technically, the console in the Serious Engine games is just a big Lua interpreter.
Most of the talk in this guide will be devoted to the first category, the world scripts, because these can actually operate with the entities in the game world and affect gameplay.
Local variables and global tables
In its default implementation, Lua allows you to declare variables by just stating their name, like this:
var = “whatever”
However, the Lua implementation in Serious Engine requires that all user-defined variables be marked as a part of the local scope when declared, so the code above should look like this in a Serious Sam script:
local var = “whatever”
However, a variable may be defined without the declaration – for example, if you create a handle variable using the drag-and-drop method (more on that later) – in this case you don’t have to write local anywhere.
In addition to local variables, you can use a special global table globals (aka worldGlobals in world scripts pre-SS4). This is a unique table which is accessible from any running script, and it can be used to pass information between two different script instances running on a level (or in the console).
Please note, however, that the globals table in the world and the globals table in the console are two different tables. World scripts cannot access the console table and vice versa. Also note that the globals table is tied to the world, so even consecutively played levels have this table reinitialized on each level start.
“Controlled from script”
Certain entities in the game (static sounds, particle effects, etc) have a field named “Controlled from script”. Not only this field controls whether you can use this entity in a script (call functions on it), but it can also affect some multiplayer behaviour.
For example, setting this field to Game scripting will make it so that the host should use functions on the entity and function calls will be replicated on clients. This is useful, for example, when you want a sound or a particle effect to play simultaneously for all players. This is the go-to setup for most pre-placed scriptable things on maps.
Meanwhile, setting this field to Local scripting will make all function calls on this entity, well, local, so that the effect of the function calls will only be visible on the client who called it. If everything I just said seems gibberish to you, that’s okay, you will learn more about replicated and local function calls in the Networking sections.
World scripts placement
A world scipt can be run either by
placing a script entity on a level and writing the code in it (open script editor by double-clicking the script entity) or by
placing a .lua file in the /Content/SeriousSamX/Scripts/CustomWorldScripts/ directory, where SeriousSamX is the gametitle folder. It is SeriousSam4 for Serious Sam 4, SeriousSam3, SeriousSamHD, SeriousSamHD_TSE for gtitles in Fusion.
World scripts execution
Once a level is loaded, the world script starts its execution.
The world scripts placed in the CustomWorldScripts directory always execute on any level (including Menu/Netricsa levels, yes, those are technically loaded levels too). These are executed in the alphabetical order, so the script named 01_Whatever.lua will be executed before A_Whatever.lua.
The script entities placed on levels have 3 states of execution type: “Always” (which makes them always execute on level start), “On host” (which makes them execute only if the machine which loaded the level is the host of the server) and “Never” (which makes them execute, well, never, which is convenient for developing a script which is already placed into the CustomWorldScripts folder).
It’s worth knowing that scripts by themselves aren’t synchronized in a multiplayer context. Every machine executes the script independently and manages its own internal state. While there might be some commonly used functions (for example, CPlayerPuppetEntity::DropDead()), which when called by the server will also tell the clients to do the same thing, most functions sadly will not. In that case, you need to use special functions graciously provided to us by Croteam called RPCs, which you can read more about in the Networking section.
But for now, let’s think in the scope of a single machine running a script. No networking yet.
Any world script executes its code in a certain part of each simulation frame. These simulation frames are the same as your regular visibly rendered frames. So, right after the level starts, the script executes its code until it either
finishes,
produces an error because something went wrong,
executes too many commands, in which case it is forcefully stopped by the game, or,
reaches a Wait statement, which blocks the execution until a certain filter is fulfilled on one of the consecutive simulation frames. Once that happens, the execution of the code resumes. See more about this in the section “Filters and payloads”.
Handles and Suggestion box
Let’s now step back from the words Wait, filter and such, and learn how can you even start doing something with a script code.
Variable types
Lua is a weakly typed language, meaning you can easily do stuff like
local var = 1 var = “string” var = {1,”Hello”,true}
and no one will bat an eye. There are your usual string, number, boolean types. There is a table type for tables, and function for creating functions.
There is one very interesting type of variable, which is a table variable pointing to an entity in the game world. I will refer to such variables as handles, or handle variables. Using such a variable, you can call different functions on an entity and create event filters using the entity.
Another interesting type of variable is a group variable, which is essentially a table filled with such handles, but with a twist. The twist which differentiates a group variable from a table is that you can call functions and create event filters with group variables as if they were just a singular handle.
How to create an entity handle
First off, in any world script which is either placed on a level or run directly from the CustomWorldScripts folder, a special variable worldInfo is an automatic handle to the World Info entity of the current world.
(Note that if you run a world script indirectly (e.g. using a dofile() function from another script), then in this script worldInfo will not point to the World Info entity, and you have to refer to the worldGlobals.worldInfo instead).
To create handles which point to other entities in the world (other than the World Info entity) there are two ways:
The first, more general way, is to use any function which returns a handle, a table of handles or a group variable. For example:
local player = GetClosestLivingPuppet(worldInfo,100000)
The function GetClosestLivingPuppet(…) returns a handle to the closest living puppet within a certain radius from a certain entity (in this case closest to the origin of the world in a 100 km radius), or nil if such a player doesn’t exist. So after such a line in the code, the variable player will be a handle to the closest player, if one exists.
The second way works only if your script runs from a Script Entity placed on a level. You must select the entity (or a group of entities) which you want to create a handle for, hold Left Control, Left Alt and Left Shift, then click, drag and drop an entity (one entity from the group if you selected a group) from the world onto a variable name in the text of the script:
This will automatically create a handle (or a group variable) with such name, pointing to the entity (entities) which you have dragged. If you did everything correctly, the variable name should highlight green whenever the linked entity is selected, and become blue when it’s not selected.
Suggestion box
This bad boy can fit so many functions is your bread and butter when it comes to learning scripting in SED. This guide can only get you so far, using suggestion box and experimenting will be the most of your knowledge.
Using the suggestion box you can see all available script functions/events, either the ones available on a certain class of entity, or general functions. Functions in suggestion box are equipped with a description of the types of arguments they accept and the type of value they return. Some functions even come with a short dev description.
In order to invoke a suggestion box for general functions, put the text cursor onto a new line and either (1) hit Ctrl+Space or (2) type two dots. You can then start typing the function name to get more precise suggestions.
In order to invoke suggestion box for class-specific functions/events, you first have to tell the SED script editor that a certain variable should be treated as a handle variable for said class.
If you create a handle by dragging an entity from the level, then the class will be assigned to the handle variable automatically.
If you have obtained a handle through other means, then you have to manually tell the editor which class this variable should be treated as. To do that, you have to enter the following code before the mention of the variable on which you want to invoke a suggestion box:
–entityVariableName : entityClass
At first you will not know names of any of the classes in Serious Engine, but you can learn them from (1) investigating descriptions of functions, (2) by hovering your mouse over world-linked variables or, once again, (3) by using a suggestion box for the class types. To invoke this box, you should enter the following: –entityVariableName, then space and a colon in the script editor, like on the gif below
If everything goes right, the line with the declaration of the variable class should turn blue after a split second.
After the editor knows the desired class of a variable, your next step is to place a cursor after a variable name and type a colon (:) or a dot (.). This will invoke the list of functions/events correspondingly. You can also press Ctrl+Space while the cursor is placed after a colon/dot to call the list again if you lost it.
And there you have it!
Note that it is not necessary to tell the script editor the type of a variable in order to call functions on it – this feature is purely for the programmer’s convenience.
Exercise 1
Write a script which, after the level start, waits one second, then searches for the closest living player to the world origin and then kills it.
Use Wait(Delay(1)) to wait for one second. Use the suggestion box to find a function which gives you a player (search general functions and functions for the world info entity), and to find any function which will kill a player. pastebin.com – https://pastebin.com/bj4Bf1Ct Bonus task: do the same, but finding all players on the server, iterating through the obtained table and killing them one by one. pastebin.com – https://pastebin.com/910XY3Nb
Filters and payloads
As I mentioned in the basics section, a script executes until it either ends, produces an error, executes too many commands per one simulation step or encounters a Wait(…) function. This last function uses so-called filters as its arguments. The script then blocks the execution until the filter is fulfilled on one of the consequent frames of the simulation.
There is also a special WaitForever() function, which doesn’t accept any arguments but simply blocks the execution forever. It is commonly used inside a RunHandled(…) (see next chapter).
Filters
A filter can be one of the following types: Delay, Event (which includes Event, Events and CustomEvent), special LatentFuncID type, returned by certain functions, or a combination of the above using one of the special combining filters Any, All, and Times. Before going into detail on each one, let me present several code examples for each of these.
This filter takes as an argument a positive float number fTime, and is fulfilled when the amount of time which has passed since the Wait was encountered equals or exceeds fTime. Since scripts only execute on simulation steps, the script will almost never wait the exact amount of time you specified.
For example, if you put a Wait(Delay(0.5)) in your script and your game runs at perfectly stable 5 FPS, then the earliest the filter will be fulfilled is 3 frames after the Wait has been encountered, which makes 0.6 seconds. Since games usually run at way higher FPS, and in many cases you don’t need the perfect precision, this filter is very useful.
LatentFuncID returned by a function
When you go through the list of functions in the suggestion box, some of them are marked with a small blue clock, and their return value says LatentFuncID. This is a function which can serve as a filter inside a Wait, and it will be fulfilled once the function decides so (I guess it’s when the function finishes execution but we don’t know the internal structure of this). Example:
Event(handleVariable.EventName)
This filter takes as an argument an event on a handle, and is fulfilled when that event is signaled. For example, in the following code
Wait(Event(worldInfo.PlayerBorn))
the filter is fulfilled when the worldInfo entity signals a PlayerBorn event, which happens each time a player has entered a server or respawned.
These events on entities of different classes can be discovered through the suggestion box, as described in the previous section.
CustomEvent([object], strEventName) and SignalEvent([object],strEventName,[optional params])
The CustomEvent filter takes a string as an argument and is fulfilled whenever a custom event is called somewhere else in the world scripts. There are some special custom events added by developers which are signaled automatically whenever a certain thing happens, more about those later.
This filter also accepts an optional handle as the first argument. Then it only catches custom events which were signaled on the entity referenced by that handle. Examples:
In order to now signal a custom event from a script (to be “catched” by the code above), you have to use the SignalEvent function, which has 1 required argument – the name of the event – and 2 optional arguments, the first being the object on which the event should be signaled, and the last being the payload content. The payload can be anything. Examples:
These are known as composite filters. Any(filter1, filter2,…) accepts an arbitrary amount of events and waits until any of them were signaled. All(filter1, filter2,…) also accepts an arbitrary amount of events but it waits until all of them are signaled. Examples:
— Waits for either the specified custom event, or for the puppet to finish playing the animation Wait(Any(CustomEvent(“ThisIsACustomEvent”), puppet:PlayAnim(“Idle”))) — Waits for both specified custom events Wait(All(CustomEvent(“ThisIsACustomEvent”), CustomEvent(“ThisIsADifferentEvent”)))
Times(n, filter), on the other hand, accepts a number and a filter and waits until the specified filter has been signaled specified number of times.
— Waits until the spawner spawns 5 enemies Wait(Times(5, Event(Spawner.OneSpawned)))
Events(groupVariable.EventName)
This filter is basically the same as the Event, but it takes a group variable as an argument instead of just singular entity variable, and must be used in conjunction with the All or Any composite filter. Hence, it is fulfilled when the event EventName has been signaled on any/all entities in the groupVariable. Example:
— Wait until all enemies in all spawners are killed Wait(All(Events(Spawners.AllKilled)))
Group variables are covered in greater detail in the “Miscellaneous” section.
Event payloads
Filters of event type (including custom events) can carry a payload. Payload is the returned value of the Wait function. Payload can be any kind of object (base type, table, function). It can also be an object of a special class which has some functions attached to it – this is common in case of events signaled on entities, but also on some custom events added by CT themselves.
For the events signaled from an entity, the payload usually is an object of class CEventNameScriptEvent, where EventName is the name of the event – see image below for an example:
For a custom event, the payload is whatever you have passed as the last argument.
For the composite filters All and Any, the payload returned is a table with individual payloads and some additional info, read more here: <should be a link to the serious sam wiki here but it is down currently 🙁 Asdolg please fix>
Exercise 2
Based on Exercise 1, make a script which waits a bit after the level starts, gets any player, then waits until that player dies and respawns it. pastebin.com – https://pastebin.com/Rkg0H2Kw Bonus 1: put this into a cycle, so the player can “respawn” like this infinitely. pastebin.com – https://pastebin.com/5K65fCwV Bonus 2: utilizing the payload for the event which makes the script wait until the player dies, find out who killed the player, and respawn the player only if he was killed by someone else, but not if he suicided. pastebin.com – https://pastebin.com/TeWsGPLg
RunAsync and RunHandled functions
Another two special and very useful functions in the Serious Engine Lua implementation are RunAsync and RunHandled.
RunAsync(function)
The RunAsync function starts executing the function which you pass to it as argument in a separate couroutine. This allows you to have multiple separate executions running in one script, as if you had several different scripts running independently. If one couroutine errors, only it is aborted, while others continue. If a couroutine encounters a Wait, then only it is blocked, and the execution in this frame is passed to some other couroutine. This is very useful when you want, for example, handle a number of different entities independently one from another. Simplest example code:
In this example, the script encounters a RunAsync and starts the execution of the function inside it as a new couroutine. It immediately encounters a Wait, so the execution of the code is passed to the original couroutine, which continues after the RunAsync block, where, once again, a Wait is encountered. After a delay of one second, the code in the second couroutine continues, and signals a custom event. This event is in turn “picked” by the initial couroutine, and a message is printed in the console.
Note that I put the whole description of the function inside the RunAsync function. That’s not an attempt to optimize the length of the code, but my usual habit.
The reason is that there is no way (which I know of) to pass arguments into a function which you put inside RunAsync. So, while you could technically do stuff like
local function Foo(arg1,arg2,…) –CODE end RunAsync(Foo)
and be fancy, you cannot pass on those arguments arg1 etc. to the Foo function utilizing this syntax. If you do want to call a function with arguments which should run in a new couroutine, then the construct like the following could be used:
local function Foo(arg1,arg2,…) RunAsync(function() –CODE end) end Foo(arg1,arg2,…)
Note that the limit of the script commands executed by script in one frame, which I mentioned in the beginning, actually applies to each couroutine independently. So you can technically run a lot of script code per each frame by utilizing many couroutines, but keep in mind that, depending on the amount of calculations your code requires, this may slow down the game significantly. And since the script couroutines are not really running in parallel, but one after another, my understanding is that all the scripts in the world can only utilize one logical core of your CPU.
Exercise 3
Combining the code from Exercises 1 and 2 and utilizing RunAsync write a script which will (1) wait a bit, then (2) obtain all players in the world, and (3) for each of them, run a “non-suicide-respawning” script from Exercise 2 Bonus 2 in a separate couroutine. pastebin.com – https://pastebin.com/8yJBg1sH
This is a very useful function which allows you to easily run new couroutines when specified filters are fulfilled without it all looking like spaghetti code.
The function accepts an odd number of arguments. The first argument is a function. While this function is running, all the other stuff inside RunHandled happens. When this function ends, the RunHandled ends and the script goes further. I will call this first function an active function of a RunHandled.
The next arguments are pairs of filter-function, where filters are put inside an On or OnEvery. When a filter inside an On is fulfilled the first time, or any time a filter inside an OnEvery is fulfilled, the corresponding function will be executed in its own couroutine. Let us present an example to understand how whis works:
RunHandled( function() Wait(Delay(10)) end, On(Delay(0.5)), function() print(“half a second passed inside RunHandled”) end, OnEvery(Event(worldInfo.PlayerBorn)), function(payload) –payload : CPlayerBornScriptEvent print(“Player with the name ” .. payload:GetBornPlayer():GetPlayerName() .. ” has been born”) end )
Let us dissect this code. After the script reaches RunHandled, the first function starts. After 10 seconds pass, the function finishes and the RunHandled is exited. During those 10 seconds, the filters inside the second and fourth arguments can be fulfilled, causing the functions to run.
When half a second passes, the function after the On(Delay(0.5)) runs and prints a message. Since I used On here, it happens just once per the RunHandled lifetime.
Whenever a worldInfo.PlayerBorn event is signaled, the second function runs. Note that in the declaration of the function I put a payload as an argument. That’s because the PlayerBorn event has a payload, and this is the way to obtain the payload from an event inside the RunHandled. It’s not necessary to put the argument there if you don’t need the payload.
So each time a player is born, the script prints a line which contains the players’ name.
There is also the BreakableRunHandled function, which acts exactly as the RunHandled function, but it can be “broken” (exited before the active function completes its execution) by calling the BreakRunHandled() function (which is, for some reason, not documented anywhere).
Exercise 4
Based on the examples above, write a script which runs a RunHandled, catches every born player and sets its HP to 200. Use the WaitForever function as the active function to create an “infinite” RunHandled. Note that the PlayerBorn event is signaled both when a player is spawning the first time on server join, and when it respawns after death, so this should automatically apply to each “life” of each player. pastebin.com – https://pastebin.com/Yc8mq9xB Bonus 1:
Add another filter-function pair to the RunHandled which shows a HUD message to all players with the name of the current difficulty 2 seconds after the simulation has started. Search for required functions in the worldInfo. pastebin.com – https://pastebin.com/S4CwVFBS Bonus 2:
Place an item on the map. Create a handle variable for it by the drag-and-drop method. Add a filter-function pair to the RunHandled which will damage each player who picks the item for, say, 10 HP. pastebin.com – https://pastebin.com/D9CQs0u4
Miscellaneous
In this section, I will mention several other important aspects of SED scripting which don’t have a whole section for themselves.
Group variables
I have briefly mentioned group variables in the previous sections, but want to expand on them a little here. A group variable is a table of handles which can be treated as a singular handle variable – a function call will be executed on each element of the group variable, while for events with group variables you need to use an All or Any filter (see previous sections).
For example, if you write
local AllPlayers = worldInfo:GetAllPlayersInRange(worldInfo,100000) AllPlayers:DropDead(0,0,0)
the game will give you an error, because worldInfo:GetAllPlayersInRange(…) function returns a table of handles, but not a group variable. But if you had a group variable AllPlayers, then you could do
AllPlayers:DropDead(0,0,0)
and it would work just fine, killing all players in the group.
There are two ways of creating a group variable. First one is only useable in the scripts which are run from script entities placed in the world, and can only be done before execution – you drag and drop a number of entities from the world onto a variable name.
The second way is to utilize the NewGroupVar() function. This function creates and returns an empty group variable, and it is the only function which does so – all other functions return plain tables. This empty group variable then must be filled with individual elements numbered from 1 and upwards in order, otherwise it will be treated as an empty group variable.
Exercise 5
Modify the code from Exercise 1 Bonus, which kills all players after a second. Instead of iterating through a table of players and killing them one by one, make it create a new group variable, put all players into this group variable and then kill them all at once by calling a function on a group variable. pastebin.com – https://pastebin.com/AicHGghb
Special custom events
As noted above, you can signal custom evens by using SignalEvent function and catch them using CustomEvent filter. But there are a few custom events which are already signalled automatically by the game, and those can come in quite handy.
OnStep
This custom event is signalled precisely once on each step of the simulation. Since, as we know, the scripts execute by simulation steps, this is very useful when you want to perform certain actions “as often as possible” to achieve the smoothest results in-game. To reiterate and avoid any confusion, you wait for this event (and all custom events) like this:
Wait(CustomEvent(“OnStep”))
This event returns a payload of type COnStepScriptEvent. It has a useful function GetTimeStep(), which returns the duration of the last passed simulation step in seconds. This is very handy for performing framerate-independent calculations, as you can take frame durations into account. The duration of the last simulation step can also be obtained by calling a worldInfo:SimGetStep() function.
EntitySpawned
This custom event is signalled each time a new entity is created in the world. The payload of this event is of the type CEntitySpawnedScriptEvent, and the spawned entity can be obtained from this payload by a corresponding function. This event may be signalled quite often (even multiple times per simulation step) as the number of dynamically spawned entities varies.
First small note I should make in this chapter is that the script state is saved in your savegames, so if you load a saved game, all world scripts will continue running from where they stopped and with whatever values were set in their variables on the moment of saving.
At the same time, any world script starts with the world simulation and ends with it. So, when the next level in the campaign loads, all scripts start again and they, in general, have no knowledge of what the scripts in the previous levels did. This is rather unfortunate, as we sometimes want to save some progress between different levels.
Game Info
This is where the Game Info object comes into play. Game Info is a table which is created with the game session (starting a level from a “new game”) and persists through all level changes until the game session is stopped (by pressing “stop game”). Pointer to this object can be obtained through, for example, a worldInfo:GetGameInfo() call, and then, by specifying the variable type, you can see all the available functions for this object, like on the screenshot below:
There are a number of functions there, but the most interesting are of the form SetSessionValue and GetSessionValue – these allow you to write and read values of corresponding types into this Game Info object table, which persists between levels.
Player profile functions
Game Info saves the data between levels, but once you press that “Stop game” button, or worse, quit the game completely, the Game Info table is no more. It is saved in the savegames, sure, but loading a savegame is not a new game session.
In order to save data between game sessions, Croteam has provided us (only in SS4) with the functions which can write and read values from the player’s profile. These persist between game launches and, IIRC, even between launching the game on a different PC but with the same Steam account. You can find these functions among the General Functions, but I will list them here:
— These return values saved in the player profile plpGetProfileFloat(strKey) plpGetProfileLong(strKey) plpGetProfileString(strKey) — These set values to be saved in the player profile plpSetProfileFloat(strKey, fValue) plpSetProfileLong(strKey, iValue) plpSetProfileString(strKey, strValue)
Please use these responsibly and do not clog the player’s Steam profile with too many values.
Network scripting (generalities)
Well here we go. Making all the stuff you have coded work properly online is arguably the least fun part of creating nontrivial script mods. Sometimes it may be rather restricting and you will have to choose between cutting off some functionality to make your mod work good online and ditching Multiplayer support at all in favor of crazy features. But I highly encourage you to try your best before giving up on MP support (wink-wink, Danny and YANexus).
As with scripting in general, the understanding of what is possible to make work in MP and what is not comes with experience (and sometimes something “impossible” becomes “possible” when you find that one useful function or come up with a creative workaround), so aside from reading this guide, peek into other people’s scripts which successfully operate in MP.
Testing Multiplayer scripts
Unfortunately, the Editor does not (to my knowledge) have functionality which allows the user to actually properly test scripting in Multiplayer, to see how it will work in a real multiplayer game scenario. So if you wish to properly test your scripts in MP, you will need to run two copies of the game, start a server on one and connect to it on another. I use a second Steam account on which I bought Serious Sam games purely for that.
If you happen to have a spare PC, that’s great – running two copies of the game simultaneously from two accounts is straightforward and convenient. But even if you have only one PC at your disposal, you can use a Sandbox in order to run two copies of Steam and two copies of Sam simultaneously on one PC. Follow https://steamcommunity.com/sharedfiles/filedetails/?id=311943358 in order to install Sandboxie and the second Steam copy. Note that you will have to download the second copy of the game, so it requires some extra space (though, it may be possible to link both libraries to one game folder? I haven’t really tried). I have been using this setup myself extensively and it gets the job done. Running games in windowed mode also helps.
The world and synchronization
Let’s start with the general concept of a world. By “world” in this guide I will denote the set of all entities and their interactions which form the simulation in which you can play and the script can run. Not the best formal definition, but I hope it gets the point across.
The first thing you should understand when thinking about Multiplayer scripting, is that the world is local, i.e. it is created and simulated on each computer independently, even if players are in the same multiplayer game. The actual multiplayer functionality comes from continuous synchronization process between the server and clients, who send all kinds of data one to another.
Few examples:
– the client sends its player’s position, or a “hey I pressed ‘Fire’ button” message to the server, who then retranslates it to all other clients. This reproduces a proper player position and weapon firing effects and damaging for all players in the game;
– the server sends a “hey this enemy was blown up by cannonball” message to all clients, so that they produce the proper gibbing effect for this enemy on their side.
So when scripting Multiplayer, you too will have to send bits of data between game clients to synchronize important script state parts. Minimizing the amount of data you have to send while maximizing the amount of local calculations each computer will perform on its own is a whole art and profession. Computer can process a lot more data with its CPU and GPU than it can send/receive over the network, and the less you clog the user’s network the better.
Script execution
A running world script is also a part of the world, and so it is local. When you place a script entity on the level, in the entity parameters its “Auto run” field is set to “On host“, which means that this particular script will be executed only on the host and ignored on all connecting machines. This type of execution works for most simple tasks, like spawning enemies for a fight or scripting some animated mover sequence, because the host is “responsible” for these actions.
But for many complex and interesting tasks you will have to write scripts which run on all machines. This is achieved by either setting the “Auto run” to “Always“, or by putting the .lua file into the CustomWorldScripts folder (see “Basics” chapter).
Each machine creating or joining a server will start executing such scripts as soon as the simulation in the world starts. So, for example, if a client joins mid-level, it will still start executing an “Always“-marked script from its start. If a client doesn’t have some mod which utilizes a script placed in CustomWorldScripts, it won’t run this script at all, even if all other players, including the host, have this mod and this script. Simply because this client doesn’t have this script.
Network descriptors and RPCs
Any object (entity) in the world contains a number of values describing its state. Some (most) of these values are calculated and handled completely locally, but some are synchronized, e.g. from the server to all clients, or from a client to the server. These latter “synchronized” values we shall call network descriptors and they constitute one of the two parts of networking. Examples of such values would be, for example player’s health (synchronized from server to clients) or player’s position (synchronized from the client which “operates” the player to the server, and then from the server to all clients).
The second part are so-called Remote Procedure Calls (RPCs), which are essentially just functions which get “replicated” on other machines when called on one machine. In the above example about synchronizing gibbing, we can imagine that the function responsible for gibbing an enemy is an RPC called, say, GibEnemy(enemy) (not an unreasonable assumption). So when the server calls this function, in addition to its execution, server also sends a packet of data to all clients with the message “hey guys, execute the GibEnemy function with this enemy as a parameter”. Clients obey and this results in synchronized enemy gibbing. Note that the RPC packet contains the function to be called and the arguments with which it is called – more on arguments later.
Using these two concepts – Network Descriptors and Remote Procedure Calls – you can describe pretty much any synchronization process in Serious Engine.
Calling existing functions
Whenever you call some pre-existing function (a global function or a member function on some entity), at first you don’t know if/how the effect of this call will propagate to other users.
Some function calls may change a synchronized data field on an entity (e.g. SetHealth(..) on a puppet), so it will “get synced”, others may actually behave like an RPC (like, SetLinearVelocity(..) on a player puppet).
Notice, though, that calling the first function in the script running on a client machine will produce no effect – because client “has no right” to affect this server-handled synchronized state of a puppet (health). Meanwhile, calling the second function on a client machine will produce interesting effects, depending on whether you call it on the player puppet which you control or not.
You should take this into account when creating network-friendly scripts – many functions (and most events!) only “work” when called on a host, some synchronize while other’s don’t and so on. As a rule of thumb, host “controls” most the gameplay-important values of synchronized entities (enemies, projectiles, etc).
Scripted RPCs
Ever since Serious Engine version used in The Last Hope (so, Fusion is included), RPCs can also be created from within the scripts, which is a massive help for scripters. It allows us to properly perform state synchronization on our own hand.
Croteam has provided us with a convenient interface to create scripted RPCs:
CreateRPC takes 4 arguments, and creates an RPC that resides inside the worldGlobals table.
The arguments are (in order):
– strSide: This argument describes who can call this RPC, and to whom it will be replicated. It takes one of two values, either “server” or “client”.
Making the RPC server sided means that you’ll be able to call it from the host only, but it will be replicated to every single client. Note: trying to call a server-sided RPC in a Singleplayer game after a savegame has been loaded produces an error, so make sure not to use server-sided RPCs in a SP context. UseworldInfo:IsSinglePlayer()for checking.
Client-sided RPCs can be called only on the client side (which also includes the host, since the host is technically both a server and a client) and it will be replicated only to the server.
– reliability: This argument describes whether the RPC replication call absolutely must arrive to the other side. It takes in one of two values, either “reliable” or “unreliable”.
As you might know, the internet is big, like really big. This sometimes causes packets to just disappear into the ether or arrive out of order, while they hop from one node to another.
Setting this argument to “reliable” means that we 100% want this packet to successfully arrive in order on its destination and if it doesn’t, the sender has to send it until it arrives. Reliable RPCs are useful for things we always need to happen, for example, setting an important variable when something happens. But you need to call them infrequently, as they may cause significant network overhead.
“unreliable” on the other hand tells the networking system that we do not care if the packet arrives. It’s helpful if we are planning to send a lot of similar packets that we don’t neccessarily need to always arrive (or arrive in-order). They’re mostly used for movement data, clients send them very frequently, and they don’t need them to arrive in order, as there’s probably gonna be a newer packet in line either way.
– strName: This argument basically says what name the RPC will have in the global table. For example, if you set it to NetMakePlayerQuack, you’ll be able to call your RPC with worldGlobals.NetMakePlayerQuack().
– func: This is the meat of the RPC. This function will be executed once the RPC is called.
It is a regular Lua function we can specify, that can take in an arbitrary amount of arguments.
An example being:
worldGlobals.CreateRPC(“server”, “reliable”, “NetMoveModel”, function(enModel, qvNewPosition) enModel:SetPlacement(qvNewPosition) end )
As can be seen, I’ve created a Lua function that takes in a handle of a static model, and a QuatVect representing the new position.
If I call it now, I need to specify them:
Doing so will invoke the function for every client with the given arguments.
It’s important to know that you can’t pass in every Lua type as an argument, though!
The RPC system only accepts:
– Handles to entities that were assigned network IDs (For example: Player, Static Models placed on levels)
– Lua types: strings, numbers, nil
– SEd types like Vector3f, Quaternion, QuatVect
Important notes:
The RPC system DOES NOT support passing in tables.
A replicated RPC call does not have the worldInfo variable created by default. Either use worldGlobals.worldInfo (Croteam-added global table value) in an RPC, or simply put a line
local worldInfo = worldGlobals.worldInfo
at the start of your code to explicitly have a local worldInfo variable in your script.
Example of scripted RPC
Let’s say there’s a very important variable that I need to change for all clients. All clients need to desperately have it.
Let’s declare it:
local importantVariable = 0
Now, if we set the script to run Always, every client will now have a variable called importantVariable set to 0. Now, let’s say the server wants to change it.
If we just do
importantVariable = importantVariable + 1
it would only change it for the server, and all clients would still have it set to zero.
Let’s construct an RPC to handle synchronization of this variable.
We know we will only call it on the server, and it’s something that all clients need to always receive. So we mark it as a “server” RPC with “reliable” delivery.
worldGlobals.CreateRPC(“server”, “reliable”, “SetImportantVariable”, function(newValue) importantVariable = newValue end )
The RPC has a function that takes in one argument, the value we want to set our important variable to, and when any client receives the RPC, it will change the important variable to our new value.
It’s all quite simple.
Now, if the server changes the variable and wants to tell clients to do the same, he would do it like this:
— Increase our important variable importantVariable = importantVariable + 1 — Call the RPC to tell the clients to change it to our new value worldGlobals.SetImportantVariable(importantVariable)
The call will get replicated on all clients and they will all set their importantVariable to the host’s value.
Exercise 7
But what if a player joins after this RPC has been called? He would still have his importantVariable set to 0. Solve the issue of delivering the value of the importantVariable to each player who has joined. Using worldInfo.PlayerBorn event, send the SetImportantVariable RPC to update the important variable to each newly joined player. pastebin.com – https://pastebin.com/cXHnJ7qf
Bonus 1
Using worldInfo.PlayerBorn event will send an RPC not only when a player has first joined, but also when he respawns. And what if a joining player doesn’t create a player puppet at all (this is possible, say, in Survival, where you spawn in Spectating mode)?
Write a client-sided RPC RequestImportantVariable, which, when replicated from the client to the server, will make the server send the SetImportantVariable RPC to update the important variable.
Remember, client-sided RPC is executed both on the host and on the client machine which called it, so use worldInfo:NetIsHost() check to make only the server send the variable-updating RPC. pastebin.com – https://pastebin.com/gqgtNAZY
Bonus 2
Join all the parts of the important variable updating code (including RPCs definitions) so that
(1) The client requests an update of the importantVariable when it joins the server;
(2) The server increases importantVariable each 5 seconds and sends the new value to all clients when it does; pastebin.com – https://pastebin.com/aQZmMw7Z
As I have mentioned in the Generalities section of the Networking, aside from RPCs there are also so-called network descriptors. They are very useful when you want to attach some values to certain entities and synchronize them rather ofter – you can then save a lot of RPC calls. Luckily, it is possible to create network descriptors in scripts too!
Essentially, scripted network descriptors are a set of data fields which you can “attach” to an entity and then you can use these data fields and they will be synchronized between machines.
IMPORTANT: Note that each entity can have only one scripted network descriptor assigned to it! This means that if two different scripts will want to add their own network descriptors to one, say, player puppet entity, one of them will not work! I therefore recommend to use scripted network descriptors only if you create some kind of “stand-alone” modification (your own gamemode or a custom conversion campaign) and only if you really need them, otherwise resort to using just RPCs.
It should also be obvious that this setup should be executed on all computers in order for the synchronization to work.
Registering a network descriptor
The first step for creating and using network descriptors is registering your descriptor. Let me present an example and then we will dissect it:
As you can see, RegisterNetworkDescriptor function accepts two arguments. The first one is the name of the network descriptor which will later be used for assigning this descriptor to entities.
The second argument is a table of members. Each member is described by, again, a table with three entries.
The first entry in this table describes the synchronization directive for the member:
“server” means that the member will be synchronized from the server to all clients;
“client” means that the member will be synchronized from a client to the server;
“client server” means that the member will be synchronized from a client to the server, and then from the server to all clients;
Note that the last two types can only be used on entities which a client can “own”, so, basically, on a player puppet.
The second entry in a member description table is it’s type. Only simple base types are supported, like floats, strings, bools.
The third entry is the name of the member, by which it will be operated.
So after performing the code above, we now have a network descriptor “MyNetDescriptor” ready for assigning to entities.
Preparing class for scripted net synchronization
Before assigning a network descriptor to an entity, you must tell the game to prepare entities of required type for scripted net synchronization. This is done using the following function:
where player in my example is a handle variable for a player puppet.
After I perform this function, my player is ready!
I can now do, for example
player.net_ServerToClientsMSG = 420.69
in a host script (because the net_ServerToClientsMSG field was marked as synchronized from server to clients, remember?), and all clients will get this member synchronized and will be able to get the number by checking the same player.net_ServerToClientsMSG data member in their script.
No exercises in this section. Examples code pretty much covers the topic and I believe if you come to the point when you need to use these, you should be competent enough to write stuff with them yourself. You may check the scripts for my https://steamcommunity.com/sharedfiles/filedetails/?id=1329922104 mod for Fusion for real-life usage of these.
Console scripts
As I have already mentioned in the Basics section, the console in Serious Engine games is just a big Lua interpreter. You can therefore create globals variables through the console, call certain functions (which are not related to world/entities), interact with existing console variables (cvars) and execute whole scripts.
The simplest way to run a script file in the console is by using a dofile() function. It accepts the path to the .lua script as the argument and executes it asynchronously until it either finishes (or aborts), or until the game is closed. I will reiterate here, console scripts run independently from worlds (levels) loaded and simulated, from world scripts. Even if your game is “Paused”, console scripts run.
Another convenient way of running a console script is to place a .lua file into a special Startup scripts folder. The full path to the folder is Content/SeriousSam4/Scripts/Startup/
for Serious Sam 4, and Content/SeriousSamFusion/Scripts/Startup/
for the Fusion.
A script placed in this folder will be automatically executed by the game on startup.
Console scripts are useful when you want to, say, do some automated manipulations with the settings (set cvars), or e.g. restart a level using a console-only function gamStart().
They are also useful because they can load and save text files (world scripts can’t do that) The strLoadText_unsigned(..) function can be used for loading, while strSaveText(..) can be used for saving. This was actively used by me and other scripters pre-SS4 in order to save data between game starts in an external configuration text files, but is now outdated, thanks to the newly added Player Profile functions (“Saving persistent data”).
Conclusion
While this guide does not have a perfect textbook-like structure for learning, I believe it has enough fundamental information to get you going.
If you have read and understood it, and even maybe did some exercises on your own, then congratulations! You should now be able to understand and learn from other Lua scripts and step-by-step create more and more complex and interesting modding projects.
If you haven’t understood some things, that’s okay. Read some real scripts used in the game for more understanding, then try again, or ask here, maybe I can clarify some sections better.
One last message I have for all aspiring scripters: Search. Try. Learn. Don’t just go around and ask people for every new bit of information. Most of it is already there, in Suggestion Box and Help System (F1).
(Yeah this means that I likely won’t be answering simple questions like “Kuber how do I check if a player is alive”, or “pls write me an example code to do X” etc. I may answer some less trivial ones)
There are a few more non-trivial scripting concepts/functions which may not be simple to discover yourself, but I didn’t include them here, as they are less fundamental. Maybe I’ll cover them later in another guide. Alright I’m tired time to release this ♥♥♥♥.
I would like to thank nano and Ryason for the feedback on this guide and help with writing some sections.
I hope you enjoy the Guide we share about Serious Sam 4 – The Scripting Guide; if you think we forget to add or we should add more information, please let us know via commenting below! See you soon!
Leave a Reply