Search docs
Search guides, modules and symbols
Lifecycle

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.

RunsIn the load window?
Top-level module code (imports, consts)No, it runs before
OnPluginStart, including its awaitsYes
Command, event and timer callbacksNo
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

ExportNotes
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.
  • EntityRef values revive live and liveness-gated in the new context (isValid() === false if the entity died in the gap).
  • Carry 64-bit values as decimal strings — bigint cannot be JSON.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):

  1. First map for this plugin: OnConfigsExecuted then OnMapStart (no OnMapEnd).
  2. Later maps: OnMapEnd, then OnConfigsExecuted, then OnMapStart.

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).