Plugin lifecycle
The host finds named publics on module.exports. OnPluginStart runs once at load; missing optional exports are not subscribed. One plugin module = one of each public.
import { previous } from '@s2script/sdk';
let reloads = 0;
export function OnPluginStart(): void {
// first load: previous() is undefined
// hot-reload: previous() is the value the old instance's OnPluginState returned
const prev = previous() as { reloads: number } | undefined;
reloads = (prev?.reloads ?? 0) + 1;
}
export function OnPluginState(): unknown {
return { reloads }; // captured, serialized, and revived as the next previous()
}
export function OnPluginEnd(): void {
// best-effort cleanup (the ledger is the real teardown authority)
}The load window#
The load window is the time while OnPluginStart runs. Registration APIs work only inside it. If OnPluginStart returns a promise, the window stays open until that promise resolves. The docs call the moment it closes settle: after settle, registration APIs throw outside the load window.
| Runs | In the load window? |
|---|---|
| Top-level module code (imports, consts) | No, it runs before |
OnPluginStart, including its awaits | Yes |
| Command, event and timer callbacks | No |
Other publics (OnMapStart, …) | No |
Load-window APIs include command, hook.on / hook.onPre, translations.load, onOutput, createScope, previous(), pluginId(), publish / use / tryUse / watchOptional / bindForwards, and on CS2 CustomHudLayout.create and items.
Everything registered in the load window is torn down when the plugin unloads. OnPluginEnd is for best-effort cleanup of your own state.
Named publics#
Plugin: OnPluginStart / OnPluginEnd / OnPluginState / OnAllPluginsLoaded / OnConfigsExecuted
Map/frame: OnMapStart(map) / OnMapEnd() / OnGameFrame() / OnPrecache(pc)
Client: OnClientConnected / OnClientPutInServer / OnClientActive / OnClientPostAdminCheck / OnClientDisconnect / OnClientSayCommand / OnClientSettingsChanged / OnClientVoice / OnClientCookiesCached / OnPlayerRunCmd
Entity: OnEntityCreated / OnEntitySpawned / OnEntityDestroyed
| Export | Notes |
|---|---|
OnClientConnected(client) | Pre-auth |
OnClientPutInServer(client) | ClientPutInServer |
OnClientActive(client) | CS2 signon; steamId is reliable here |
OnClientPostAdminCheck(client) | Steam ticket validated; admin cache is host-global |
OnClientDisconnect(client) | Synchronous read-only identity snapshot; isValid() already false. Capture identity before any await |
OnClientSayCommand(slot, text, teamonly) | Return HookResultValue; Handled / Stop suppress broadcast |
OnClientSettingsChanged(client) | Client convars/settings changed |
OnClientVoice(client) | Voice packet (per-frame while speaking) |
OnClientCookiesCached(client) | Persisted cookies are readable. Writes go through Cookies.set / setAuthId (boolean admission) — Cookies |
OnPlayerRunCmd(cmd, info) | Per-tick usercmd; return HookResult.Handled to block the tick |
OnEntityCreated(entity, className) | Subscribe is always "*"; filter className in the handler |
OnEntitySpawned(entity, className) | Same — filter in the handler |
OnEntityDestroyed(entity, className) | Same — the ref goes stale right after |
OnTakeDamage(info) | Mutate DamageInfo in place (info.damage /= 2) |
OnPrecache(pc) | Precache window; pc is valid only synchronously |
Per-entity damage is also SDKHook (SDKHookType.OnTakeDamage / OnTakeDamagePost) — books-gated, not load-window-only. Pre mutates DamageInfo; post is read-only. See SDKHooks. Filtered entity I/O is the free onOutput, not a public.
Client publics fire for clients that connect after this plugin is Active. To cover already-connected clients, seed in OnPluginStart: for (const c of Clients.all()) { … }. There is no framework replay.
Client handles#
A Client is a handle to one connection lifetime, not merely a slot. Reusing a slot — even for the same SteamID — never revives a saved handle. client.slot names the engine slot; it is not enough to identify the connection. CS2 Player refs follow the same lifetime.
Stale handles: isValid() is false; steamId is "0"; name / ip are ""; userId / signonState are -1; booleans are false; void actions no-op; command-style actions (command, fakeCommand) return false.
OnClientDisconnect(client) receives a synchronous read-only identity snapshot during the callback (isValid() is already false). Capture identity before any await; after the sync callback returns, stale defaults apply.
Delayed work, menus, votes, HUD input, player refs, cookies, and voice follow that lifetime — a saved handle must not act on a replacement in the same slot. Clients.fromSlot(n) is the occupant now, which is a different handle after reconnect. Interop payloads that identify a player (for example zone enter / leave / stay) key by connection userId — resolve Player.fromUserId(event.userId) rather than treating a copied slot as identity. See zones.
Retained HUD views (HudPlayer, ModalView, DashboardView, BadgeView, MotdHandle, HudKitPlayer) expose isValid() for the same reason. Slot-first APIs adopt the current occupant; a saved view must not drive a replacement in that slot. See HUD.
Game frame#
SourceMod has only OnGameFrame (before simulation). There is no OnGameFramePre / OnGameFramePost.
Post-simulation paint is Metamod’s post hook, subscribed in the load window:
import { createScope } from '@s2script/sdk';
export function OnPluginStart(): void {
createScope().server.onGameFrame(
() => {
// health after damage, movetype after a move — last tick's values if you read in OnGameFrame
},
{ phase: 'post' }
);
}createScope throws after settle. A HUD that must never delay gameplay work in the same frame also passes { priority: 'low' }.
OnAllPluginsLoaded#
OnAllPluginsLoaded fires after the plugin is Active once no Loading or Waiting plugins remain. The load window is sealed at that point. tryUse is a one-time optional lookup (call it from OnPluginStart; it may still be null and does not follow later availability). watchOptional also registers during the load window; attachment runs later after both plugins are Active. This public means “the set is stable,” not a second registration window.
A late sm plugins load fires it immediately when that plugin becomes Active and the set is quiet. That includes sm plugins load <folder>/<plugin id> (and Plugins.load with the same spec) for a plugin kept in a folder under plugins/ for this run only. sm plugins enable / disable (and s2s enable / s2s disable) are different: they move a managed plugin between plugins/ and plugins/disabled/ and record it in plugins.json, so the change survives a restart. See Install a plugin without loading it.
Hot reload handoff#
On a same-id file-watch Reload, the old instance’s OnPluginState return is serialized (JSON + EntityRef revival) and handed to the new instance as previous().
- Primitives, strings, arrays, and nested objects round-trip.
EntityRefvalues revive live and liveness-gated in the new context (isValid() === falseif the entity died in the gap).- Carry 64-bit values as decimal strings —
bigintcannot beJSON.stringify‘d and would drop the whole handoff.
Vanished (delete the .s2sp) clears any pending handoff. A later re-add is a fresh load with previous() === undefined.
Map changes
Plugins persist across changelevel — OnPluginStart does not re-run per map. Export OnMapStart for map-aware work:
export function OnMapStart(mapName: string): void {
console.log('map start', mapName);
}Named publics OnMapStart / OnMapEnd / OnConfigsExecuted share one host subscription on that mux so OnMapStart does not double-fire. CS2 has no LevelShutdown; OnMapEnd is derived from a subsequent map start (StartupServer):
- First map for this plugin:
OnConfigsExecutedthenOnMapStart(noOnMapEnd). - Later maps:
OnMapEnd, thenOnConfigsExecuted, thenOnMapStart.
Server-shutdown OnMapEnd is deferred. Old-map entities are already gone when the next start fires — OnMapEnd is for resetting plugin state, not walking the previous world’s refs.
Teardown#
The host ledger owns every persistent resource (commands, event subs, DB handles, websockets, imported interfaces). Unload of a full set walks reverse-dependency order, so cleanup does not depend on the plugin’s own OnPluginEnd running correctly. Manually unloading one provider leaves hard consumers running and removes bindings to that provider; bindings to other providers remain. After restarting the provider, reload the hard consumer to re-register those forward bindings. Use watchOptional for automatic reattachment across provider generations. OnPluginEnd is for best-effort work, not for releasing framework resources. See interfaces.
HUD surfaces are cleared on plugin unload and reload. A non-focused hudkit modal and a hudkit badge hold an owned, hide-only surface lease while shown, so the host’s owner sweep hides them. badge.release() also hides the badge for every player still seeing it. See HUD.
Runtime load and unload#
Plugins.load / unload (the sm plugins load / unload backend) change what runs this process and touch nothing on disk.
import { Plugins } from '@s2script/sdk/plugins';
Plugins.load('disabled/@edge/rtv');
Plugins.load('fun/@edgegamers/jailbreak');
Plugins.load('events/halloween/@edge/rtv');
for (const p of Plugins.list()) {
if (p.folder) console.log(p.id, p.folder);
}
Plugins.unload('@edge/rtv');Plugins.load(id) still loads a plugin unloaded earlier in the same run. Plugins.load with "<folder>/<plugin id>" also loads one kept in any folder under plugins/ for this run only. The plugin id is the last segment, or the last two when they form @scope/name; everything before is the folder. Only a folder prefix reaches into a folder — a bare id never does. You type the id; it maps to its file by the registry naming rule (@ and / become _, e.g. _edgegamers_jailbreak.s2sp), falling back to the archive whose manifest carries that id. The folder must resolve inside plugins/: .., absolute paths, outbound symlinks, and plugins/ itself are refused.
The file stays where it is. A restart, or Plugins.unload (the same <folder>/<id> form, or just the id), turns it off again. enable / disable are the permanent counterparts: they move the file and edit plugins.json.
PluginInfo.folder is that folder, or null for a plugin loaded from plugins/ itself. sm plugins list shows (running from plugins/<folder>/, this run only). Operators: Getting started.
See Authoring, the server module, the clients module, the plugin module, the plugins module, and SDKHooks (SDKHook is not load-window-only).