Search docs
Search modules and symbols
SDKHooks

SDKHooks

Per-entity hooks, SourceMod-shaped. Subscribe with SDKHook(entity, type, callback) and drop one with SDKUnhook — not a load-scoped mux on ctx.entities. Shipped types include OnTakeDamage, SetTransmit, and lifecycle virtuals (Spawn, Think, Use, …). There is also a named public OnTakeDamage (lifecycle); SDKHook is the per-entity form. ctx.entities.onDamage, Scope.entities.onDamage, and public Damage.onPre are gone.

import { SDKHook, SDKHookType, Entity, HookResult } from '@s2script/sdk';
import type { EntityRef, DamageInfo, Client } from '@s2script/sdk';

export function OnPluginStart(): void {
	for (const pawn of Entity.findByClass('player')) {
		SDKHook(pawn, SDKHookType.OnTakeDamage, onTakeDamage);
	}
	for (const prop of Entity.findByClass('prop_dynamic')) {
		SDKHook(prop, SDKHookType.SetTransmit, onSetTransmit);
	}
}
export function OnEntityCreated(entity: EntityRef | null, className: string): void {
	if (!entity) return;
	if (className === 'player') SDKHook(entity, SDKHookType.OnTakeDamage, onTakeDamage);
	if (className === 'prop_dynamic') SDKHook(entity, SDKHookType.SetTransmit, onSetTransmit);
}
function onTakeDamage(info: DamageInfo) {
	info.damage /= 2;
}
function onSetTransmit(_entity: EntityRef, client: Client) {
	if (client.isBot) return HookResult.Handled;
}

SDKHook is not load-window-only. Call it from OnPluginStart (entities already live), OnEntityCreated, OnClientPutInServer, or any time you hold a live EntityRef. Pair Entity.findByClass at start with OnEntityCreated so you cover both.

Import from @s2script/sdk/sdkhooks or the barrel @s2script/sdk. DamageInfo stays on @s2script/sdk/damage. HookResult is on @s2script/sdk/events (also re-exported from the barrel). The SetTransmit viewer argument is a Client, not a raw slot.

OnTakeDamage

Handlers mutate info: DamageInfo in place (info.damage /= 2). No return needed. DamageInfo is a block-scoped view of the live CTakeDamageInfo — do not stash it across await.

Optional HookResult (from @s2script/sdk/events):

  • Handled zeroes the hit. Later hooks on that entity still run.
  • Stop zeroes the hit and skips later hooks.

Handled does not skip later hooks. Multiple hooks on the same entity+type all run, in subscribe order.

Demos hook "player" — the pawn that takes bullet damage. Hooking every classname is OnEntityCreated with no filter; that is allowed, not required.

SetTransmit

CS2 has no useful per-entity CBaseEntity::SetTransmit virtual; the engine path is the existing CheckTransmit POST hook (wiki SDKHook_SetTransmit).

SDKHook(entity, SDKHookType.SetTransmit, (entity, client) => HookResultValue | void)

Callback is (entity, client). Omit return = Continue — the entity stays visible to that viewer (as far as this hook is concerned).

Handled / Stop hide this entity from this viewer by clearing that viewer’s transmit bit. SetTransmit never sets a bit: it cannot show an entity the native mask already hid.

There is no SetTransmitPost.

Handled vs Stop

  • Handled — clear this viewer’s bit. Later SetTransmit callbacks on that (entity, viewer) pair still run.
  • Stop — clear this viewer’s bit and skip later SetTransmit callbacks on that pair.

Handled does not skip later SetTransmit callbacks. Multiple hooks on the same entity all run, in subscribe order, until one returns Stop.

AND-merge with Transmit.setVisibleTo

SetTransmit shares CheckTransmit with Transmit.setVisibleTo. The mux runs after native Transmit.setVisibleTo bit clears. JavaScript only sees hooked entities whose bit is still set. Either API can hide; SetTransmit cannot un-hide a native mask clear.

