Creating a CS2 plugin is easy, actually
Recently a friend of mine asked me:
and in my bored (and half-asleep) mind, i responded:
But i could certainly figure it out. After all, CS already has “glows” that allow you to see people through walls for spectating purposes and seeing your own teammates. What this man here wanted was to be able to “give” a single player wallhacks so that he could organize 1v5 matches where the single player has an advantage over the other team. Seems pretty doable: apply a glow to the team that is not the single player every round.
so i started googling things and figured out that CS2 has a plugin framework called CounterStrikeSharp that can be used to develop plugins. As the name implies, that is in C#, my language of choice! So i got started.
I intend for this to be a post about how i figured things out, and i hope some of that can be of value if you’re trying to learn how to do this yourself too. This “Guide” assumes you have a CS2 server running and have installed CS# and Metamod: Source. It also assumes you have basic to mid-level knowledge of C# and the .NET ecosystem.
Getting started
The CS# Documentation explains that CS# plugins are just C# class libraries loaded by the server, which means that we can start our plugin by doing
1
dotnet new classlib --name cs2-glow-plugin
which will make a new class library with an empty class Class1
. After that we need to add CS#’s API:
1
dotnet add package CounterStrikeSharp.API
Now that we’ve got an empty class and our dependency we can turn it into our plugin:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//imports left out
namespace cs2_glow_plugin;
public class Plugin : BasePlugin
{
public override string ModuleName => "CS2 glow plugin";
public override string ModuleVersion => "1.0";
public override void Load(bool hotReload)
{
Console.WriteLine($"cs2-glow-plugin {(hotReload ? "hot" : "")} loaded!");
base.Load(hotReload);
}
}
Now, before we continue..
CS# Comes with a Hot-reloading functionality (as that last code snippet suggests). What this means for you as a developer is that you don’t need to restart your server between plugin changes! After you’ve made any changes, simply rebuild your plugin and drop your plugin into CS#’s plugin folder.
Of course, that’s manual labor every time you’re writing changes, and we don’t really like repetitive manual labor. So what you can do is go to your project’s properties (under the “build” tab) and add a post-build step to automatically overwrite the plugin the server is running with your new version:
my step looks like this, but the important part is
1
xcopy /y \path\to\your\debug\output\*.* \path\to\your\plugin\folder\your_plugin\
it’s worth noting that this will only copy debug output, not a release version!
Now that you’ve set that up, all that’s left to do is write some code and build it to immediately see our changes in-game, which makes it really easy to quickly check if what you just wrote actually works.
Now for writing an actual plugin
What we want is for anyone with server console access to effectively be able to do give_wallhack (player)
to apply a glow to everyone else. Luckily, CS# has the command API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//using these annotations you can register a command, specify what the command is called, and what will be sohown when someone tries to use it wrongly
[ConsoleCommand("give_wallhack", "gives a player wallhacks")]
[CommandHelper(minArgs: 1, usage: "[target]", CommandUsage.SERVER_ONLY)]
public void GiveWalls(CCSPlayerController? CommandSendingPlayer, CommandInfo command)
{
//for this plugin i'm only allowing someone with server console access to use the command
if (CommandSendingPlayer is not null)
{
Console.WriteLine("Command was sent by player, ignoring.");
return;
}
var target = command.GetArg(1); //argument 0 is the command itself
if (string.IsNullOrEmpty(target)) //this shouldn't be hit, but i'm doing it just to be certain
{
return;
}
var AllPlayers = GetPlayers();//this is a helper method i wrote to get all players in a server
CCSPlayerController? BenefittingPlayer = AllPlayers.Where(p => p.PlayerName.Contains(target, StringComparison.OrdinalIgnoreCase)).FirstOrDefault();
if (BenefittingPlayer is null)
{
Console.WriteLine("Player not found");
return;
}
//i've left the implementation of this out of this snippet, but you can find the rest of this file on GitHub
ApplyGlowToAllOtherPlayers(BenefittingPlayer.SteamID);
}
And then we’ve got a command to do this with. Now when we do give_wallhack {player}
that player will see everyone else glow, even through walls! Now that we’ve got our neat little plugin done, let’s move on to a more serious attempt at modding.
Actually making better plugins
While writing all of your hooks, listeners and utilities in one file is nice if you’re writing a small plugin, it doesn’t really scale well once it gets a bit more complicated. As for me, i figured that once i was done creating my little one-off plugin i’d instead make something a bit more general for the next one.
Enter CS2 Randomizer: A plugin that randomizes every player’s loadout, every round! this includes weapons, armor, grenades, and bombs. For this project i wanted a bit more of a well-structured project, so i got to work:
The debugger is cool and you should use it
Before we begin it’s good to know about debugging CS# plugins.
CS#, when run, has a very interesting property: it turns your CS2 server into an attachable debug target. This allows you to do something very interesting: Use breakpoints and other debugging tricks in your plugin to control your execution flow and quickly see what is going wrong and improve it.
I’m assuming you’re doing this on Windows and using Visual Studio, but this should work similarly on Linux and using other IDE’s.
When you have the plugin running in your server, CS# will turn that server into a debug target, which means you can attach a debugger to your server and do this with your plugin while your server is running!
Keep in mind that a breakpoint actually pauses execution! players that are connected to your server when your breakpoint is hit will lose connection if you don’t resume execution within the connection timeout window.
Splitting up your classes
Something that irks me in the last plugin is the fact that we kinda just… write everything in one file. Of course, this is allowed, the plugin will work fine regardless (because the compiler doesn’t really care), but it looks kind of messy once you introduce more complexity and features. So, let’s split up those classes in the new plugin. We’ll start out by moving our commands to their own file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class Commands
{
private readonly ILogger _logger;
public Commands(ILogger logger) {
_logger = logger;
}
[ConsoleCommand("enable_randomizer", "Enables the randomizer plugin")]
[CommandHelper(minArgs: 0, usage: "enable_randomizer", whoCanExecute: CommandUsage.SERVER_ONLY)]
public void EnableRandomizer(CCSPlayerController? player, CommandInfo commandInfo)
{
if (player is not null)
{
return;
}
Plugin.RandomizerEnabled = true;
_logger.LogInformation("Randomizer enabled!");
}
[ConsoleCommand("disble_randomizer", "Disables the randomizer plugin")]
[CommandHelper(minArgs: 0, usage: "disable_randomizer", whoCanExecute: CommandUsage.SERVER_ONLY)]
public void DisableRandomizer(CCSPlayerController? player, CommandInfo commandInfo)
{
if (player is not null)
{
return;
}
Plugin.RandomizerEnabled = false;
_logger.LogInformation("Randomizer Disabled!");
}
}
And move our event listener(s) to another:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Events
{
private readonly ILogger _logger;
private readonly Randomizer randomizer;
public Events(ILogger logger) {
_logger = logger;
randomizer = new Randomizer(logger);
}
[GameEventHandler]
public HookResult OnRoundStart(EventRoundStart gameEvent, GameEventInfo eventInfo)
{
_logger.LogDebug("Round start");
if (Plugin.RandomizerEnabled)
{
randomizer.RandomizeLoadouts();
}
return HookResult.Continue;
}
}
Like this, I’ve also split up the randomizer’s logic and some utility classes into their own files. I won’t put all of the code here, but you can view the full project Here if you want to see for yourself.
What’s important to remember when you do this is that you need to register these! If you don’t, your command handlers and event hooks won’t be available. You can register them like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Plugin : BasePlugin
{
public override string ModuleName => "CS2 Randomizer";
public override string ModuleVersion => "0.5";
public static bool RandomizerEnabled = true;
public override void Load(bool hotReload)
{
Logger.LogInformation($"CS2 Randomizer {(hotReload ? "hot " : "")}Loaded !");
//these will register command attributes and other attributes
RegisterConsoleCommandAttributeHandlers(new Commands(Logger));
RegisterAllAttributes(new Events(Logger));
base.Load(hotReload);
}
}
which makes it look at lot more organized.
It’s not bad to ask for help
Something i ran into was that the CS# documentation quickly ends up falling short once you try to do some decently advanced things. Luckily, the CounterStrikeSharp Discord has a lot of very smart people in it who have (very likely) already tried to do the thing you’re trying to do. Remember to do a quick ctrl+F before you ask a question though - as I said, Someone has probably already tried to do what you are currently trying to do!
With that being said, have fun developing plugins!