Plugins to Modules¶
This is the largest structural port in the Helix → Parallax transition. Every Helix plugin becomes a Parallax module, but the two systems differ in file layout, hook dispatch, data persistence, and the shape of the extensibility contract itself.
Table of Contents¶
- How Helix Plugins Work
- How Parallax Modules Work
- The Minimum Viable Port
- Directory Layout Mapping
- Converting a Single-File Plugin
- Converting a Directory Plugin
- Plugin Hooks to Module Methods
- SetData / GetData Migration
- Plugin-Local Entities, Factions, Items
- Common Pitfalls
How Helix Plugins Work¶
A Helix plugin is either a single .lua file under plugins/ or a directory with a sh_plugin.lua entry point and auto-loaded subdirectories. When ix.plugin.Load runs, it:
- Creates a scoped
PLUGINtable withfolder,uniqueID, defaultname/description/author. - For directory plugins, auto-loads
languages/,libs/(recursively),attributes/,factions/,classes/,items/,plugins/(nested),derma/, and entities. - Executes the plugin's main file.
- Injects
PLUGIN:SetData/PLUGIN:GetDatahelpers that wrapix.data. - Walks every function on the
PLUGINtable and stuffs it into a globalHOOKS_CACHEkeyed by function name. - A replacement
hook.CallchecksHOOKS_CACHE[name]first, thenSchema[name], then falls back to the gamemode hook.
Plugins can define any number of hooks simply by naming functions after them on the PLUGIN table. No registration step is required.
How Parallax Modules Work¶
A Parallax module is always a directory with a boot.lua entry point. When ax.module:Include runs against a containing path, it:
- Iterates subdirectories; for each one with a
boot.lua, creates a scopedMODULEtable withuniqueID,scope("framework" or "schema"), andfolder. - Runs
boot.lua. If boot returnsfalse, the module is skipped. - Auto-loads
libraries/,meta/,core/,hooks/,networking/,interface/in order. - If
MODULE.autoload != false, auto-loads the rest of the module directory excluding known subdirs. - Loads content directories:
factions/,classes/,ranks/,items/,entities/. - Calls
MODULE:OnLoaded()if defined. - Fires the
OnModuleLoadedhook. - Stores the module in
ax.module.storedfor later dispatch byhook.Call.
The dispatch side of the hook.Call override walks ax.module.stored, finding any module whose table has a method matching the hook name, and calling it as method(moduleTable, ...).
The Minimum Viable Port¶
If you have a trivial Helix plugin — one file, one hook — here is what the port looks like end-to-end.
Before: helix/plugins/blockchat.lua
PLUGIN.name = "Block Chat"
PLUGIN.description = "Blocks all chat messages from gagged players."
PLUGIN.author = "You"
function PLUGIN:PlayerCanHearPlayersVoice(listener, talker)
if (talker:GetNetVar("gagged")) then
return false
end
end
After: <your-schema>/gamemode/modules/blockchat/boot.lua
MODULE.name = "Block Chat"
MODULE.description = "Blocks all chat messages from gagged players."
MODULE.author = "You"
function MODULE:PlayerCanHearPlayersVoice(listener, talker)
if ( talker:GetNetVar("gagged") ) then
return false
end
end
return MODULE
That's the whole port: swap PLUGIN for MODULE, move the file into a directory named after the module, rename the file to boot.lua, and return MODULE at the end. Both frameworks auto-dispatch the hook.
Directory Layout Mapping¶
Single-file plugin¶
Directory plugin¶
helix/plugins/vendor/ → <schema>/gamemode/modules/vendor/
sh_plugin.lua boot.lua
languages/ (merge into <schema>/gamemode/schema/languages/)
libs/ libraries/
factions/ factions/
classes/ classes/
items/ items/
derma/ interface/
entities/ entities/
plugins/ (nested modules not supported — flatten)
Notes on the mapping:
libs/→libraries/: Parallax expects the plural form. Shared helpers underlibraries/are loaded early, before hooks and UI.derma/→interface/: Same purpose (UI code), different name. Client-only files should use thecl_prefix.languages/: Parallax localization is schema-scoped, not module-scoped. Merge plugin language files into your schema'sschema/languages/directory and prefix phrase keys with the module name to avoid collisions.- Nested plugins: Helix allowed plugins inside plugins. Parallax does not. If your source plugin has its own
plugins/subdirectory, those become sibling modules in your schema'smodules/directory. meta/,core/,hooks/,networking/: New subdirs with no Helix equivalent. Leave them out if your module doesn't need them.
Converting a Single-File Plugin¶
Take a moderately complex single-file plugin and walk through the port:
helix/plugins/ammosave.lua (shortened, illustrative):
PLUGIN.name = "Ammo Save"
PLUGIN.description = "Saves weapon ammo counts across sessions."
function PLUGIN:PlayerSpawn(client)
timer.Simple(0.5, function()
if (!IsValid(client)) then return end
local char = client:GetCharacter()
if (!char) then return end
local saved = char:GetData("ammo") or {}
for class, count in pairs(saved) do
local weapon = client:GetWeapon(class)
if (IsValid(weapon)) then
weapon:SetClip1(count)
end
end
end)
end
function PLUGIN:PlayerDisconnected(client)
local char = client:GetCharacter()
if (!char) then return end
local ammo = {}
for _, weapon in ipairs(client:GetWeapons()) do
ammo[weapon:GetClass()] = weapon:Clip1()
end
char:SetData("ammo", ammo)
end
Create the module directory:
boot.lua:
MODULE.name = "Ammo Save"
MODULE.description = "Saves weapon ammo counts across sessions."
if ( SERVER ) then
function MODULE:PlayerLoadout(client)
-- Parallax fires PlayerLoadout after the character is ready and
-- loadout has been applied, so the 0.5s timer is no longer needed.
local char = client:GetCharacter()
if ( !char ) then return end
local saved = ax.character:GetVar(char, "data", "ammo", {})
for class, count in pairs(saved) do
local weapon = client:GetWeapon(class)
if ( IsValid(weapon) ) then
weapon:SetClip1(count)
end
end
end
function MODULE:PlayerDisconnected(client)
local char = client:GetCharacter()
if ( !char ) then return end
local ammo = {}
for _, weapon in ipairs(client:GetWeapons()) do
ammo[weapon:GetClass()] = weapon:Clip1()
end
ax.character:SetVar(char, "data", "ammo", { dataValue = ammo })
end
end
return MODULE
Key changes:
PLUGIN→MODULE.char:GetData(k, default)/char:SetData(k, v)→ax.character:GetVar(char, "data", k, default)/ax.character:SetVar(char, "data", k, { dataValue = v }). This is because Parallax'sdatais a registeredax.type.datacharacter var rather than a free-form per-character blob.PlayerSpawntimer dance replaced withPlayerLoadout, which Parallax guarantees fires after the character is bound and loadout is applied.- Explicit
if (SERVER) thenguard — Parallax loads every file shared by default, so server-only logic needs to say so. return MODULE— the module loader expects it.
Converting a Directory Plugin¶
A directory plugin needs its subdirectories renamed and, in some cases, its load order rethought. Here is the Helix vendor plugin structure collapsed into a Parallax module:
Helix plugins/vendor/ (summary):
plugins/vendor/
├── sh_plugin.lua -- core logic, net messages, item helpers
├── libs/sh_vendor.lua -- vendor helper library (ix.vendor)
├── factions/ -- (empty in this plugin)
├── derma/cl_vendor.lua -- the vendor UI panel
└── entities/entities/ix_vendor/init.lua ...
Parallax modules/vendor/:
modules/vendor/
├── boot.lua -- what was sh_plugin.lua
├── libraries/sh_vendor.lua -- was libs/ — builds ax.vendor or module-local table
├── hooks/
│ ├── sh_hooks.lua -- extract MODULE:HookName functions here if boot gets large
│ └── sv_hooks.lua
├── networking/
│ ├── sh_net.lua -- net.Receive handlers moved out of boot.lua
│ └── sv_net.lua
├── interface/
│ └── cl_vendor.lua -- was derma/
└── entities/entities/ax_vendor/
├── init.lua
├── shared.lua
└── cl_init.lua
Work through the port in this order:
- Rewrite
sh_plugin.luaasboot.lua. Replace everyPLUGINreference withMODULEandreturn MODULEat the end. Do not move hooks out ofboot.luayet — one file is easier to reason about. - Rename the library file.
libs/sh_vendor.lua→libraries/sh_vendor.lua. Anyix.vendorlibrary it creates becomes a local table or anax.vendorglobal — your call, but module-local is cleaner. - Move net messages. Net-receive handlers are easier to find in a dedicated
networking/file. This is optional but recommended. - Port the UI.
derma/cl_vendor.lua→interface/cl_vendor.lua. Most vgui controls port with only surface changes; see08-UI_THEME_GUIDELINES.mdin the main framework docs for Parallax's theme system. - Entities.
entities/entities/ix_vendor/→entities/entities/ax_vendor/. Do the namespace rename inside the entity files too; weapons go inentities/weapons/.
If boot.lua gets larger than a few hundred lines, split hooks into hooks/sh_hooks.lua / hooks/sv_hooks.lua. The module loader auto-loads those directories, and any MODULE:HookName defined in them is picked up just like one defined in boot.lua itself.
Plugin Hooks to Module Methods¶
In Helix, a function on PLUGIN is a hook by virtue of its name — the load step scrapes the table and registers every function into HOOKS_CACHE. Parallax does the same thing but lazily: the custom hook.Call iterates ax.module.stored on every dispatch and looks for a matching method.
This has two practical consequences:
- Naming a function
MODULE:PlayerSaymakes it a hook. No registration step, same as Helix. - You can split hooks across any file in the module — the
hooks/subdirectory just exists as a convention. A function defined inboot.lua,libraries/sh_anything.lua, orhooks/sv_hooks.luaall register equally, as long as they end up as methods on the sameMODULEtable.
Helix plugins occasionally used ix.plugin.GetHook(pluginID, hookName) to get a reference to another plugin's hook — typically for override or extension. Parallax doesn't expose an equivalent helper; use ax.module:Get(id) and read the method directly:
-- Helix
local fn = ix.plugin.GetHook("vendor", "PlayerUse")
-- Parallax
local mod = ax.module:Get("vendor")
local fn = mod and mod.PlayerUse
SetData / GetData Migration¶
Helix plugins have PLUGIN:SetData(value, global, ignoreMap) / PLUGIN:GetData(default, global, ignoreMap, refresh) auto-injected. These wrap ix.data with the plugin's uniqueID as the key. They are typically paired with SaveData / LoadData hooks that run on a 10-minute timer.
Parallax has no auto-injection and no SaveData hook. Two migration patterns:
Pattern A — Occasional state via ax.data¶
For data that only needs to survive server restarts and changes rarely (e.g. vendor stock levels, per-map door configuration):
-- Helix
function PLUGIN:SaveData()
self:SetData(self.stored)
end
function PLUGIN:LoadData()
self.stored = self:GetData() or {}
end
-- Parallax
function MODULE:OnLoaded()
self.stored = ax.data:Get("vendor_stock", {}, { scope = "map" })
end
function MODULE:SaveStock()
ax.data:Set("vendor_stock", self.stored, { scope = "map" })
end
-- Call :SaveStock() manually at meaningful state-change points,
-- or on a timer:
if ( SERVER ) then
timer.Create("axVendorSave", 600, 0, function()
local mod = ax.module:Get("vendor")
if ( mod ) then mod:SaveStock() end
end)
end
The scope option maps to Helix's bGlobal/bIgnoreMap pair:
| Helix args | Parallax scope |
|---|---|
(value) (default) |
"map" |
(value, false, true) |
"project" (schema-wide, not map-specific) |
(value, true, true) |
"global" (installation-wide) |
Pattern B — Structured state via ax.database¶
For data that is queried, filtered, or grows large (e.g. persistent player logs, per-character module state), promote it to a SQL schema:
-- In boot.lua or libraries/sv_init.lua
if ( SERVER ) then
ax.database:AddToSchema("ax_vendor_transactions", "id", ax.type.number)
ax.database:AddToSchema("ax_vendor_transactions", "character_id", ax.type.number)
ax.database:AddToSchema("ax_vendor_transactions", "item_class", ax.type.string)
ax.database:AddToSchema("ax_vendor_transactions", "price", ax.type.number)
ax.database:AddToSchema("ax_vendor_transactions", "timestamp", ax.type.number)
end
See 09-data-persistence.md for the full treatment.
Plugin-Local Entities, Factions, Items¶
Helix auto-loads these directories inside a plugin. Parallax does the same for modules, but the subdirectory names and prefixes are worth double-checking:
| Content type | Helix path (in plugin) | Parallax path (in module) |
|---|---|---|
| Entities | entities/entities/name/ |
entities/entities/name/ |
| Weapons | entities/weapons/name/ |
entities/weapons/name/ |
| Effects | entities/effects/name/ |
entities/effects/name/ |
| Factions | factions/sh_name.lua |
factions/sh_name.lua |
| Classes | classes/sh_name.lua |
classes/sh_name.lua |
| Items (base) | items/base/sh_name.lua |
items/base/sh_name.lua |
| Items | items/category/sh_name.lua |
items/category/sh_name.lua |
Paths carry over almost verbatim. The main trap is entity namespaces — Helix-era entities named ix_* keep working on a Parallax server (class names are just strings), but you should rename them to ax_* to avoid the two frameworks' content colliding on a mixed server.
Common Pitfalls¶
- Forgetting
return MODULE. The module loader's reload path depends on the return value. Without it, hot-reload will replace your module withnil. - Nesting module directories. Parallax iterates one level deep and expects
boot.luain each child. Nested modules are not discovered. - Mixing
SCHEMAandMODULEhooks. Both get dispatched by the customhook.Call. Define a hook on whichever table logically owns it; don't shadow a schema hook from a module unless you mean to. - Hook return value semantics. Returning a non-nil value from a Helix plugin hook short-circuits subsequent plugin hooks. Parallax does the same — if your hook returns
falseor a value, other modules' implementations of the same hook do not run. If you need permissive composition, return nothing and let the chain continue. PLUGIN.loading. Helix exposed aPLUGIN.loadingboolean for code paths that needed to differentiate "first load" from "reload". Parallax has no equivalent; useMODULE.loadedyourself if you need the distinction, setting it inOnLoaded.
Next: 03-factions.md