Native mask (Transmit.setVisibleTo)SetTransmit resultBit after mux
already clearany / not calledstays clear (no JS)
still setContinuestays set
still setHandledcleared
still setStopcleared; later SetTransmit callbacks on that pair skipped

An empty SetTransmit table means zero JS on CheckTransmit. Today’s cost is unchanged when only native Transmit rules exist.

Fail-open: a core panic (or a missing Client constructor) is Continue — do not hide.

When to use which

Transmit.setVisibleToSDKHookType.SetTransmit
ShapeNative mask: this entity is transmitted only to the given viewer slots (empty = hidden from everyone)Per-viewer JS predicate each snapshot
Hot pathZero JSJS only for hooked entities whose bit is still set
Can hideYesYes (Handled / Stop)
Can un-hide a native clearNo (AND-merge across plugins too)No

Use the native mask when the viewer set is a list of slots you can write down. Use SetTransmit when visibility depends on live client state the mask cannot express (client.isBot, admin flags, round state, …). Combine them: a mask can narrow the set, then SetTransmit can hide further — never the other way around.

See the transmit module for setVisibleTo / reset / resetAll.

Lifecycle virtuals

Registration is still SDKHook(entity, SDKHookType.Think, callback) / SDKUnhook. These are per-entity engine virtuals, not OnEntitySpawned / onOutput (those fire after DispatchSpawn and cannot skip the original Spawn).

Spawn / Think

Callback (entity). Pre: omit return = Continue. Handled / Stop skip the original virtual (Stop also skips later callbacks). SpawnPost / ThinkPost ignore the return.

Hook Spawn from OnEntityCreatedOnEntitySpawned is too late; the virtual already ran.

PreThink / PostThink

This-only ((entity)). Return is ignored, including PreThinkPost / PostThinkPost.

Use

Callback (entity, activator, caller, type, value). type is UseType (Off=0, On=1, Set=2, Toggle=3). Pre SUPERCEDEs on Handled / Stop. activator / caller may be null. UsePost ignores the return.

import { UseType, HookResult } from '@s2script/sdk';
import type { EntityRef, UseTypeValue } from '@s2script/sdk';

function onUse(
	entity: EntityRef,
	activator: EntityRef | null,
	caller: EntityRef | null,
	type: UseTypeValue,
	value: number
) {
	if (type === UseType.Off) return HookResult.Handled;
}

GetMaxHealth

Mutate { maxHealth } in place. Handled / Stop SUPERCEDE with the new int.

import { HookResult } from '@s2script/sdk';

function onMaxHealth(info: { maxHealth: number }) {
	info.maxHealth = 200;
	return HookResult.Handled;
}

ShouldCollide

Callback (entity, collisionGroup, contentsMask, originalResult) => boolean. Not HookResult. Last defined boolean wins; void keeps the original.

VPhysicsUpdate / GroundEntChangedPost

VPhysicsUpdate, VPhysicsUpdatePost, and GroundEntChangedPost ignore the return.

CanBeAutobalanced

Callback (client, origRet) => boolean. Last defined boolean wins. Hook the EntityRef you were given — not a slot number. The callback is skipped when there is no Client (never invent slot 0). SDKHook may still record if the instance has the virtual.

Registration

Hooks are books-gated per entity identity. Auto-unhooked on entity destroy and plugin unload. SDKUnhook(entity, type, callback) drops one matching entry early — callback identity is the function reference. You do not need to unhook in OnEntityDestroyed unless you want to drop a hook before the entity dies.

Failures

SDKHook / SDKUnhook return boolean.

  • null or a stale entity returns false and does not throw.
  • A wiki name whose engine backing is missing or failed returns false and does not throw. Lifecycle gamedata rows start empty until the host self-resolves them against libserver.so. A missing row skips that type without a boot FAIL. Do not copy SourceMod or CounterStrikeSharp vtable slot numbers.
  • A string that is not a member of SDKHookType still throws.

See the sdkhooks module, damage module, and transmit module.

s2script — Source 2 plugin framework

GitHub