These helpers are the building blocks every production mod ends up needing. Most are short (5 to 15 lines). All are exception-guarded for the safety rules described in EVRIMA_Lua_Safety_Rules.md.
Looks up the server's game mode object. The class name differs depending on which gameplay mode the server is running, so the helper tries each candidate in order. Each FindFirstOf is pcall-wrapped because that call can crash for a class with no live instances (safety rule 3a).
local function findGameMode()
local candidates = { "BP_SurvivalGameMode_C", "TISurvivalGameMode", "TIGameModeBase", "GameModeBase" }
for _, name in ipairs(candidates) do
local gm
pcall(function() gm = FindFirstOf(name) end)
if gm ~= nil then return gm end
end
return nil
endBP_SurvivalGameMode_C is the BP wrapper for the survival mode. TISurvivalGameMode is the native class. TIGameModeBase is the parent class that exists regardless of mode. GameModeBase is the engine base fallback. Trying them in order covers every server configuration.
Don't cache the returned pointer across ticks (same stale-wrapper issue as controller wrappers). Call findGameMode() fresh each time you need it. The call is cheap; FindFirstOf on a known-loaded class is roughly a hash lookup.
Returns nil only if the game hasn't loaded a map yet, which shouldn't happen post-boot.
Returns a controller's live pawn, or nil if the pawn isn't actually spawned. Safety rule 9a in the safety doc covers why this is necessary: K2_GetPawn() returns a non-nil wrapper around a null pawn when the player exists but hasn't spawned a dino yet (spawn-zone select after reconnect, mid-respawn between death and species pick, etc.). Calling methods on that wrapper crashes the server.
local function livePawnFromCtrl(ctrl)
if ctrl == nil then return nil end
local pawn
pcall(function() pawn = ctrl:K2_GetPawn() end)
if pawn == nil then return nil end
local addr
pcall(function() addr = pawn:GetAddress() end)
if addr == nil or addr == 0 then return nil end
return pawn
endThe GetAddress() ~= 0 check is what distinguishes a real pawn from a nullptr wrapper. Use this helper everywhere you derive a pawn from a controller via an indirect lookup.
Extracts the underlying UObject from a RegisterHook callback parameter wrapper. The wrapper is what UE4SS passes to hook callbacks; the underlying UObject is what your hook logic actually wants.
local function unwrapParam(p)
if p == nil then return nil end
local obj
pcall(function() obj = p:get() end)
return obj
endUsed inside hook callbacks to convert param to a usable UObject. Don't cache the returned object across ticks; the underlying memory may be reused (safety rule 6).
Safely reads the steam ID string from a controller. Returns "" (empty string) if any step fails — production callers check steam ~= "" and steam ~= nil rather than just nil.
local function safeString(value)
if value == nil then return "" end
-- Try native tostring first
local ok, s = pcall(function() return tostring(value) end)
if ok and type(s) == "string" and s ~= "" and not s:find("^UObject") then return s end
-- Try :ToString() method (for FString/FName userdata)
local okT, t = pcall(function() return value:ToString() end)
if okT and type(t) == "string" then return t end
return ""
end
local function getControllerSteamId(ctrl)
if ctrl == nil then return "" end
-- Primary path: GetSteamId() UFunction
local sId
pcall(function() sId = ctrl:GetSteamId() end)
if sId ~= nil then
local s = safeString(sId)
if s ~= "" and not s:find("^UObject") then return s end
end
-- Fallback path: SteamId field-access (works on wrappers where GetSteamId returns garbage)
local field
pcall(function() field = ctrl.SteamId end)
if field ~= nil then
local s = safeString(field)
if s ~= "" then return s end
end
return ""
endThe pcall wraps are defensive against edge cases (controller has no associated steam, the FName-to-string conversion fails on stale wrappers). The SteamId field fallback is necessary because GetSteamId() sometimes returns wrappers that stringify as "UObject: 0x..." garbage; the underlying property field is more stable on those cases.
Simple logger that prefixes with the mod name and goes to UE4SS.log via print(). The trailing \n is required — UE4SS's print() does NOT append a newline; without it, log lines from a single mod run together into one giant line.
local MOD_NAME = "YourModName"
local function log(msg)
print(string.format("[%s] %s\n", MOD_NAME, tostring(msg)))
endprint() in UE4SS Lua writes to UE4SS.log. The [YourModName] prefix is the convention every production mod uses; it makes per-mod filtering trivial when you have multiple mods logging to the same file.
For higher-volume logging, add log levels and a per-level toggle:
local LOG_VERBOSE = true
local function log(msg) print(string.format("[%s] %s\n", MOD_NAME, tostring(msg))) end
local function vlog(msg) if LOG_VERBOSE then log(msg) end endWraps a Lua string as FText for UFunctions that require it (notably ClientShowNotification). Falls back to the raw string if UE4SS doesn't expose FText.
local function makeText(message)
if FText == nil then return message end
local ok, ft = pcall(function() return FText(message) end)
if ok and ft ~= nil then return ft end
return message
endSends a chat notification to a specific player. Safe to call from a poll or timer tick; NOT safe from inside a hook callback (safety rule 5).
Production mods differ in return shape. The pattern below uses (ok, reason) (matching DinoStorage). Other mods use single-bool or no return — pick whichever shape your caller code expects and stay consistent within a mod.
local function safeNotify(steamId, message)
if steamId == nil or steamId == "" then return false, "no-steam" end
if message == nil or message == "" then return false, "no-message" end
local gm = findGameMode()
if gm == nil then return false, "no-gameMode" end
local controller
pcall(function() controller = gm:GetControllerBySteamId(steamId) end)
if controller == nil then return false, "no-controller" end
local text = makeText(message)
local okN, errN = pcall(function() controller:ClientShowNotification(text) end)
if not okN then return false, "ClientShowNotification failed: " .. tostring(errN) end
return true, "ok"
endThe fresh findGameMode() plus gm:GetControllerBySteamId(steamId) chain produces a valid pointer with the right lifetime. A stored controller from a hook param does not. Note: ClientShowNotification is intermittently flaky in production — sometimes returns ok=true without the popup reaching the client, especially right after a player spawns. Defer + retry or hold off notifying within a few seconds of fresh connect.
Because notify shouldn't be called from inside hooks (safety rule 5), production mods queue messages in the hook and drain from a tick. Pattern:
local pendingNotifies = {}
local function queueNotify(steamId, message)
if steamId == nil or steamId == "" or message == nil or message == "" then return end
pendingNotifies[#pendingNotifies + 1] = { steam = steamId, msg = message }
end
-- In your recurring poll tick:
LoopInGameThreadWithDelay(3000, function()
if #pendingNotifies == 0 then return end
local drain = pendingNotifies
pendingNotifies = {}
for _, n in ipairs(drain) do
pcall(function() safeNotify(n.steam, n.msg) end)
end
end)This is the safe-from-hook pattern. Direct safeNotify from a chat-hook callback violates rule 5; queueing + draining from a tick fixes it.
Older revisions of these docs carried an inline guard that nudged pure-black (0, 0, 0) colors to (0.01, 0.01, 0.01) before writing, on the theory that the engine treats pure zero as "unset" in some replication paths. That diagnosis is retracted. Pure black renders as solid black; no probe ever reproduced the sentinel behavior; and the leading re-attribution for the original mishap is an out-of-range PatternIndex silently dropping a whole apply — a failure mode documented since (see the retraction section in EVRIMA_Customizer_Field_Map.md for the honest status of that re-attribution). The bump also has a real cost: it makes true black unreachable for players. It is being removed from the production code. If you inherited it from an older copy of these docs, delete it and add PatternIndex sanitization instead.
Application-layer permission check. The exact implementation depends on your admin model. Two production patterns:
Config-driven (BodyDrop, PlayerStats — recommended):
-- After loadConfig() populates config.adminSteamIds = {"76561198XXX", ...}
local function isAdmin(steam)
if steam == nil then return false end
for _, s in ipairs(config.adminSteamIds or {}) do
if tostring(s) == tostring(steam) then return true end
end
return false
endStatic table (DinoStorage):
ADMIN_STEAM_IDS = { -- intentionally global so reload.flag preserves it
["76561198XXXXXXX"] = true,
}
local function isAdmin(steam)
return steam ~= nil and ADMIN_STEAM_IDS[tostring(steam)] == true
endFor a role-based check (Discord roles, etc.), integrate with your bot's database via CommandBridge instead.
UE4SS Lua doesn't include a JSON library, and production mods do NOT pull one in via require. Instead they use ad-hoc regex readers for the few fields they actually need. Pattern:
local function jsonReadString(body, fieldName)
return string.match(body or "", '"' .. fieldName .. '"%s*:%s*"([^"]*)"')
end
local function jsonReadNumber(body, fieldName)
return tonumber(string.match(body or "", '"' .. fieldName .. '"%s*:%s*(-?%d+%.?%d*)'))
end
local function jsonReadBool(body, fieldName)
local v = string.match(body or "", '"' .. fieldName .. '"%s*:%s*([%a]+)')
if v == "true" then return true end
if v == "false" then return false end
return nil
endFor writes, production mods build NDJSON via string.format and concatenation (no recursive encoder needed for flat-ish shapes). PlayerStats and CommandBridge include a small recursive jsonValue builder for nested-object events — copy from those mods if you need it.
Why no require("json"): keeps the mod dependency-free, avoids package.path issues across UE4SS builds, and the read patterns above cover everything production needs. If your mod has genuinely complex parsing needs, drop in rxi/json.lua (small, MIT-licensed, ~350 lines) under your own Mods/<YourMod>/Scripts/, but be aware no live mod does this.
For escaping strings going INTO an NDJSON write:
local function jsonEscape(s)
if s == nil then return "" end
s = tostring(s)
s = s:gsub("\\", "\\\\")
s = s:gsub('"', '\\"')
s = s:gsub("\n", "\\n")
s = s:gsub("\r", "\\r")
s = s:gsub("\t", "\\t")
return s
endUse when building lines like string.format('{"msg":"%s"}', jsonEscape(text)).
Append a single line to a file:
local function appendLine(path, line)
local f = io.open(path, "ab")
if f == nil then return false end
f:write(line); f:write("\n"); f:close()
return true
endProduction mods call this once per line. There's no batched-append helper in the live codebase — file IO is fast enough for the per-tick volumes involved (1 PlayerStats line per player per 5s; a few command results per second peak). If you do need batching, write it yourself by opening the file once and writing N lines before closing.
GetClass():GetFullName() returns strings like "BlueprintGeneratedClass /Game/.../BP_Tyrannosaurus_C" or "TIDinosaurBase /Game/...". Strips the leading-word-plus-whitespace prefix to get just the path.
local function stripClassPrefix(s)
if s == nil then return nil end
s = tostring(s)
return string.match(s, "^%S+%s+(.+)$") or s
endThe regex pattern matches <non-whitespace><whitespace><rest>. Falls through to the original string if no prefix was present. Production mods use this naming convention (stripObjectTypePrefix in PlayerStats, anonymous helper in DinoStorage). It correctly handles all the class-name prefixes the engine emits, not just BlueprintGeneratedClass.
Five helpers that together form the presence registry pattern. The full discussion is in EVRIMA_Presence_Registry.md; the implementations are reproduced here for completeness.
local presenceRegistry = {}
local PRESENCE_EXPIRY_SEC = 180
local function presenceUpdate(steam)
if steam == nil or steam == "" then return end
local s = tostring(steam)
if not presenceRegistry[s] then
presenceRegistry[s] = { firstSeen = os.time() }
end
presenceRegistry[s].lastSeen = os.time()
end
local function presenceRegisterHook()
local ok, err = pcall(function()
RegisterHook("/Script/TheIsle.TIPlayerController:SetAdminCred", function(ctrlParam, _bool)
local self_
pcall(function() self_ = ctrlParam:get() end)
if self_ == nil then return end
local sId
pcall(function() sId = self_:GetSteamId() end)
if sId == nil then return end
local steamStr
pcall(function() steamStr = sId:ToString() end)
if steamStr ~= nil and tostring(steamStr) ~= "" then
presenceUpdate(steamStr)
end
end)
end)
if ok then log("Presence heartbeat hook registered (SetAdminCred)")
else log("Presence heartbeat hook FAILED: " .. tostring(err)) end
end
local function presenceStartRefreshTick()
if LoopInGameThreadWithDelay == nil then return end
LoopInGameThreadWithDelay(15000, function()
local gm = findGameMode()
if gm == nil then return end
local now = os.time()
for steam, entry in pairs(presenceRegistry) do
local ctrl
pcall(function() ctrl = gm:GetControllerBySteamId(steam) end)
if ctrl == nil then
presenceRegistry[steam] = nil
else
entry.lastSeen = now
end
end
end)
log("Presence refresh tick started (15s interval)")
end
local function enumerateOnlinePlayers()
local results = {}
local gm = findGameMode()
if gm == nil then return results end
local now = os.time()
for steam, entry in pairs(presenceRegistry) do
if (now - entry.lastSeen) > PRESENCE_EXPIRY_SEC then
presenceRegistry[steam] = nil
else
local ctrl
pcall(function() ctrl = gm:GetControllerBySteamId(steam) end)
if ctrl == nil then
presenceRegistry[steam] = nil
else
local pawn = livePawnFromCtrl(ctrl)
results[#results + 1] = { controller = ctrl, pawn = pawn, steam = steam }
end
end
end
return results
endThese helpers must be called at boot, not just defined. Pasting the function bodies into your module without invoking presenceRegisterHook() and presenceStartRefreshTick() leaves the registry empty forever. The symptom is enumerateOnlinePlayers() always returning {} even with players connected.
registerChatHook() -- your own chat hook setup
presenceRegisterHook() -- installs the SetAdminCred heartbeat hook
presenceStartRefreshTick() -- starts the 15-second registry refresh tickpresenceRegisterHook is what feeds the registry from connect plus heartbeat events. presenceStartRefreshTick is what keeps entries alive between SetAdminCred bursts (which fire only every 7 to 8 minutes in steady state). If either is missing, the registry stays empty or empties out within a few minutes of every burst.
After boot, verify by checking UE4SS.log for Presence heartbeat hook registered (SetAdminCred) and Presence refresh tick started (15s interval). If those lines are missing, the wiring did not run.
A wrapper around RegisterHook that catches registration failures and logs them. Production version (DinoStorage) double-pcalls: once around the registration itself, once around the user callback. The second pcall makes hook callbacks crash-resistant — if your callback errors out, it doesn't propagate up the hook chain.
local function registerSafeHook(fnName, label, callback)
local ok, err = pcall(function()
RegisterHook(fnName, function(...)
-- Wrap user callback in its own pcall so a single bad fire
-- doesn't bring down the hook or any subsequent fires.
pcall(callback, ...)
end)
end)
if ok then
log("Hook registered: " .. label .. " (" .. fnName .. ")")
else
log("Hook FAILED: " .. label .. " (" .. fnName .. "): " .. tostring(err))
end
return ok
endFor UFunctions that may not be loaded yet at mod-boot time, defer the registration to a tick after the engine has fully initialized:
LoopInGameThreadWithDelay(5000, function()
registerSafeHook("/Script/TheIsle.SomeLateLoadedClass:SomeFunc", "label", callback)
end)Pattern for loading per-steam JSON files. The SkinMod schema is flat: top-level keys are the engine field names (BodyColor, MarkingsColor, etc.) with {r,g,b,a} lowercase sub-keys. No colors envelope, no steam/updatedAt metadata.
local function loadSkin(steam)
if steam == nil or steam == "" then return nil end
local path = "Mods/SkinMod/Saved/skins/" .. tostring(steam) .. ".json"
local f = io.open(path, "r")
if f == nil then return nil end
local body = f:read("*a")
f:close()
if body == nil or body == "" then return nil end
-- Parse: matches each top-level "<FieldName>":{...} block
local out = {}
for fieldName, colorBlock in string.gmatch(body, '"([%w_]+)"%s*:%s*({[^}]*})') do
local r = tonumber(string.match(colorBlock, '"r"%s*:%s*(-?%d+%.?%d*)'))
local g = tonumber(string.match(colorBlock, '"g"%s*:%s*(-?%d+%.?%d*)'))
local b = tonumber(string.match(colorBlock, '"b"%s*:%s*(-?%d+%.?%d*)'))
local a = tonumber(string.match(colorBlock, '"a"%s*:%s*(-?%d+%.?%d*)')) or 1.0
if r and g and b then
out[fieldName] = { r = r, g = g, b = b, a = a }
end
end
return out
endReturns a table keyed by engine field name (out.BodyColor.r, etc.) — no .colors wrapping. Adapt the path and field names to your mod's schema.
If you're implementing a sub-mod that CommandBridge will route commands to, you need a pollInbox loop reading Mods/<YourMod>/Saved/inbox.ndjson (inbox lives in YOUR mod's Saved/ dir, not under CommandBridge) and a write to the shared results file.
Inbox-tail pattern (atomic-rename style, used by BodyDrop and PlayerStats):
local INBOX_PATH = "Mods/YourMod/Saved/inbox.ndjson"
local SHARED_RESULTS = "Mods/CommandBridge/Saved/results.ndjson"
local function fileExists(path)
local f = io.open(path, "rb"); if f == nil then return false end
f:close(); return true
end
local function pollInbox()
if not fileExists(INBOX_PATH) then return end
-- Atomic-ish rename so the bridge can keep writing while we process
local stash = INBOX_PATH .. ".processing"
os.remove(stash)
os.rename(INBOX_PATH, stash)
local f = io.open(stash, "rb"); if f == nil then return end
local body = f:read("*a"); f:close()
if body == nil or body == "" then os.remove(stash); return end
for line in body:gmatch("[^\n]+") do
local id = jsonReadString(line, "id")
local steam = jsonReadString(line, "steam") or ""
-- args.tokens shape: ["set","interval","60"]
local argsBlock = string.match(line, '"args"%s*:%s*%[([^%]]*)%]')
local tokens = {}
if argsBlock then
for s in string.gmatch(argsBlock, '"([^"]*)"') do
tokens[#tokens + 1] = s
end
end
local ok, msg = pcall(function() return handleCommand(steam, tokens) end)
if not ok then msg = "error: " .. tostring(msg) end
appendLine(SHARED_RESULTS, buildResultLine(id, steam, tokens, ok, msg))
end
os.remove(stash)
endResult-line shape (matches what the live bridge expects):
local function buildResultLine(id, steam, tokens, ok, msg)
local tokensJson = "["
for i, t in ipairs(tokens) do
if i > 1 then tokensJson = tokensJson .. "," end
tokensJson = tokensJson .. '"' .. jsonEscape(t) .. '"'
end
tokensJson = tokensJson .. "]"
return string.format(
'{"id":"%s","ts":%d,"source":"YourMod","steam":"%s","args":%s,"ok":%s,"msg":"%s"}',
tostring(id or ""), os.time(), tostring(steam), tokensJson,
tostring(ok == true), jsonEscape(tostring(msg)))
endField set is {id, ts, source, steam, args, ok, msg}. Note: the bridge's own queued-ACK lines use {id, ts, verb, steam, ok, msg} (no source, no args array). Bots correlating results by id will see both lines for routed verbs; use source field's presence/value to distinguish the bridge ACK from your sub-mod's final result.
The dominant idiom for top-level tick functions. Wraps pcall and logs label-prefixed failures so a crashing tick doesn't kill the poll loop silently.
local function safeCall(label, fn)
local ok, err = pcall(fn)
if not ok then log(string.format("safeCall(%s) failed: %s", label, tostring(err))) end
return ok, err
endUse everywhere your tick callback does meaningful work:
LoopInGameThreadWithDelay(POLL_INTERVAL_MS, function()
safeCall("pollInbox", pollInbox)
safeCall("autoRestore", autoRestore)
safeCall("drainNotifies", drainNotifies)
end)Used by every mod for reload.flag and command-triggered flags. Reads the file body, deletes the file, returns the body trimmed (or nil if absent/empty).
local function consumeFlag(path)
local f = io.open(path, "rb"); if f == nil then return nil end
local body = f:read("*all") or ""; f:close()
os.remove(path)
body = body:gsub("^%s+", ""):gsub("%s+$", "")
if body == "" then return nil end
return body
endDrop a token into the flag file from outside the server, the poll tick consumes it next iteration.
Two production patterns. Plain write (BodyDrop, PlayerStats, CommandBridge):
local function writeAll(path, body)
local f = io.open(path, "wb"); if f == nil then return false end
f:write(body); f:close(); return true
endAtomic write via temp + rename (DinoStorage, SkinMod) — survives crashes mid-write:
local function writeAllAtomic(path, body)
local tmp = path .. ".tmp"
local f = io.open(tmp, "wb"); if f == nil then return false end
f:write(body); f:close()
os.remove(path)
return os.rename(tmp, path)
endUse the atomic variant for files that must never be half-written (per-player state, index files).
A typical mod's boot sequence pulls in these helpers and wires the standard infrastructure. Real production pattern:
local MOD_NAME = "YourMod"
local MOD_VERSION = "v001"
local POLL_INTERVAL_MS = 3000 -- 1000ms (CB, PS), 3000ms (SK, DS), 5000ms (BD); never 500ms
local function log(msg) print(string.format("[%s] %s\n", MOD_NAME, tostring(msg))) end
-- ... helper definitions ...
log(string.format("Loading; version=%s", MOD_VERSION))
-- Chat hook + presence registry first (no config dependency)
registerChatHook()
presenceRegisterHook()
presenceStartRefreshTick()
-- One-shot boot tick: load config, fire any startup work, then self-cancel
if LoopInGameThreadWithDelay ~= nil then
local hbHandle
hbHandle = LoopInGameThreadWithDelay(5000, function()
log(string.format("Boot; version=%s", MOD_VERSION))
safeCall("loadConfig", function() loadConfig("boot") end)
if hbHandle ~= nil and CancelDelayedAction ~= nil then
CancelDelayedAction(hbHandle) -- one-shot: cancel after first fire
end
end)
-- Steady-state poll loop
LoopInGameThreadWithDelay(POLL_INTERVAL_MS, function()
safeCall("pollInput", pollInput)
safeCall("autoRestore", autoRestore)
safeCall("drainNotifies", drainNotifies)
local reload = consumeFlag(RELOAD_FLAG)
if reload ~= nil and RestartCurrentMod ~= nil then
log(string.format("RELOAD; token=%s", reload))
RestartCurrentMod()
end
end)
end
log(string.format("Loaded; version=%s", MOD_VERSION))Key points production mods get right:
Loading;log at top,Loaded;log at bottom — paired markers so watchers can detect partial boot.loadConfigruns inside a 5000ms one-shot tick, NOT at top level — gives UE4SS time to fully initialize before file IO.CancelDelayedAction(hbHandle)makes the boot tick one-shot — without this,LoopInGameThreadWithDelaykeeps re-firing every 5s.- Poll interval is 1000ms or 3000ms, never 500ms — anything faster wastes CPU without meaningful latency improvement.
- Every tick callback is
safeCall-wrapped — one bad fire shouldn't kill the loop. reload.flagis consumed from the poll loop, not top-level.
These helpers are the underlying infrastructure every mod uses. Drop them into a Mods/YourMod/Scripts/helpers.lua file and require it from main.lua, or paste them inline if you prefer fewer files. Either approach works; the per-mod-isolation rule means there's no benefit to a shared helpers module across mods.
Most of the helpers are exception-guarded against the specific failure modes documented in EVRIMA_Lua_Safety_Rules.md. Stripping the pcall wraps to make the code shorter usually causes a real crash a few weeks later when an edge case appears. Keep the wraps.