Update: The VDC example for listening to game events has been updated to highlight the dangers of using ClearGameEventCallbacks()
, And the provided example there is mostly fine. Keeping this post up just as a further explanation on why the old way was bad practice
We've seen a lot of people migrating over to VScript for their projects recently, myself included, This is highly encouraged and a great way to future-proof more exotic content that was historically chained to rafmod/sourcemod plugins.
Unfortunately for us, VScript's main use case is purpose-built scripts for specific maps. In the context of MvM, where it is very common to have multiple missions on one map, There are some important considerations that many existing tutorials do not cover. If you were linked here, it may be because you got an error trying to test your code that works fine in vanilla TF2, but is forbidden on our servers, or you were doing something in VScript that we consider to be bad practice in general.
TL;DR
Start encapsulating your code into one big table for easier cleanup. If you end up just throwing everything into the root table as directed by simpler tutorials, you will end up with a total mess that will bleed into other people's missions. Even outside of MvM, you should at least do this for your event callbacks and AVOID using ClearGameEventCallbacks()
.
The Root Table
If you are just starting out with VScript/programming in general, you may not understand the concept of the root table. In short: all of the functions, variables, etc that can be called from VScript are stored into one big table that can be freely called and accessed. You can see behind the curtains on this system by loading any map and running the following console command:
sv_cheats 1; ent_fire worldspawn runscriptcode "foreach(k, v in getroottable()) printl(k + ` : ` + v)"
This command will print out the name and a reference for every possible function/variable.
The "Wrong" Way (for MvM missions)
Lets say Mission Maker A wants to declare a global variable in their code that has +1 added to it every time a player dies. If they follow older tutorials, they might end up with something that looks like this:
::death_counter <- 0
function AddDeathCounter() {
death_counter++
}
ClearGameEventCallbacks()
function OnGameEvent_player_death(params) {
AddDeathCounter()
}
__CollectGameEventCallbacks(this)
The code provided above will be thrown directly into the root table, accessible from every other script. In the context of anywhere else except MvM, there is absolutely nothing wrong with this code, it will work fine and be cleaned up when the server switches to a new map. In most cases, this is also the easiest and most convenient way to write VScript.
Unfortunately for us, this does NOT get cleaned up when switching missions on the same map, this means that writing MvM-specific VScript code that is intended to run on only one mission on a map with multiple other missions, must be written with special consideration.
Furthermore, ClearGameEventCallbacks()
will completely nuke any other event callbacks from other libraries, or our own server-side scripts.
The Problem
In the previous example, the death_counter
variable was still sitting around in the background of other missions constantly counting upwards. This may not sound catastrophic, but imagine if Mission Maker B uploads something to the same map, with the following line of code somewhere
if ("death_counter" in getroottable()) return
::death_counter <- 100
From Mission Maker B's perspective, this code is fine and nothing bad will happen, but because mission maker A came along and put a variable with the same name into the root table, Mission Maker B may be banging their head against a wall wondering why death_counter
exists already, only to find out later that someone else used a variable with the same name before him and messed up his code.
The Better Way
In order to avoid this and write mission-specific code, you need to encapsulate everything you want to run into it's own table that gets deleted when no longer needed. Here's an example:
::MyNamespace <- {
death_counter = 0
function AddDeathCounter() {
MyNamespace.death_counter++
}
Events = {
function OnGameEvent_player_death(params) {
MyNamespace.AddDeathCounter()
}
function OnGameEvent_recalculate_holidays(_) {
if (GetRoundState() != 3) return //pre-round/wave not active
delete ::MyNamespace
}
function OnGameEvent_wave_complete(_) {
delete ::MyNamespace
}
}
}
__CollectGameEventCallbacks(MyNamespace.Events)
There's a lot more to unpack here than the simple examples provided:
::MyNamespace <- {
death_counter = 0
...
This is creating a table called MyNamespace
in the root table. MyNamespace
can be whatever you want, so long as you make it something unique (don't just name it "MyTable" or something). This table will be the table we stuff all of our code into that gets deleted whenever a wave is completed/failed.
Note that to access anything inside of MyNamespace
, including functions, you must use the syntax MyNamespace.variable_name
now.
Events = {
...
function OnGameEvent_player_death(params) {
MyNamespace.AddDeathCounter()
}
...
This is going to be a bit subjective, but when writing VScript it can be convenient to bundle your event hooks into a separate subtable to section them off from other functions/variables that are not related to game events. Format it however you personally want, but we believe this is a more readable approach.
...
function OnGameEvent_recalculate_holidays(_) {
if (GetRoundState() == 3)
delete ::MyNamespace
}
function OnGameEvent_wave_complete(_) {
delete ::MyNamespace
}
...
These two event hooks fire when the wave fails or completes. There are other event hooks that fire around this same time and you can change them if you prefer. What this does is completely wipes out all of our variables, functions, and event hooks in a single delete
statement whenever the wave is completed or failed. If you want the same code to run on multiple waves, you can simply run your script again on the next wave after these events are fired.
}
}
__CollectGameEventCallbacks(MyNamespace.Events)
__CollectGameEventCallbacks(MyNamespace.Events)
only looks for event hooks that are inside of your Events
subtable. If we delete the table all of our event hooks are in, they will automatically get cleaned up as well.
ClearGameEventCallbacks()
Something you may have noticed is we are NOT using ClearGameEventCallbacks()
anywhere, and quite frankly I strongly suggest you stop using it even outside of MvM.
Lets say you want to use a VScript library alongside your own scripts that use event hooks, or the map also has some packed in VScript. If you use this function in your scripts, you will need to awkwardly dance around this function to find the right place to run your code without messing with any other scripts. None of this is a problem if we bundle all of our code into a table and delete the entire table when we're done, without wiping out every other game event that may be present.