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.

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.

See Authoring, the server module, the clients module, the plugin module, and SDKHooks (SDKHook is not load-window-only).