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.
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.
See Authoring, the server module, the clients module, the plugin module, and SDKHooks (SDKHook is not load-window-only).