VoltMod
C++23 framework for CS2 server plugins
Loading...
Searching...
No Matches
Writing a plugin

The smallest plugin

A plugin is a library with a plugin.json beside its CMakeLists.txt and an App class in src/App.hpp that owns everything for one load cycle.

// src/App.hpp
#pragma once
#include <VoltMod/Api.hpp>
namespace MyPlugin
{
struct App final : VoltMod::Plugin
{
explicit App(VoltMod::Runtime& runtime) : Plugin(runtime) {}
/** Register commands. Returning false aborts the load. */
bool Load() override;
/** Loaded first, so every member below is built with settings. */
ConfigManager Config = VoltMod::LoadConfig<ConfigManager>(Runtime);
private:
/** Declared last, so handlers stop before the state they capture goes away. */
};
} // namespace MyPlugin
Everything a plugin owns for one load cycle.
Definition Plugin.hpp:15
Framework services for one load and unload cycle.
Definition Runtime.hpp:57
Holds the subscriptions an object makes and releases them together, newest first.
static std::string ReadFile(const std::filesystem::path &path)
Definition Loader.cpp:56
// src/App.cpp
#include "App.hpp"
#include <VoltMod/Api.hpp>
namespace MyPlugin
{
void RegisterCommands(VoltMod::CommandManager& commands); // defined in src/Commands.cpp
bool App::Load()
{
RegisterCommands(Runtime.Commands);
return true;
}
} // namespace MyPlugin
Typed commands for chat and the server console.

There is no entry macro. voltmod_add_plugin generates the entry point for <Namespace>::App, where the namespace is the plugin name with each word capitalized (admin-system is AdminSystem), and fails at configure time when src/App.hpp is missing. The generated file defines the plugin object, this module's hook dispatch pointer and VoltMod_Plugin, the one symbol the host resolves. Its descriptor carries the VoltMod version the plugin was built with and the short commit of the plugin's own repository.

Your App derives from VoltMod::Plugin. The framework builds the runtime with every service ready, constructs the App from Runtime&, and destroys it before the runtime shuts down.

Override Required Called
bool Load() no Once, after every member is built and the settings loaded; false aborts the load

Map starts and chat are events, not overrides: Runtime.Map.Started carries the map name, and Runtime.Players.Said carries each chat line no menu or command took (set Blocked to keep it out of chat). Keep custom engine hooks, signals and timers in the App's own Subscriptions.

Members initialize in declaration order, so an initializer may reference only members above it. A member that subscribes or registers commands can do it in its constructor; work that can fail the load, or that acts outside the plugin (a server convar, a published service), belongs in Load. The App is destroyed before the Runtime, which is what lets its subscriptions unregister while the services they point at are still alive.

What the scaffold creates

voltmod new plugin <name> writes plugins/<name>/:

File Holds
CMakeLists.txt one voltmod_add_plugin(<name>) call
plugin.json the manifest below
src/App.hpp, src/App.cpp the load-cycle object graph and its Load, which requires addonId and draws menus on the layout when menu.panorama is on
src/Commands.cpp the !ping command
src/Config.hpp the settings struct and ConfigManager
configs/settings.jsonc operator settings
README.md what the plugin does, its commands and settings
translations/en.json player-facing text
panorama/screens/<name>_menu.xml.j2, .css.j2 the menu layout; voltmod build renders it and Ui/<Pascal>Menu.hpp
content/ workshop sources: models, particles, sounds for the CS2 Workshop Tools

Add .cpp files anywhere under src/; voltmod_add_plugin globs them.

plugin.json

The manifest is hand-written and lives beside CMakeLists.txt. CMake reads name and version from it at configure time; the host reads the copy installed at addons/voltmod/plugins/<name>/plugin.json. An unknown key is an error and the plugin is refused.

{
"$schema": "https://raw.githubusercontent.com/voltygg/voltmod/main/templates/plugin.schema.json",
"name": "my-plugin",
"version": "1.0.0",
"logTag": "MYPLUGIN",
"description": "",
"author": "",
"website": "",
"license": "",
"dependencies": [],
"optionalDependencies": []
}
Key Type Default Meaning
$schema string none Points editors at the plugin schema, so they complete and check keys. The host ignores it.
name string required The plugin's directory under addons/voltmod/plugins/ and its CMake target. All three must match.
version string required Its version. volt list prints it with the commit the plugin was built from: v1.0.0 (2dfd824).
logTag string name The prefix the host puts in front of every log line from this plugin.
description string "" One line, printed after the version by volt list.
author string "" Credit. The host does not print it.
website string "" The plugin's page or repository. Credit, like author.
license string "" An SPDX id such as MIT, or proprietary. Credit only.
logLevel string "info" The level the plugin starts at: info, warn or error. volt log <name> <level> changes it until the next load; another value refuses the plugin.
dependencies string[] [] Plugins this one is refused without.
optionalDependencies string[] [] Plugins it is better with; never a reason to refuse it.
database object none migrations, header and namespace for voltmod database header, relative to the plugin directory. The host ignores it.

Neither list decides load order. dependencies decides whether the plugin loads at all and what a reload takes down with it; see Running the host.

The connection lifecycle is not an override. Subscribe to Runtime.Players.Connected, .FullyConnected, .SettingsChanged and .Disconnected; see Players, targeting, and actions. Custom hooks are in Movement, teleports, damage, weapon drops, and server commands, typed game events in ConVars and game events.

Load report

runtime.LoadReport records what failed while the plugin loaded. The runtime checks its own services there when it is built; add a check with the VoltMod::Status your own work returns.

