Search docs
Search modules and symbols
Authoring plugins

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. Register load-window APIs inside OnPluginStart — they 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, previous(), pluginId(), createScope(), onOutput, topmenu — throw after settle. On CS2, ui, gameRules, players, and items are load-window exports from @s2script/cs2 (proxies onto the current load). 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.

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 } 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 for engine facts:

{
	"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.configtyped config materialized at load
  • s2script.pluginDependencies — hard inter-plugin deps (prefer producer-as-import)
  • s2script.publishes — interfaces you publish for other plugins
  • s2script.gamedata / s2script.permissionsengine calls and inbound hooks (engine:calls, engine:hooks)

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.

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

s2script — Source 2 plugin framework

GitHub