Warning
exBase is currently in open beta. If you encounter issues, unexpected behaviour, or have questions, please open an issue.
exBase lets you write GM hooks directly inside your weapon while automatically handling: hook registration and cleanup, active weapon checks and per-instance dispatching. No more hook lifecycle management, no more duplicated logic.
exBase acts as a hook abstraction layer rather than a traditional weapon base. It can be used as the base of a weapon, as the base for a weapon base, or integrated directly via its module, though more setup is required.
Get it on the workshop: exBase - Extended Weapon Base
Architecture features:
- Zero-boilerplate
- Single proxy per event
- O(1) dispatch time
- Hot-reload support
- Weak key GC safety
- Idempotent registration / cleanup
- Guaranteed cleanup fallback path
- No per-dispatch iteration, string operations, or allocations
Create a weapon and inherit from weapon_exbase. Do not override Initialize, OnRestore, OnRemove or OnReloaded (see Getting started why).
SWEP.Base = "weapon_exbase"
...
function SWEP:PlayerButtonDown(ply, button)
-- your code
endDone. Your SWEP can now use GM:PlayerButtonDown and exBase automatically handles registration, routing and cleanup.
Inherit from weapon_exbase and use the provided convenience functions. Do not override Initialize, OnRestore, OnRemove or OnReloaded, exBase uses these to manage hook lifecycles.
You can also download templates here.
SWEP.Base = "weapon_exbase"
SWEP.Author = "..."
SWEP.Spawnable = true
...
function SWEP:CustomInitialize()
-- your initialize code here.
end
function SWEP:CustomOnRestore()
-- your onrestore code here.
end
function SWEP:CustomOnRemove()
-- your onremove code here.
end
function SWEP:CustomOnReloaded()
-- your onreloaded code here.
endTip
If you need to override Initialize, OnRestore, OnRemove or OnReloaded, make sure to call the corresponding function on BaseClass so exBase can preserve its lifecycle management.
For more information, see baseclass.Get.
If you wish to implement exBase behaviour without inheriting from it, you can do so using the following template:
Warning
Only use this path if you have an existing weapon base you cannot change. Requires manual lifecycle calls.
require("exbase_registry")
SWEP.Base = "weapon_base"
SWEP.Author = "..."
SWEP.Spawnable = true
...
function SWEP:Initialize() -- mandatory
exbase_registry.Register(self)
end
function SWEP:OnRestore() -- mandatory
exbase_registry.Register(self)
end
function SWEP:OnRemove() -- mandatory
exbase_registry.Deregister(self)
end
function SWEP:OnReloaded() -- recommended
exbase_registry.Register(self, true)
endYou are now free to implement custom callbacks for Initialize, OnRestore, OnRemove and OnReloaded as you see fit. You may also manage hook lifecycles in another part of your code, but this is the recommended way.
Now that you are inheriting from exBase, you are free to use any GM hook in your weapon as long as the following conditions are met:
- The hook's signature contains a
Playerargument (server-side). If it does not provide aPlayerargument but provides a genericEntityargument instead, please see Example 2. - The hook is client-side (fallback to
LocalPlayer()if noPlayerargument is found).
When weapon is active, the user takes no fall damage:
SWEP.Base = "weapon_exbase"
SWEP.Author = "..."
SWEP.Spawnable = true
...
if (SERVER) then
function SWEP:GetFallDamage(ply, speed)
return 0
end
endThat's all, the weapon uses GM:GetFallDamage and exBase proxies the hook while managing its lifecycle.
Some hooks like GM:EntityTakeDamage are server-side and do not provide a Player object, rather, they provide a generic Entity. Using exbase_indexer.Hint(eventName, plyIndex) as a decorator, we can specify which argument index the Player object will be found at.
In this example, the user takes half damage when the weapon is active:
SWEP.Base = "weapon_exbase"
SWEP.Author = "..."
SWEP.Spawnable = true
...
if (SERVER) then
exbase_indexer.Hint("EntityTakeDamage", 1) -- Entity at index 1 (target) is the player
function SWEP:EntityTakeDamage(target, dmg)
dmg:ScaleDamage(0.5)
end
endCaution
This is required for any server-side hook that has no Player object in its signature. Skipping this will lead to inconsistent behaviour, or skipped hook calls.
When the weapon is active, a cartoon shader is applied:
SWEP.Base = "weapon_exbase"
SWEP.Author = "..."
SWEP.Spawnable = true
...
if (CLIENT) then
local mod = {
["$pp_colour_addr"] = 0,
["$pp_colour_addg"] = 0,
["$pp_colour_addb"] = 0,
["$pp_colour_brightness"] = -0.04,
["$pp_colour_contrast"] = 1.35,
["$pp_colour_colour"] = 5,
["$pp_colour_mulr"] = 0,
["$pp_colour_mulg"] = 0,
["$pp_colour_mulb"] = 0
}
function SWEP:RenderScreenspaceEffects()
DrawColorModify(mod)
DrawSobel(0.5)
end
endHere, the weapon makes use of a generic client-side hook GM:RenderScreenspaceEffects with no Player argument. In this case, exBase will recognize the intent and default to LocalPlayer() as its argument proxy during dispatching.
Module level debug concommands are provided for you to inspect all caches.
Prints registered hooks per class and class reference count.
example:
] sv.exbase_hooks
Classes [class] | [hooks]:
["weapon_exbase_test"]:
["EntityTakeDamage"] = function: 0xcfbf0be2
["GetFallDamage"] = function: 0xcaddeca2
Class Instances Count [class] | count:
["weapon_exbase_test"] = 1
Prints indexer cache of resolved player indices.
example:
] sv.exbase_indexer
Resolved Ply Indices [hook] | plyIndex:
["EntityTakeDamage"] = 1
Prints cache of hooks currently being proxied.
example:
] sv.exbase_proxy
Proxied Hooks [hook] | isProxied:
["EntityTakeDamage"] = true
["GetFallDamage"] = true
Prints registry cache. The cache presents which weapon method is delegated through proxied hooks, cached class for each weapon instance and hook ref-counts.
example:
] sv.exbase_registry
Delegates [weapon] | [proxiedHooks]:
[Weapon [76][weapon_exbase_test]]:
["EntityTakeDamage"] = function: 0xcfbf0be2
["GetFallDamage"] = function: 0xcaddeca2
Classes [weapon] | "class":
[Weapon [76][weapon_exbase_test]] = weapon_exbase_test
Hook Counts: [hook] | count
["EntityTakeDamage"] = 1
["GetFallDamage"] = 1
One proxy per event, not per instance
When the first instance of a weapon is created, exBase registers a single hook.Add proxy for each hookable methods defined for that class. Every subsequent instance of the same class shares that proxy, there is no accumulation of hooks as more weapons are created. The proxy is ref-counted and removed via hook.Remove only when the last instance is destroyed.
Class-level scanning, not instance-level
The weapon class table is scanned exactly once per class per lifetime. The scan iterates a pre-filtered table of valid GM hooks and performs a single key lookup into the weapon table per entry. The result is cached and reused by every subsequent instance of that class at zero scan cost.
Shared function references, not copies
The functions stored in the delegate cache are references to the class-level method, not copies, not closures. All instances of the same class share one function reference per hook. The correct self is injected at dispatch time, so each instance operates on its own entity despite sharing the function. This means the memory footprint of the delegate cache scales with the number of distinct weapon classes, not the number of live instances.
Permanent index resolution
When a GM hook fires, exBase must identify which argument holds the player in order to retrieve their active weapon. This resolution is lazy on first call and cached for the duration of the session. GM hook signatures are fixed by the engine and never change at runtime. All future dispatches skip resolution and read the index from cache in O(1).
For hooks with no player argument (server-side hooks like EntityTakeDamage), the index must be provided via exbase_indexer.Hint, which pre-populates the cache before any instance exists, bypassing the resolution step entirely.
Dispatch cost per hook call
Once the system is warm, a single dispatch looks like:
- One table lookup to resolve the player index
- One
GetActiveWeaponcall (cached locally) - One table lookup to check the weapon tag (for validity)
- One table lookup to retrieve the delegate
- One function call with
selfinjected
No per-dispatch iteration, string operations, or allocations.
Cleanup is guaranteed
SWEP:OnRemove handles the clean deregistration path. An EntityRemoved hook runs as a fallback for edge cases where OnRemove is not called. Both paths converge on the same Deregister function which is idempotent. Calling it twice is safe. Furthermore, some caches implement weak keys to avoid leaks when weapon instances 'disappear'.
ENTRYweapon_exbase.lua
SWEP base file is loaded.AddCSLuaFile()chain is resolved fromrequireand sent to all clients.
β
REQUIREexbase_registry
Daisy-chainsexbase_proxy,exbase_indexerandexbase_hooks.
β
INITexbase_hooks
Loadsgamemode_hooks.luaand pre-filters it, removing any key also present in theWeaponmetatable or inweapon_hooks.lua. Avoids collisions likeGM:CalcViewModelViewvsWEAPON:CalcViewModelView.
β
RESULT_hookTable
A pre-filtered, static set of proxiable GM hooks ready for use.
TRIGGERSWEP:Initialize()
Called by engine, base calls toexbase_registry.Register(self).
β
REGISTRYexbase_hooks.Acquire(class)
If the class is not cached yet, callscan(class). Iterate_hookTableto find proxiable delegates.
β
RESULThooks table cached
Class cache ofexbase_hooksis populated with{ [event] = delegate }. Reference_countis incremented, skippingscan(class)on subsequent instances.
β
PROXYexbase_proxy.Register()
For each proxiable hooks, registers a singlehook.Addif one doesn't exist. Proxies are ref-counted so only one proxy per event regardless of instance count.
GMODGM hook called
exbase_proxyintercepts viahook.Add, e.g.GetFallDamage,RenderScreenspaceEffects.
β
INDEXERexbase_indexer.Resolve()
If player index is found in cache, return stored index. If not found,Resolve(event, args)for first validPlayerobject and add index to cache.
β
LOOKUPGetActiveWeapon
Use cached version ofPLAYER:GetActiveWeaponin order to remain lean and confirm weapon uses exBase throughweapon[_tag].
β
DISPATCHdelegate(weapon, ...)
Weapon's method is called, passing itself as the first argument for colon syntax support, e.g.SWEP:GetFallDamage, followed by original hook arguments.
β
Note
GUARD SERVER + no player argument?
no argument: return and drop event
has argument: continue and dispatch
Note
GUARD CLIENT + spectating
ply != LocalPlayer(): return and drop event
ply == LocalPlayer(): continue and dispatch
Note
GUARD Weapon validity
invalid or no tag: return and drop event
valid and tagged: continue and dispatch
WEAPON FILEexbase_indexer.Hint(event, i)
Called at file scope. Writes directly into indexer cache before any instance exists. Acts as a decorator annotation for the method defined below it.
e.g.exbase_indexer.Hint("EntityTakeDamage", 1), no player in signature, index provided manually.
β
RESULTCache pre-populated
Resolve()will find the index immediately on first call, noscanattempted. Cache is permanent,GMhook signatures are static in nature.
β
DISPATCHSERVER guard bypass
With a validPlayerindex in cache, thedispatchproceeds normally to the weapon's delegate.
PRIMARY PATHSWEP:OnRemove()
Called by engine, invokesexbase_registry.Deregister(self).
Warning
FALLBACK PATH EntityRemoved hook
Catches cases where OnRemove was not called, for example, during a map cleanup edge case or fullupdate events.
β
RESULTunregister(weapon)
Decrements per-event reference counts. When a count hits zero, callsexbase_proxy.Deregister(event)which then callshook.Remove.
Warning
FALLBACK RESULT Deregister(ent)
Same deregister path as primary. Guarded to make it idempotent.
β
CLEANUPexbase_hooks.Release(class)
Decrements class reference count. Evicts class cache when it reaches zero, allowing for re-scan on next instantiation.
Warning
FALLBACK CLEANUP
Guarded, ensures the fallback is not ran if the primary path OnRemove ran cleanly.
Please do not repackage exBase in your own addons. Add the workshop release as dependency to make sure everyone uses the same base and stays up to date. Repackaging leads to mutations which leads to inevitable conflicts.
