Retired
Plugin hot reload
A developer tool that reloaded individual plugins instead of the whole gamemode. It carried four months of feature work, then was retired.
- Role
- Design and implementation
- Stack
- Lua (GLua), NutScript internals
- Context
- Local development workflow
- Status
- Retired
The problem
Changing any gamemode file reloaded the entire gamemode, freezing my local server for about twenty seconds every time I wanted to test something, even for a one-character change. Over a day of iteration, that adds up to a lot of waiting.
How it worked
- Plugins moved outside the gamemode folder, where the engine can refresh a single file on its own.
- A one-line guard at the top of each plugin file caught that refresh and reloaded the plugin set through my own loader instead.
- The loader mirrored the framework’s own: libraries, factions, classes, interface code, entities, weapons, effects, languages, sub-plugins and items.
- Plugin tables were reused across reloads, so state survived and event handlers were replaced rather than duplicated.
- After loading it replayed the framework’s start-up events, with an opt-out list for plugins that couldn’t handle running them twice.
- Profiling showed the spawn menu rebuild was a major lag source, so it was suppressed during reloads.
-- The one line at the top of every plugin file:
--
-- if (HR and HR()) then return end
--
-- When the engine refreshes a single plugin file, this hands the reload to my
-- loader and stops the stale copy from running on its own.
local function hotreloadPlugins()
if (HR_HOTRELOADING) then return end
HR_HOTRELOADING = true
hr.plugin.loadFromDir("plugins-hr") -- mirrors the framework's own loader (omitted)
-- Rebuilding the spawn menu was the main lag source, so it's suppressed during reloads.
hook.Add("SpawnMenuEnabled", "hotreload-lagfix", function() return false end)
-- Replay the framework's start-up events, skipping plugins on the
-- opt-out list that can't handle running them twice.
local skipped = {}
for uniqueID in pairs(skipInitializedPlugins) do
local plugin = nut.plugin.list[uniqueID]
local fn = hook.GetTable()["InitializedPlugins"][plugin]
if (fn) then
hook.Remove("InitializedPlugins", plugin)
skipped[plugin] = fn
end
end
hook.Run("InitializedPlugins")
for plugin, fn in pairs(skipped) do
hook.Add("InitializedPlugins", plugin, fn)
end
hook.Remove("SpawnMenuEnabled", "hotreload-lagfix")
hook.Run("InitializedItems")
HR_HOTRELOADING = nil
end
function HR()
-- Only plugins from the hot-reload folder are handled.
local src = debug.getinfo(2, "S").source
if (src and not string.find(src, "hotreload%-plugins")) then return end
-- Re-entrancy: never start a reload while one is running, while the loader
-- is including plugin files itself, or right after a full gamemode reload.
if (HR_HOTRELOADING) then return end
if (HR_PLUGIN_LOADING or HR_PLUGINS_ALREADY_LOADED) then return end
hotreloadPlugins()
return true
end
-- A full gamemode reload loads the plugins once, then blocks the per-file
-- guard for a second so every plugin file doesn't trigger its own reload.
hook.Add("OnReloaded", "hotreload-gm-reload", function()
if (HR_PLUGINS_ALREADY_LOADED) then return end
timer.Simple(0, hotreloadPlugins)
HR_PLUGINS_ALREADY_LOADED = true
timer.Create("hotreload-gm-reload", 1, 1, function()
HR_PLUGINS_ALREADY_LOADED = false
end)
end)What it carried
From February to June 2025 it was how I built most new features, including faction events, conflict locks, criminal stashes, gun bags, barricades, lockpicking and the first version of felonies.
Why it was retired
Its weakness was its footprint. Every hot-reloadable file needed that one-line guard, so a convenience for my own workflow became part of the codebase everyone else maintains. After a compatibility issue with one plugin that did unusual things at start-up, the owner and I agreed to retire it.
If I built it again, I’d make it invisible to the code itself: a development-only file watcher that reloads plugins from outside the gamemode, with nothing added to plugin files.