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):
Handledzeroes the hit. Later hooks on that entity still run.Stopzeroes 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 result | Bit after mux |
|---|---|---|
| already clear | any / not called | stays clear (no JS) |
| still set | Continue | stays set |
| still set | Handled | cleared |
| still set | Stop | cleared; 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.setVisibleTo | SDKHookType.SetTransmit | |
|---|---|---|
| Shape | Native mask: this entity is transmitted only to the given viewer slots (empty = hidden from everyone) | Per-viewer JS predicate each snapshot |
| Hot path | Zero JS | JS only for hooked entities whose bit is still set |
| Can hide | Yes | Yes (Handled / Stop) |
| Can un-hide a native clear | No (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 OnEntityCreated — OnEntitySpawned 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.
nullor a stale entity returnsfalseand does not throw.- A wiki name whose engine backing is missing or failed returns
falseand does not throw. Lifecycle gamedata rows start empty until the host self-resolves them againstlibserver.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
SDKHookTypestill throws.
See the sdkhooks module, damage module, and transmit module.