Inter-plugin interfaces
Plugins talk through typed, versioned interfaces — methods as natives, events as forwards. Opt in to interface protocol 2 for verified typed methods, notifications, hooks, and transforms. Protocol 1 remains available while a producer and its callers migrate together.
A producer publishes an implementation. Hard-dep consumers use name-inferred use(name), producer-as-import named methods, producer-as-import on, or bindForwards. Optional consumers use watchOptional. Protocol 2 on(...) — including import { on } from '@demo/…' — returns a consumer-owned Subscription (dispose() is idempotent; ignoring the return remains valid). The host also ledgers subscriptions and drops them on unload.
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.
Declare deps under s2script.pluginDependencies or optionalPluginDependencies. publish / use / tryUse / watchOptional / bindForwards / producer-as-import on(...) are load-window only. Per-frame forwards go on OnGameFrame (before simulation), not hook.server.
See the interfaces module and the plugin module.
Protocol 2
Set s2script.interfaceProtocol to 2 on both producer and consumer. A producer publishes one declaration, selected by its package types field.
{
"name": "@demo/counter",
"version": "1.0.0",
"types": "api.d.ts",
"main": "src/plugin.ts",
"s2script": { "interfaceProtocol": 2, "publishes": "self" }
}// api.d.ts
import type { Notification } from '@s2script/sdk/interfaces';
export interface Contract {
methods: { getCount(): number; setCount(count: number): void };
forwards: { OnCountChanged: Notification<{ count: number }> };
}import { publish } from '@s2script/sdk/plugin';
let count = 0;
export function OnPluginStart(): void {
const counter = publish('@demo/counter', {
getCount: () => count,
setCount(next: number): void {
count = next;
counter.emit('OnCountChanged', { count });
}
});
}import { use } from '@s2script/sdk/plugin';
import { getCount, on } from '@demo/counter';
export function OnPluginStart(): void {
const counter = use('@demo/counter');
counter.on('OnCountChanged', (event) => console.log(event.count));
on('OnCountChanged', (event) => console.log(event.count));
console.log(getCount());
}publish(name, impl) and use(name) infer types from the provider name. Do not pass caller-selected generics. Authored InterfaceContracts augmentations do not authorize protocol 2 builds.
The CLI generates .s2script/interfaces.d.ts for name inference. New scaffolds include it in tsconfig.json; add it to existing projects’ include list. Refresh with s2s build after changing declarations.
s2s add for a plugin dep downloads types only (registry metadata + types artifact), not the producer binary. Workspace siblings use the producer declaration directly.
Producer method agreement is checked even without an explicit implementation annotation. Each implementation must accept every declared input and return the declared synchronous result; async methods, value-returning implementations of void methods, and narrowed input types are rejected.
Contract value exports must agree with Contract.methods; extra functions, constants, enums, and aliases to runtime values are rejected. Type aliases, interfaces, and explicit type-only exports remain fine.
Named forward bindings
Use .on for an inline callback: the selected provider contract infers the payload, and the returned Subscription controls that exact registration. Use bindForwards(name, handlers) when local handler names should differ from the provider’s forward names:
import { bindForwards, command } from '@s2script/sdk';
type RaceResult = { elapsedMs: number };
type ParkourResult = { checkpoints: number };
export function OnRaceFinished(event: RaceResult): void {
console.log(event.elapsedMs);
}
export function OnParkourFinished(event: ParkourResult): void {
console.log(event.checkpoints);
}
export function OnPluginStart(): void {
const racing = bindForwards('@demo/racing', {
OnRunFinished: OnRaceFinished
});
const parkour = bindForwards('@demo/parkour', {
OnRunFinished: OnParkourFinished
});
command.server('sm_disable_integrations', () => {
racing.dispose();
parkour.dispose();
});
}Forward keys stay qualified by the provider passed to bindForwards, so the same OnRunFinished name can have a different payload in each map. Binding is explicit: exported functions are never discovered or matched automatically. The whole map registers during the load window, rolls back if any entry fails, and the returned Subscription owns every registration from that call (dispose is idempotent). Types reject unknown forward keys, swapped same-name provider handlers, and async decision handlers.
bindForwards requires a hard pluginDependencies entry and does not open the optional attachment window. Optional integrations keep watchOptional, then pass named local functions to the supplied service.on(...) inside the attach callback.
Wire values
Supported: null, boolean, finite number, string, literal enums, arrays, finite object records with optional fields, discriminated object unions, and EntityRef. Method results may also be void. All domain types must be declared in the entry file.
Contracts may import SDK Notification, Hook, Transform, EntityRef, and HookResultValue (from @s2script/sdk/events or the SDK barrel). External domain imports, re-exports, reference directives, unbounded dictionaries, recursive types, arbitrary classes, functions, BigInt, Promise, any, and unknown are rejected. This is the documented wire algebra, not arbitrary TypeScript.
Values cross by copy. Unpaired UTF-16 surrogates in strings or property names are rejected (InterfaceValueNotSerializable) before delivery; valid BMP and non-BMP text is preserved. Every field is checked before a producer method or notification listener receives the value; method results are checked before returning to the consumer. Unknown fields, present-but-undefined fields, nonfinite numbers, sparse arrays, symbols, proxies, accessors, and toJSON coercions do not pass. Omit an optional field or argument instead of passing undefined.
After a method call (including void), both original participant generations and the interface owner must still be live or the consumer gets InterfaceUnavailable.
__s2ref is reserved for the entity-reference wire envelope. Carry persistent player identity as copied identifiers and a map generation; a bare retained client slot is not a stable identity. Entity refs must use the original SDK prototype with exactly index and id as numeric primitives in range.
Nested method/notification crossings share a limit of 32 active calls; call 33 throws InterfaceRecursionLimit. Exactly one provider may own an interface. Notification identity is the interface name plus forward name.
Notifications, hooks, and transforms
emit accepts notification names. dispatch accepts hook and transform names. Listeners run in registration order from a snapshot; every listener gets a fresh copy. Exceptions are logged and do not stop later listeners. Thenable returns are observed as synchronous-contract errors.
A hook asks listeners for a decision. A transform lets them return a shallow patch to declared writable fields. Both reuse HookResult from @s2script/sdk/events (Continue = 0, Changed = 1, Handled = 2, Stop = 3).
import type { Hook, Transform } from '@s2script/sdk/interfaces';
export interface Contract {
methods: {};
forwards: {
OnRequest: Hook<{ identity: string }>;
OnFormat: Transform<{ identity: string; text: string }, 'text'>;
};
}import { use } from '@s2script/sdk/plugin';
import { HookResult } from '@s2script/sdk/events';
export function OnPluginStart(): void {
const service = use('@demo/formatter');
service.on('OnRequest', (event) =>
event.identity === 'blocked' ? HookResult.Handled : HookResult.Continue
);
service.on('OnFormat', (event) => ({
result: HookResult.Changed,
patch: { text: event.text.trim() }
}));
}The producer can dispatch later from a registered command or method:
const decision = formatter.dispatch('OnRequest', { identity: 'guest' });
const formatted = formatter.dispatch('OnFormat', { identity: 'guest', text: ' hello ' });
// formatted: { result: HookResultValue; payload: { identity: string; text: string } }Hooks return the highest listener result, defaulting to Continue. Changed alone does not mutate. Handled still runs later listeners; Stop ends the snapshot immediately.
Transforms return { result, patch? }. A patch is allowed only with Changed and may contain only the contract’s writable keys. The host validates the entire response before applying anything. Patches replace whole field values — no deep merge. undefined does not remove a field. An empty listener set returns Continue and a copy of the original payload.
Handlers must finish synchronously. A throwing listener, invalid result, invalid patch, or thenable contributes Continue and no patch (fail-open). Security-sensitive producers must account for that. Forward kind and, for transforms, sorted writable keys are part of canonical metadata; changing either requires compatible contracts even if payload fields stay identical.
Ownership and optional providers
Every protocol 2 on, including a direct producer import, returns an idempotent Subscription with dispose(): void. Disposal removes that exact registration. Disposing before arming cancels buffered registration. The host also ledgers subscriptions to the consumer.
tryUse is a one-time optional lookup and does not track availability. To follow an optional provider across load and reload, declare it under s2script.optionalPluginDependencies, acquire types with s2s add, then register a watch from OnPluginStart:
import { watchOptional } from '@s2script/sdk/plugin';
export function OnPluginStart(): void {
const watch = watchOptional('@demo/counter', (counter, scope) => {
scope.own(counter.on('OnCountChanged', (event) => console.log(event.count)));
scope.own({
dispose() {
console.log('counter attachment ended');
}
});
});
// watch.dispose() from a later callback removes the watch and its current attachment
}The host calls attach after both plugins are Active, once per compatible provider identity and generation. A watch stays pending while the provider is absent. An incompatible provider logs and does not attach; a later compatible generation can. Attachment callbacks must be synchronous.
scope.own(resource) returns its input and retains its disposer for this attachment. Forward on via the supplied service is automatically attachment-owned. Service on is allowed only during that synchronous attach callback; saved handles cannot reopen registration later. Methods on a retained service throw InterfaceUnavailable once the attachment ends (including after provider reload).
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. Unloading a full set still walks reverse-dependency order.
Host API 3 and migration
SDK builds stamp host API 3.x. Protocol 2 needs API 3 plus contract metadata digests (RFC 8785 JCS). Protocol 2 archives carry interfaceProtocol: 2 and hashed declaration bytes. An archive cannot evade that by declaring API 2.
The API 3 host still loads API 2 protocol 1 archives (legacy generic types and permissive wire). Old API 2 hosts reject API 3 archives. Rebuild and deploy both ends for protocol 2; a protocol 1 consumer cannot silently bind to a protocol 2 provider.
Zones
@s2script/zones 1.0.0 is the first base plugin on protocol 2. Seven methods and five notifications (enter / leave / stay / created / deleted) keep the same names and payload fields. Consumers migrate atomically: interfaceProtocol: 2, refresh the verified declaration, depend on ^1.0.0, and drop caller-selected generics.
Hard dep:
import { on, getZones } from '@s2script/zones';
import { Player } from '@s2script/cs2';
export function OnPluginStart(): void {
on('enter', (event) => {
const player = Player.fromUserId(event.userId);
});
getZones();
}Optional dep — own the five subscriptions and call getZones() after subscribing on every attachment (including provider reload). Notifications do not replay history; initial map DB loads precede publication and are never replayed:
import { watchOptional } from '@s2script/sdk/plugin';
export function OnPluginStart(): void {
watchOptional('@s2script/zones', (zones, scope) => {
scope.own(zones.on('enter', onEnter));
scope.own(zones.on('leave', onLeave));
scope.own(zones.on('stay', onStay));
scope.own(zones.on('created', onCreated));
scope.own(zones.on('deleted', onDeleted));
zones.getZones();
});
}enter / leave are engine boundary crossings. stay fires every eighth game frame for the same connection that entered. Disconnect/map cleanup does not invent leave events. Occupancy is keyed by connection userId, not slot alone — resolve Player.fromUserId(event.userId) when acting on an event. Delayed previous-map DB loads cannot repopulate a newer map.
Protocol selection is whole-plugin. Cookbook econ/workshop recipes stay protocol 1 in examples/legacy-contracts because those community contracts are asynchronous.
See the zones plugin.
BaseComm
@s2script/basecomm 1.0.0 publishes mute and gag policy. Methods: isMuted, isGagged, setMuted, setGagged — SteamID-keyed (canonical nonzero decimal u64 strings). Notifications: OnClientMuteChanged / OnClientGagChanged with { steamId: string; state: boolean }, fired only on real policy transitions.
Queries return false for invalid identities. Setters return false for invalid identity or nonboolean state; otherwise the return is whether current policy equals the requested value when the call returns (including an unchanged request). Offline policy is allowed; reconnect restores voice mute and the scoreboard indicator. Mute result describes BaseComm policy — it does not claim audio delivery if the host voice descriptor is degraded.
Command and menu paths keep permissions, immunity, targeting, and translated replies. Direct service callers are trusted plugins and bypass those checks.
The interop-observer example watches BaseComm as optional, owns subscriptions in the attachment scope, and queries current state after subscribe. It builds from a types-only copy (s2s add @s2script/basecomm); move the generated entry to optionalPluginDependencies. The operator installs the provider archive separately.
See the basecomm plugin.
BaseBans
@s2script/basebans 1.0.0 publishes ban(request) → { recorded, result } and unban({ steamId }) → boolean, plus OnBanRequested (advisory Hook) and OnBanRecorded / OnBanRemoved (notifications).
BanRequest is { steamId, minutes, reason, source: 'command' | 'menu' | 'plugin', actorSteamId }. SteamIDs (including non-null actors) must be canonical nonzero decimal u64 strings. minutes is a nonnegative safe integer (0 = permanent). Invalid domain inputs return { recorded: false, result: Continue } before hooks, store, or effects.
Plugin callers are trusted; source / actorSteamId are caller-supplied context, not auth proof. Command/menu wrappers keep permission, targeting, and immunity checks.
OnBanRequested: Handled/Stop suppress default record+kick (recorded false). Changed alone does not mutate. Fail-open Continue on exceptions. This hook is not an authorization boundary.
recorded means verified immediate host-cache readback after Bans.add (exact reason + expiry consistent with minutes/wall clock) — not disk durability. Persistence failure can leave recorded true and lose the record on restart. Missing/mismatched readback → false, no notification or kick.
Online ban snapshots target+actor identity before callbacks and re-resolves the same target before kick. Reconnect enforcement never emits OnBanRecorded. sm_addban stays record-only even if that SteamID is connected. unban returns whether a cache key existed; OnBanRemoved fires only on true — no disk ACK.
The same interop-observer example also watchOptional('@s2script/basebans', …), owns the three forwards, and returns Continue (it never bans).
SDK Bans.add returns void: cache update first, then attempt persistence; failure is not acknowledged and does not roll back the cache. Bans.get() after add confirms cache visibility only. Bans.remove returns whether a cache key existed, not disk ACK.
See the basebans plugin and the bans module.
Protocol 1
Protocol 1 interfaces remain with generic handles and permissive wire. A hard dep (use / producer-as-import) is a proxy that throws InterfaceUnavailable while the producer is unloaded; tryUse resolves to T | null and does not follow later availability.
import { tryUse } from '@s2script/sdk';
import type { Zones } from '@s2script/zones';
export function OnPluginStart(): void {
const handle = tryUse<Zones>('@s2script/zones');
if (!handle) return;
handle.on('enter', ({ slot, zone }) => {
/* … */
});
}Rebuild both ends before switching a pair to protocol 2.
Opt-in acceptance fixtures for the protocol 2 stack live under tools/interop-acceptance (test plugins and live probes). There is no public SDK diagnostic API for those runs.