# Authoring plugins

A plugin is an ordinary npm package that exports `OnPluginStart` (plus optional named publics) and builds to a `.s2sp` archive. Author-time types come from `@s2script/sdk` (engine-generic capabilities) and `@s2script/cs2` (CS2 game types); the engine injects the runtime at load. The host refuses a missing artifact by naming `OnPluginStart` only.

## Scaffold

```bash
npx @s2script/sdk create my-plugin --game cs2
cd my-plugin
npm install
npm run build          # runs `s2s build .`
```

`s2s` is the CLI shipped in `@s2script/sdk`. `create` writes a `package.json`, a strict `tsconfig.json`, an ESLint config (the same pinned rules `s2s build` enforces, so violations are red squiggles in your editor), and a starter `src/plugin.ts` that exports `OnPluginStart` only. The CS2 starter registers `command("hello", …)` from the `@s2script/sdk` barrel and returns `HookResult.Handled` — see [Commands](https://s2script.com/docs/concepts/commands).

Drop the resulting `.s2sp` into `addons/s2script/plugins/`. The runtime watches that directory: drop → load, replace → hot-reload, delete → unload.

## The plugin shape

The host finds named publics on `module.exports`. A missing export is not subscribed. One plugin module = one of each public. CJS module evaluation runs **before** the [load window](https://s2script.com/docs/concepts/lifecycle#the-load-window) — put `publish`, `on`, `bindForwards`, `watchOptional`, and command registration inside `OnPluginStart`, not at module top level. Imports and exported handler definitions may stay top-level. Load-window APIs throw after settle (the same window as `command()`). Register commands with `command` / `command.server` / `command.admin`.

```ts
import { command, hook, onOutput, previous, HookResult, Chat } from '@s2script/sdk';
import type { Client } from '@s2script/sdk';
import { Player } from '@s2script/cs2';

let n = 0;

export function OnPluginStart(): void {
	const prev = previous() as { n: number } | undefined;
	n = prev?.n ?? 0;

	command('hello', (cmd) => {
		cmd.reply(`hi from slot ${cmd.callerSlot}`);
		return HookResult.Handled;
	});

	hook.on('player_spawn', (ev) => {
		const player = Player.fromSlot(ev.getPlayerSlot('userid'));
		if (player) Chat.toSlot(player.slot, 'Welcome!');
	});

	hook.onPre('player_changename', () => {
		return HookResult.Handled;
	});

	onOutput('trigger_multiple', 'OnStartTouch', (ev) => {
		console.log('touch', ev.caller?.name);
	});
}

export function OnClientPostAdminCheck(client: Client): void {
	client.chat('welcome');
}

export function OnPluginState(): unknown {
	return { n };
}

export function OnPluginEnd(): void {
	// best-effort cleanup (the ledger is the teardown authority)
}
```

`hook` is the game-event catalog only: `hook.on` (post) and `hook.onPre` (pre; return `HookResult.Handled` / `Stop` to suppress the client broadcast). The `GameEvent` is valid only synchronously. There is no `hook.client`, `hook.entity`, `hook.server`, or nested `hook.events`.

Load-window APIs — `command` / `command.admin` / `command.server` / `command.onClientCommand`, `hook.on` / `hook.onPre`, `translations`, `publish` / `use` / `tryUse` / `watchOptional` / `bindForwards`, `previous()`, `pluginId()`, `createScope()`, `onOutput`, `topmenu` — throw after settle. On CS2, `CustomHudLayout` (`ui` is a deprecated alias), `gameRules`, `players`, and `items` are load-window exports from `@s2script/cs2` (proxies onto the current load). Pickup gates are `import { items } from "@s2script/cs2"` (`ItemsApi`). Use `hudkit` from `OnPluginStart` or a later callback — methods throw before plugin context exists; claimed handles stay usable after settle. Stateless helpers — `Chat`, `Admin`, `config`, `Translations` — are plain named imports. `Player` / `Pawn` stay on `@s2script/cs2`.

Engine and plugin lifecycle are named publics — [lifecycle](https://s2script.com/docs/concepts/lifecycle) lists them. Filtered entity I/O is the free `onOutput` (SourceMod `HookEntityOutput`), not a public. Post-simulation paint is `createScope().server.onGameFrame(fn, { phase: 'post' })` — Metamod's post hook, not a named public.

## Pickup gates

CS2 CanAcquire votes are the load-window `items` export (`ItemsApi`). Register from `OnPluginStart`; a call after settle throws. Subscriptions tear down with the plugin.

```ts
import { HookResult } from '@s2script/sdk';
import { items, AcquireResult } from '@s2script/cs2';
import type { CanAcquireView } from '@s2script/cs2';

export function OnPluginStart(): void {
	items.onCanAcquire((view: CanAcquireView) => {
		if (view.defIndex === 9) {
			view.result = AcquireResult.NotAllowedByProhibited;
			return HookResult.Handled;
		}
	});
	items.onCanAcquirePost((view: CanAcquireView) => {
		// view.skipped is true when Pre skipped the original
	});
}
```

`CanAcquireView` is valid only during the handler — do not stash it across `await`. Pre may write `result` (seed `Allowed`); Post is read-only. Return `Changed` to vote the current `result`, `Handled` / `Stop` to skip the original and vote the result this handler wrote (or `InvalidItem` when it wrote none). Handled/Stop outrank Changed; any deny beats `Allowed`; the first deny wins. A plugin's gate sees acquisitions it causes elsewhere (`giveNamedItem`). A nested give from **inside** that plugin's own gate callback skips **only that plugin's** gate; other plugins still vote.

Pickup gates and custom-HUD clicks run through the CS2 package adapters (`legacy.acquire.v1`, `legacy.hud-click.v1`) on the engine-function service — not legacy core gamedata hooks. Public hook timing and HUD button-id copy are unchanged.

## TopMenu

`topmenu.addTab({ id, title })` declares a plugin-owned dashboard tab. Then `topmenu.addItem(tabId, item)` under that tab id — not a shared SourceMod category string. [adminmenu](https://s2script.com/plugins/adminmenu) paints `sm_admin` and `sm_menu` / `!menu` as one tabbed hub (`hudkit.dashboard` over `s2_dash` on addon `3790153369`), not a category list you drill into. Empty tabs (no flag-visible items on that sheet) are omitted at paint time. An empty hub replies instead of opening a blank sheet. Pick closes the hub, then `TopMenu.select` — so `onSelect` may open a Menu. Freeze and cursor stay on while the hub is open.

Items default to the admin sheet (`sm_admin`). Opt into the player hub (`sm_menu` / `!menu`) with `sheets: ['menu']` — or both sheets. Unknown sheet names throw `TopMenuInvalidSheet` at register time. Snapshot `tabs` is `{ id, title }[]`; `categories` stays tab ids in registration order.

`addCategory(name)` remains as the id==title form. Shared SourceMod category names (`Player Commands`, `Server Commands`, `Voting Commands`) still work for third-party plugins; first-party plugins declare their own tabs (Players, Bans, Comm, Server, Votes, Maps).

```ts
import { topmenu, ADMFLAG } from '@s2script/sdk';

export function OnPluginStart(): void {
	topmenu.addTab({ id: 'playercommands', title: 'Players' });
	topmenu.addItem('playercommands', {
		id: 'playercommands:slap',
		name: 'Slap',
		flags: ADMFLAG.SLAY,
		onSelect: (slot) => {
			/* pick + slap */
		}
	});
}
```

Player-hub items still set `sheets`:

```ts
topmenu.addTab({ id: 'nominations', title: 'Maps' });
topmenu.addItem('nominations', {
	id: 'nominations:open',
	name: 'Nominate',
	flags: 0,
	sheets: ['menu'],
	onSelect: (slot) => {
		openNominate(slot);
	}
});
```

See the [topmenu module](https://s2script.com/docs/api/modules/topmenu). The cookbook demo command is `sm_menudemo` / `sm_menudemo_chat` — `sm_menu` belongs to adminmenu.

## Imports

Prefer the root `@s2script/sdk` barrel for authoring. Subpath imports (`@s2script/sdk/commands`, `@s2script/sdk/plugin`, …) stay valid. `@s2script/sdk/unsafe` stays a deliberate subpath (not re-exported). `Player` is not on the SDK barrel.

```ts
import { command, hook, onOutput, createScope, HookResult, ADMFLAG, config } from '@s2script/sdk';
import type { Client, DamageInfo } from '@s2script/sdk';
import { Player, ChatColors, items } from '@s2script/cs2';
```

Add `@s2script/sdk` (and `@s2script/cs2` for CS2 plugins) to your package's `dependencies`. Browse every subpath in the [package reference](https://s2script.com/docs/api/packages).

## package.json

Standard npm fields, plus an optional `s2script` block:

```json
{
	"name": "@demo/hello",
	"version": "0.1.0",
	"main": "src/plugin.ts",
	"dependencies": {
		"@s2script/sdk": "^0.5.0",
		"@s2script/cs2": "^0.7.0"
	},
	"s2script": {
		"config": {
			"greeting": { "type": "string", "default": "hello", "description": "Chat greeting" }
		},
		"pluginDependencies": { "@demo/greeter": "^1.0.0" },
		"publishes": { "@demo/hello": "1.0.0" }
	}
}
```

- `dependencies` — npm build deps (`@s2script/sdk`, `@s2script/cs2`, your own libs)
- `s2script.config` — [typed config](https://s2script.com/docs/concepts/config) materialized at load
- `s2script.interfaceProtocol` — `2` for verified typed methods and forwards ([interfaces](https://s2script.com/docs/concepts/interfaces#protocol-2)); omit for protocol 1
- `s2script.pluginDependencies` — hard [inter-plugin](https://s2script.com/docs/concepts/interfaces) deps (prefer producer-as-import)
- `s2script.optionalPluginDependencies` — optional providers followed with `watchOptional`
- `s2script.publishes` — interfaces you `publish`. Protocol 2 uses `"self"` (the package `types` field is the declaration)
- `gamedata/functions.jsonc` — [engine functions](https://s2script.com/docs/concepts/engine-calls). `s2s build` discovers the file, generates `Engine.function` types, and derives `engine:calls` / `engine:hooks` from each function's `surfaces`. There is no handwritten `s2script.gamedata` field, permission list, or generated-file include. Existing v1 plugins: `s2s migrate engine-functions`

## Typecheck gate

`s2s build` typechecks strictly against the shipped `@s2script/sdk` / `@s2script/cs2` `.d.ts` files and refuses to emit a `.s2sp` on any error. A failing file-watch reload leaves the running plugin untouched. New scaffolds include `.s2script/interfaces.d.ts` in `tsconfig.json` `include`; add it to existing projects and refresh with `s2s build` after changing declarations.

The plugin typecheck and bundle target is **ES2024** (`lib: ["ES2024"]`) — the same `target`/`lib` `s2s create` writes into plugin tsconfigs. ES2024 APIs such as `Object.groupBy` pass the gate and are fine at runtime. This is the plugin authoring target; the SDK CLI itself and library package builds stay ES2020.

## Next

- [Publishing to the registry](https://s2script.com/docs/publishing) — orgs, CLI login, `s2s deploy`
- [Inter-plugin interfaces](https://s2script.com/docs/concepts/interfaces) — protocol 2, `watchOptional`, `bindForwards`
- [Plugin lifecycle](https://s2script.com/docs/concepts/lifecycle) — named publics, `previous()`, and map changes
- [Config](https://s2script.com/docs/concepts/config) — typed defaults, override files, live reload
- [Translations](https://s2script.com/docs/concepts/translations) — phrase files, `replyT`, colour tags
- [Engine calls](https://s2script.com/docs/concepts/engine-calls) — `Engine.function` bindings, PRE/POST hooks, and v1 migrate
- [SDKHooks](https://s2script.com/docs/concepts/sdkhooks) — per-entity `SDKHook` / `SDKUnhook` (`OnTakeDamage` / `OnTakeDamagePost`, `SetTransmit`, Spawn/Think/Use, Weapon*/Reload)
- [Pickup gates](https://s2script.com/docs/authoring#pickup-gates) — `import { items } from "@s2script/cs2"` (`ItemsApi`)
- [API overview](https://s2script.com/docs/api/overview) · [package reference](https://s2script.com/docs/api/packages) · [plugin catalog](https://s2script.com/plugins)
