[TOC]
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>
#include <VoltMod/Core/Signals/Subscriptions.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. */
VoltMod::Subscriptions _subs;
};
} // namespace MyPlugin// 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 MyPluginThere 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 @ref 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.
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.
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/VoltyGames/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 @ref host_guide.
The connection lifecycle is not an override. Subscribe to Runtime.Players.Connected,
.FullyConnected, .SettingsChanged and .Disconnected; see @ref players_guide.
Custom hooks are in @ref sdk_hooks_guide, typed game events in @ref sdk_events_guide.
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 @ref 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 builderIt 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 @ref config_guide.
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(); });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.
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.
Nothing survives the App's destructor, so a volt reload starts from clean state. Cleanup belongs
in a member destructor or in a @ref 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
VoltMod::Subscription _slots;
};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.
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 distthen 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.