auto& report = Runtime.LoadReport;
const bool database = report.Optional("Database", ConnectDatabase());
if (database)
report.Optional("Admins", LoadAdminData()); // no second error while it is down
if (!report.Required("Migrations", Migrate()))
return false;

Optional continues without that feature. Required is for work the plugin cannot run without: the framework hands the first required failure to the host as <name>: <reason>, which the host logs as the refusal. It checks once the App is built, before Load, and again when Load returns false. Both return whether the check passed. After the load the framework logs Load took N ms with one line per failure. Work that cannot fail needs no check.

The standard prelude - settings as a required check, then translations - is the Config member's initializer:

ConfigManager Config = VoltMod::LoadConfig<ConfigManager>(Runtime);
ConfigManager Config = VoltMod::LoadConfig(Runtime, ConfigManager{&BuildSnapshot}); // with a builder
TConfig LoadConfig(Runtime &runtime, TConfig config={}, const LoadConfigOptions &options={})
Load the plugin's settings, then its translations, and return config.

It reads addons/voltmod/plugins/<plugin>/configs/settings.jsonc and then translations. Pass {.SettingsFile = "configs/other.jsonc"} or {.Translations = false} as the third argument to change either. A broken file leaves the defaults in place, and the plugin is refused with Configuration: <path>: <reason> before Load runs, so members built below it never act on them. See Configuration.

Status sections

runtime.Status combines named diagnostic sections into one report. The framework registers build, load and uptime. Add your own in Load and expose the report as a console command:

Runtime.Status.RegisterSection("db", [this] {
return VoltMod::Json::Write(DbSection{.connected = Db.IsConnected()});
});
Runtime.Status.InstallCommand("my_status", "Report plugin health; 'my_status json' emits STATUS_JSON.",
[this] { return Db.IsConnected(); });
static std::string Write(const T &value)
Serialize value as compact JSON.
Definition Json.hpp:96

my_status prints text, my_status json emits one STATUS_JSON {...} line for RCON tooling, and volt status <name> prints the same JSON from the host. The top-level healthy value is the predicate's answer, or true when there is no predicate. The command unregisters on unload.

Keep sections compact - counts and names, not full lists - because RCON console capture truncates long responses. A section capturing this must live on an object the Runtime outlives.

Logging

Log::Info, Log::Warn and Log::Error from <VoltMod/Core/Log.hpp> format with std::format and hand the line to the host, which prefixes the plugin's logTag. The host also sets the minimum level, so a line below it is never formatted.

Cleanup on unload

Nothing survives the App's destructor, so a volt reload starts from clean state. Cleanup belongs in a member destructor or in a VoltMod::Subscription held beside the state its handler captures:

class Bhop
{
Bhop(VoltMod::Runtime& runtime) : _rt(runtime)
{
_spawn = _rt.GameEvents.On<VoltMod::PlayerSpawn>([this](const VoltMod::PlayerSpawn& e) { OnSpawn(e.Slot); });
_slots = _rt.Slots.Changed += [this](int slot) { _state.Reset(slot); };
}
VoltMod::Subscription _spawn; // destroyed before the members above it
};
Owns one registration and releases it on destruction.

Events, game events, scheduler timers and scoped hooks return a [[nodiscard]] Subscription that unregisters on destruction; dropping a scheduler subscription cancels the timer. Draining a database or withdrawing a published interface belongs in the App destructor. Commands are owned by CommandManager for the load cycle and need no cleanup.

Whatever the plugin still holds when its view closes, the host takes back and logs:

my-plugin left a frame subscription behind; the host dropped it.
my-plugin left the service 'bans.IBanService/1' published; the host withdrew it.

Each of those is a bug in the plugin: something outlived the App that built it. Command names are different: the host removes them with the plugin and says nothing.

Install layout

voltmod_add_plugin defines an install component named after the plugin. Staged into a server's game/csgo, the tree is:

addons/
voltmod/
bin/win64/ or bin/linuxsteamrt64/
server_valve.dll the loader; libserver_valve.so on Linux
voltmod.dll the host; voltmod.so on Linux
gamedata/gamedata.jsonc
gamedata/dumps/schema.json written by the server once a map has run
gamedata/dumps/resolved.json written by the server once per game build
plugins/my-plugin/
plugin.json
my-plugin.dll or my-plugin.so
configs/ the operator's: seeded once, never overwritten
settings.jsonc
translations/en.json replaced on every install, like migrations/, data/ and server-assets/
server-assets/ compiled workshop files the server itself loads

A plugin has no bin directory of its own. runtime.PluginFile("data/x") builds addons/voltmod/plugins/<name>/data/x for any file the plugin reads at run time. Put files an operator tunes under configs/ and files the plugin ships under data/.

Compiled workshop files the server needs, such as models, particles, sound events or an override of a game file like scripts/weapons.vdata_c, go under server-assets/. While the plugin is loaded, the host mounts that folder ahead of the game's own VPKs, which a loose copy under game/csgo would lose to. The game reads weapon subclasses when a map loads, so a plugin loaded mid-map has them from the next map.

voltmod install <name> stages and merges both trees; with no name, every plugin's. By hand:

cmake --install build/<preset> --component host --prefix dist
cmake --install build/<preset> --component my-plugin --prefix dist

then copy dist/addons into game/csgo, leaving operator-edited settings alone, and put Game csgo/addons/voltmod directly above Game csgo in gameinfo.gi so the engine finds the loader. voltmod serve and voltmod run add that line themselves.