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

```ts
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 `await`s | 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`.

> [!TIP]
> Register everything in `OnPluginStart`. To add handlers later (for example after a map loads), create a `Scope` with `createScope()` during the load window and register through it later.

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()`](#map-changes) / `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](https://s2script.com/docs/concepts/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`](https://s2script.com/docs/concepts/sdkhooks#ontakedamagepost)) — books-gated, not load-window-only. Pre mutates `DamageInfo`; post is read-only. See [SDKHooks](https://s2script.com/docs/concepts/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](https://s2script.com/docs/concepts/interfaces#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](https://s2script.com/docs/concepts/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:

```ts
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](https://s2script.com/docs/getting-started#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:

```ts
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](https://s2script.com/docs/concepts/interfaces#optional).

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](https://s2script.com/docs/concepts/hud).

## Runtime load and unload

`Plugins.load` / `unload` (the `sm plugins load` / `unload` backend) change what runs **this process** and touch nothing on disk.

```ts
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](https://s2script.com/docs/getting-started#load-and-unload-for-this-run).

See [Authoring](https://s2script.com/docs/authoring), the [server module](https://s2script.com/docs/api/modules/server), the [clients module](https://s2script.com/docs/api/modules/clients), the [plugin module](https://s2script.com/docs/api/modules/plugin), the [plugins module](https://s2script.com/docs/api/modules/plugins), and [SDKHooks](https://s2script.com/docs/concepts/sdkhooks) (`SDKHook` is not load-window-only).
