Update: The VDC example for listening to game event callbacks has since been updated to highlight the dangers of using ClearGameEventCallbacks()
correctly, 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. Unfortunately, VScript and how it interacts with MvM requires some special treatment that most of the existing tutorials do not consider. 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
You need to wrap all of your code into a table that gets cleaned up on mission/wave end, otherwise it will bleed into other missions on the same map.
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 Right 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. The following code is the standard format we would prefer you use:
::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. Obviously MyNamespace
can be whatever you want. 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)
If you were confused by the this
keyword before, it's just a reference to the current scope table. __CollectGameEventCallbacks(this)
is essentially the same thing as __CollectGameEventCallbacks(getroottable())
. __CollectGameEventCallbacks(MyNamespace.Events)
instead only looks for event hooks that are inside of your Events
subtable. If we delete the table all of our event functions 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 with a map that also has some packed in VScript. If you use this function you will totally mess up any other scripts besides your own. If you use this function in your libraries or in the scripts packed into your map, the user needs to awkwardly dance around this function to find the right place to run their own code. 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.
Popextensions+
The popextensions+ library is entirely self contained and will delete itself on its own. If you are using this library alongside other code, you can take advantage of this by hooking your own functions into the library. This can introduce a lot boilerplate to your code, but if you don't want to deal with cleanup it will work.
PopExt.MyFunctions <- { //make a subtable with all of our extra functions
function FunctionName() {
//
}
}
PopExt.MyFunctions.FunctionName()
The PopExt
table automatically gets cleaned up on wave end, including anything you add to it, no extra cleanup necessary.
Conclusion
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 most tutorials, you will end up with a total mess that will bleed into other people's missions. It's rude, lazy, and in my opinion should be considered a newbie mistake to haphazardly throw everything into root for not just MvM but TF2 VScript as a whole. Even outside of MvM, you should at least do this for your event callbacks and AVOID using ClearGameEventCallbacks().