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#
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.
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 — 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.
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 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.
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 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).
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:
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. 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.
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.
package.json#
Standard npm fields, plus an optional s2script block:
{
"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 materialized at loads2script.interfaceProtocol—2for verified typed methods and forwards (interfaces); omit for protocol 1s2script.pluginDependencies— hard inter-plugin deps (prefer producer-as-import)s2script.optionalPluginDependencies— optional providers followed withwatchOptionals2script.publishes— interfaces youpublish. Protocol 2 uses"self"(the packagetypesfield is the declaration)gamedata/functions.jsonc— engine functions.s2s builddiscovers the file, generatesEngine.functiontypes, and derivesengine:calls/engine:hooksfrom each function’ssurfaces. There is no handwrittens2script.gamedatafield, 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 — orgs, CLI login,
s2s deploy - Inter-plugin interfaces — protocol 2,
watchOptional,bindForwards - Plugin lifecycle — named publics,
previous(), and map changes - Config — typed defaults, override files, live reload
- Translations — phrase files,
replyT, colour tags - Engine calls —
Engine.functionbindings, PRE/POST hooks, and v1 migrate - SDKHooks — per-entity
SDKHook/SDKUnhook(OnTakeDamage/OnTakeDamagePost,SetTransmit, Spawn/Think/Use, Weapon*/Reload) - Pickup gates —
import { items } from "@s2script/cs2"(ItemsApi) - API overview · package reference · plugin catalog