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)
} previous() and pluginId() are load-window only. Call them from OnPluginStart, not from a later public. The other load-window APIs (command, hook, translations, onOutput, createScope, …) also throw after settle.
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) | |
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 |
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) — books-gated, not load-window-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.
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 stays load-window-only (call it from OnPluginStart; it may still be null). 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.
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 walks the ledger in reverse-dependency order, so cleanup does not depend on the plugin’s own OnPluginEnd running correctly — OnPluginEnd is for best-effort work, not for releasing framework resources.
See Authoring, the server module, the clients module, the plugin module, and SDKHooks (SDKHook is not load-window-only).