Hooks
Hooks let Luau code run when the engine does something, not just when a
player types a command. Two hooks exist today: "frame" (client) and
"tick" (server).
usems.hook("frame", callback)
Section titled “usems.hook("frame", callback)”callback(dt) runs inside Source’s HudUpdate every rendered frame, on the
engine thread. dt is the real frametime in seconds, read live from the
engine’s own gpGlobals.
-- count frames:local frames = 0usems.hook("frame", function(dt) frames = frames + 1end)
-- a 1-second heartbeat:local next_beat = os.clock() + 1.0usems.hook("frame", function(dt) if os.clock() >= next_beat then next_beat = os.clock() + 1.0 print(("beat dt=%.4f"):format(dt)) endend)-- a smooth value driven by frametime:local charge = 0.0usems.hook("frame", function(dt) charge = math.min(charge + dt * 0.5, 1.0) -- fills in 2 seconds if charge == 1.0 then print("charged!") endend)Multiple frame subscriptions (and multiple mods) all run, in load order.
usems.hook("tick", callback)
Section titled “usems.hook("tick", callback)”callback() runs inside the server’s GameFrame, once per server tick —
66 times per second in single-player Portal, independent of frame
rate. This is the simulation clock: the right place for gameplay logic
that must run at a fixed rate.
-- a fixed-rate counter (66/s regardless of fps):local ticks = 0usems.hook("tick", function() ticks = ticks + 1end)
-- one print per simulated second:local next_report = 66usems.hook("tick", function() if ticks == next_report then next_report = next_report + 66 print("one simulated second elapsed") endend)-- frame vs tick side by side — proves they're independent clocks:local frames, ticks = 0, 0usems.hook("frame", function() frames = frames + 1 end)usems.hook("tick", function() ticks = ticks + 1 end)local next_report = os.clock() + 1.0usems.hook("frame", function() if os.clock() >= next_report then next_report = os.clock() + 1.0 print(("%d frames, %d ticks"):format(frames, ticks)) endend)-- sample log line at ~19 fps: counts: 95 frames, 337 ticks since startThe frozen-registration model
Section titled “The frozen-registration model”usems.hook — like register_command/register_cvar — only works while
your entry script is loading. Once it returns, registration freezes.
usems.hook("frame", function() end) -- ✓ during load
usems.register_command("later", function() usems.hook("frame", function() end) -- ✗ raises: only available whileend) -- the mod loadslocal armed = falseusems.register_command("arm", function() armed = true end, 0, "Arm the timer")usems.hook("frame", function(dt) if not armed then return end -- hook exists from load; logic gates here -- ... timer logic ...end)Error handling
Section titled “Error handling”A callback that raises is unsubscribed immediately with one warning line — the game never crashes, other hooks and mods keep running:
usems.hook("frame", function(dt) error("boom") -- first (and only) invocationend)-- [USEMS] my-mod: frame hook removed after error: boomFrame and tick callbacks share the per-mod execution budget (see Sandbox & Limits) — a spinning callback hits the 5-second wall and is killed the same way.
Roadmap
Section titled “Roadmap”| Hook | Source surface | Status |
|---|---|---|
frame |
client HudUpdate |
live |
tick |
server GameFrame via ServerGameDLL009 |
live |
spawn, damage, death |
entity events via datamap reflection | Phase 7 continues |
usercmd, key |
input path | later |
console |
console line interception | later |