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

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

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), and [SDKHooks](https://s2script.com/docs/concepts/sdkhooks) (`SDKHook` is not load-window-only).
