SDKHooks
Per-entity hooks, SourceMod-shaped. Subscribe with SDKHook(entity, type, callback) and drop one with SDKUnhook. Shipped types include OnTakeDamage / OnTakeDamagePost, SetTransmit, lifecycle virtuals (Spawn, Think, Use, …), and Weapon* / Reload. There is also a named public OnTakeDamage (lifecycle); SDKHook is the per-entity form. 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. TraceAttackInfo is on @s2script/sdk/sdkhooks. 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 borrowed view of the live CTakeDamageInfo: reading or writing it after the synchronous callback — including after await — throws "expired borrowed view". Do not stash it. Valid on both pre and post; assigning info.damage only takes effect on pre. A non-finite damage write is refused (the prior value stays).
On CS2, SDKHook OnTakeDamage / OnTakeDamagePost run on the selected game package’s damage function (trusted takeDamageOld via legacy.damage.v1). Public SDKHook signatures and DamageInfo fields are unchanged. Without a game package that provides damage, SDKHook for these types returns false. The wrapper runs for every subscriber and filters by victim in JavaScript; nested damage skips the plugin that caused it.
Optional HookResult (from @s2script/sdk/events):
Handledzeroes the hit after every handler ran. 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. Either result still lets the engine damage function run — the block is the zeroed amount, not skipping the original.
Demos hook "player" — the pawn that takes bullet damage. Hooking every classname is OnEntityCreated with no filter; that is allowed, not required.
OnTakeDamagePost
Same CBaseEntity_TakeDamageOld mux as OnTakeDamage (CS2 package takeDamageOld / legacy.damage.v1). Pre still runs before the original; post runs after with the info pointer still live. Public OnTakeDamage / SDKHooks behavior is unchanged. The post view is borrowed the same way — it expires when the synchronous callback returns.
SDKHook(entity, SDKHookType.OnTakeDamagePost, (info: DamageInfo) => void)Return is ignored. Handled on the post-hook does not zero the hit (that is pre-only) and does not skip later post callbacks. info.damage assignment is frozen during post — reads still work, writes are ignored. A nested hit save/restores the current info/victim so an inner hit does not drop the outer post.
Operators with a DispatchTraceAttack hot-fix override must re-derive it under CBaseEntity_TakeDamageOld (no alias) in addons/s2script/gamedata/cs2/custom/ — damage signature repair moved out of core gamedata. Do not deploy that recipe alone against an older runtime. Every operator repair is reported at boot.
Use post to observe the applied hit. Use pre to block or scale damage.
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.
Weapon*#
WeaponCanUse / WeaponCanUsePost, WeaponCanSwitchTo / WeaponCanSwitchToPost, WeaponDrop / WeaponDropPost, WeaponEquip / WeaponEquipPost, WeaponSwitch / WeaponSwitchPost. Same sdkhook_vp_add path as the other virtuals.
SDKHook(entity, SDKHookType.WeaponDrop, (entity, weapon) => HookResultValue | void)
SDKHook(entity, SDKHookType.WeaponDropPost, (entity, weapon) => void)Pre: omit return = Continue. Handled / Stop SUPERCEDE the original virtual (Stop also skips later callbacks). Post ignores the return.
CS2 gamedata for these VPs is still empty in the current dump — SDKHook returns false (does not throw) until a later RE slice fills gamedata/sdkhooks/.
Reload#
This-only pre like Spawn/Think: (entity) => HookResultValue | void. Handled / Stop SUPERCEDE the original; Stop truncates later callbacks. Missing VP → false. ReloadPost is not a this-void twin — wiki member, returns false.
Typecheck-only#
Wiki members so a port typechecks. No distinct CS2 function / thunk yet — SDKHook returns false and does not throw.
ReloadPost—(weapon, successful) => void. Not a this-void VP twin.OnTakeDamageAlive/OnTakeDamageAlivePost—DamageInfo-shaped.TraceAttack/TraceAttackPost—TraceAttackInfo(damage,damageType,ammoType,hitbox,hitgroup,attacker,inflictor,victim). No distinct CS2 virtual yet (OnTakeDamageremains theCBaseEntity_TakeDamageOldmux).FireBulletsPost—(entity, shots, weaponName) => void. Often absent on the wiki.
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. Weapon* / Reload CS2 rows are still empty in the current dump.OnTakeDamage/OnTakeDamagePostreturnfalsewhen no game package provides damage.OnTakeDamageAlive,TraceAttack,FireBulletsPost, andReloadPosthave no live path yet. 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.