ModNet
Deprecated β superseded by Essentials (Ess).
ModNet.luais absorbed there as nativeEss.Net, shipped in one drop-in1_Ess.lua. This page stays as historical reference for the standalone predecessor; new mods should build on Ess.
Status: new, built on already-confirmed pieces. The hijack target, event ID constraint, and 3-bytes-per-number wire packing are the exact mechanics
coopchat.luaalready uses, which are themselves built on the confirmed networking foundation. The generalizations specific to this library β channel multiplexing, value serialization, last-writer-wins state sync β havenβt yet accumulated their own separate live-test history.
ModNet.lua (_G.ModNet) takes coopchat.luaβs pack/chunk transport β built for exactly one job, sending chat text β and generalizes it into a reusable co-op data-sync library: any number of named channels, arbitrary Lua values (not just strings), and a synced-variable layer on top so a mod can just write to a shared table instead of thinking about the network at all. ModNet_CoopChat.lua is chat, rebuilt on top of it β worth reading side by side with the original to see exactly how much of coopchat.lua was really βthe network problemβ versus βthe chat problem.β
Three layers β use the highest one that fits
1. Synced state (simplest)
local S = ModNet.Shared("mymod") -- a table whose fields auto-sync
S.score = 100 -- write -> broadcast to the other player
print(S.score) -- read -> last known value, local or remote
ModNet.Set("k", v) -- shortcut: same idea, default namespace
ModNet.Get("k")
ModNet.Track("hp", function() return myHp end) -- push a LOCAL variable out whenever it changes
Shared(ns) returns a plain table with __index/__newindex metamethods wired to ModNet.getv/setv β reading or writing a field is reading or writing synced state, with no explicit send/receive call anywhere in your own code. Track(key, getter) is the other direction: a function you supply, polled once per heartbeat, that gets pushed out automatically the moment its return value actually changes (diffed against the last value seen β a getter that returns the same number every tick sends nothing).
A direct Shared write broadcasts every time, whether or not the value actually changed β unlike Track, which only sends on an actual diff. Writing the same value to a Shared field every tick (a health bar, a live counter) spams the wire needlessly. Guard it yourself with a plain equality check before writing:
local function put(k, v) if S[k] ~= v then S[k] = v end end
This one-line helper is exactly what WaveDefense.lua wraps every one of its Shared writes in.
2. Messages
ModNet.On("chat", function(sender, text) ... end) -- sender = 0/1 player id
ModNet.Send("chat", "hello") -- any value: string/number/bool/table
Named, one-shot messages instead of persistent state β the closest analogue to coopchat.luaβs own SendChat/receiver pair, except the payload can be any serializable Lua value, including a nested table, not just a string.
3. Raw (experts)
ModNet.OnRaw("ch", function(sender, nums) ... end)
ModNet.SendRaw("ch", { 1, 2, 3 })
Bypasses serialization entirely β you hand over (and receive) plain number arrays yourself. This is essentially coopchat.luaβs own approach, exposed directly for a caller who wants to hand-roll their own encoding for some reason. Send/On and SendRaw/OnRaw are separate pairs per channel β the receiving side has to register with the same one the sender used, since thatβs what determines whether the incoming bytes get deserialized or handed back raw.
Identity & authority
function M.Me() return localId() end -- this machine's player id (0/1)
function M.IsCoop() return T(try(Net and Net.IsMultiplayer)) end -- in a live co-op session?
function M.IsHost() return M.IsCoop() and T(try(Net and Net.IsServer)) end
function M.IsAuthority() return not M.IsCoop() or M.IsHost() end
IsAuthority() is the one that actually matters for how you structure a co-op mod: βshould this machine run the simulation, or just display it?β It is not the same question as IsHost() β Net.IsServer() can read falsy in single-player (thereβs no βserverβ role to hold when nobody else is connected), so IsHost() alone is false in single-player, even though the lone local player obviously should be authoritative for everything. IsAuthority() folds that in: true in single-player unconditionally, true in co-op only for the host. This generalizes a bug Contract Framework already had to work around once by hand (Contract.Accept gating on Net.IsMultiplayer() and Net.IsClient() specifically because a naive host-check alone broke single-player) β here itβs a named, reusable function instead of a one-off inline condition.
T(x) is a tiny normalizer (x == true or x == 1) used throughout, because the engineβs own boolean-ish return values arenβt consistently real Lua booleans β some come back as 1/0 instead of true/false. Wrapping every native flag check in T(...) avoids a truthy-0-is-falsy surprise (0 is truthy in Lua, unlike most C-family languages β a raw if Net.IsServer() then can silently do the wrong thing if the value that comes back is the number 0 rather than false).
The typical shape this produces in a mod (see WaveDefense.lua for the full pattern):
local function loop()
if ModNet.IsAuthority() then pcall(engineTick) end -- only the authority runs the sim
pcall(updateHud) -- everyone reads shared state and displays it
Event.Create(Event.TimerRelative, { TICK }, loop)
end
How it works
Same foundation as coopchat.lua, generalized to many channels at once
The hijack target (MrxFactionManager), the below-8 event ID (M.EV = 5), and the 3-bytes-per-number wire packing are unchanged from coopchat.lua β see that page (and the networking deep dive underneath it) for why each of those specific choices is correct. Whatβs new is that a single event ID now carries every channel a mod registers, not just one hardcoded chat message type. Two mechanisms make that possible:
- Channel names hash to a 16-bit id (
chash, a simple rolling hash βh = (h*33 + byte) % 65536per character) thatβs identical on both machines for the same string, so"chat"and"score"never collide with each other on the wire without needing a manually-assigned numeric id per channel the way a single-purpose script would. - The reassembly key includes the channel, not just sender and message id (
sender .. "/" .. ch .. "/" .. mid, versuscoopchat.luaβs plainsender*256+msgId) β necessary precisely because multiple channelsβ chunks can now be in flight at the same time and must not be reassembled into each other.
Serialization: a small TLV encoding under the βMessagesβ layer
Send/On support arbitrary values by serializing to a byte string first, with one tag byte ahead of each value: 0=nil, 1=false, 2=true, 3=number (a length byte, then the number as ASCII digits from tostring), 4=string (a 2-byte length, then the raw bytes), 5=table (a 2-byte pair count, then each key and value serialized recursively, key immediately followed by its value). This is deliberately simple, not compact β a table serializes to noticeably more bytes than the equivalent hand-packed encoding would, which is the tradeoff for not having to write a custom encoder per message type the way coopchat.lua had to for plain text.
Last-writer-wins keeps synced state convergent
Every Shared/Set write carries a per-key version counter (ver) and the writerβs sender id. On receipt:
if not e or ver > e.ver or (ver == e.ver and (src or -1) > (e.src or -1)) then
st[key] = { v = value, ver = ver, src = src }
end
A strictly newer version always wins; a tied version (both players happened to write the same key at the same local βtickβ) is broken by sender id, deterministically, so both machines land on the identical final value even though they canβt agree on true wall-clock ordering. This is why the libraryβs own constraints note host-authoritative or single-writer keys as the cleanest use β LWW guarantees convergence, not that βthe version you wantedβ wins if two players write the same key at once.
The heartbeatβs three jobs
One Event.TimerRelative loop (M.HB, default 2s) handles everything time-based:
- Poll every
Tracked getter and broadcast whichever ones actually changed since last check. - Every 5th beat, re-broadcast all synced state β cheap duplicate sends the receiverβs LWW check just ignores if nothingβs actually new, but the mechanism a late-joining player needs to ever learn state that was set before they connected (nothing else pushes existing state to a new arrival).
- Garbage-collect reassembly buffers older than 10 seconds β a message that never fully arrives (a dropped chunk) would otherwise leak its partial-reassembly entry forever.
Config
| Field | Default | What |
|---|---|---|
M.EV | 5 | The shared SendCustomEvent id every ModNet channel multiplexes over. Below 8, per the networking deep diveβs event-ID-masking constraint. |
M.SLOTS | 5 | tArgs per send (2 are header β message id/sequence/total packed into one number, plus sender+channel packed into another); the rest carry payload. Matches the largest array size confirmed safe anywhere in the decompiled corpus. |
M.HB | 2.0 | Heartbeat interval, seconds. |
Constraints
- Numbers are 24-bit-safe (
byte1*65536 + byte2*256 + byte3), same ceiling ascoopchat.luaβs own encoding. - A small-payload control plane, not a file transfer β sync state, not large blobs; every send still costs one or more real
SendCustomEventcalls. - Host-authoritative or single-writer keys are cleanest. LWW resolves conflicting writes deterministically, but βdeterministicβ isnβt the same as βthe outcome either player actually intendedβ β design keys so only one side is expected to write a given one where that matters.
The full script
local KEYVAL = "f4" -- must be in the first 10 lines; only used if you also drop this under [OnKey] for a manual reload
-- ModNet.lua -- co-op data-sync library over Net.SendCustomEvent -------------
-- One reliable, chunked, arbitrary-data channel between the two co-op clients,
-- with a simple "synced variable" layer on top. Deploy as OnLoad/ModNet.lua on
-- BOTH machines (load it before any consumer). Numbers cross the wire intact;
-- strings/tables are serialized to bytes here, so callers just pass Lua values.
--
-- LAYERS (use the highest one that fits):
-- 1. Synced state (simplest):
-- local S = ModNet.Shared("mymod") -- a table whose fields auto-sync (LWW)
-- S.score = 100 -- write -> broadcast; read S.score anywhere
-- ModNet.Set("k", v) / ModNet.Get("k") -- default namespace shortcuts
-- ModNet.Track("hp", function() return myHp end) -- push a local var out on a heartbeat
-- 2. Messages:
-- ModNet.On("chat", function(sender, text) ... end) -- sender = 0/1 player id
-- ModNet.Send("chat", "hello") -- any value: str/num/bool/table
-- 3. Raw (experts): ModNet.OnRaw("ch", fn) / ModNet.SendRaw("ch", {numbers})
-- Pair Send<->On and SendRaw<->OnRaw per channel (the receiver interprets by how it registered).
-- Constraints: numbers are 24-bit-safe; it's a small-payload control plane (sync STATE, not files);
-- host-authoritative or single-writer keys are cleanest (writes converge last-writer-wins).
----------------------------------------------------------------------------
_G.ModNet = _G.ModNet or {}
local M = _G.ModNet
M.VERSION = "1.0"
-- ===== config (experts may tune before/after load) =====
M.EV = M.EV or 5 -- SendCustomEvent id (<8); all ModNet traffic shares it, namespaced by channel
M.SLOTS = M.SLOTS or 5 -- tArgs per send (2 are header); bump toward the coopchat_test CAP result
M.HB = M.HB or 2.0 -- heartbeat seconds (Track polling + late-join reconcile)
-- ===== persistent state (survives a reload) =====
M._chan = M._chan or {} -- chash -> { fn, raw, name }
M._rx = M._rx or {} -- reassembly buffers
M._store = M._store or {} -- _store[ns][key] = { v, ver, src }
M._watch = M._watch or {} -- { ns, key, get, last }
local function try(f, ...) if type(f) == "function" then local ok, v = pcall(f, ...); if ok then return v end end end
local function now() return try(Sys and Sys.RealTime) or 0 end
local function localId()
return try(Player and Player.GetLocalPlayerId) or try(Player and Player.GetLocalId)
or ((Net and Net.IsServer and Net.IsServer()) and 0 or 1)
end
local function chash(name) -- name -> 16-bit id, identical on both machines
local h = 0
for i = 1, #name do h = (h * 33 + string.byte(name, i)) % 65536 end
return h
end
-- ---- serialize a Lua value <-> byte string (number/string/bool/nil/nested table) ----
local function u16(n) return string.char(math.floor(n/256) % 256, n % 256) end
local function serInto(v, out)
local t = type(v)
if v == nil then out[#out+1] = "\000"
elseif t == "boolean" then out[#out+1] = v and "\002" or "\001"
elseif t == "number" then local s = tostring(v); out[#out+1] = "\003" .. string.char(#s) .. s
elseif t == "string" then out[#out+1] = "\004" .. u16(#v) .. v
elseif t == "table" then
local n = 0; for _ in pairs(v) do n = n + 1 end
out[#out+1] = "\005" .. u16(n)
for k, val in pairs(v) do serInto(k, out); serInto(val, out) end
else out[#out+1] = "\000" end -- functions/userdata -> nil
end
local function serialize(v) local o = {}; serInto(v, o); return table.concat(o) end
local function deser(s, i)
local tag = string.byte(s, i); i = i + 1
if tag == 0 then return nil, i
elseif tag == 1 then return false, i
elseif tag == 2 then return true, i
elseif tag == 3 then local ln = string.byte(s, i); i = i + 1; return tonumber(string.sub(s, i, i + ln - 1)), i + ln
elseif tag == 4 then local ln = string.byte(s, i) * 256 + string.byte(s, i + 1); i = i + 2; return string.sub(s, i, i + ln - 1), i + ln
elseif tag == 5 then
local n = string.byte(s, i) * 256 + string.byte(s, i + 1); i = i + 2; local tb = {}
for _ = 1, n do local k; k, i = deser(s, i); local val; val, i = deser(s, i); tb[k] = val end
return tb, i
end
return nil, i + 1
end
local function unserialize(s) local ok, v = pcall(deser, s, 1); if ok then return v end end
-- ---- bytes <-> numbers (3 bytes/number; faithful, incl. NULs -- TLV self-delimits any trailing pad) ----
local function bytesToNums(s)
local n = {}
for i = 1, #s, 3 do n[#n+1] = (string.byte(s,i) or 0)*65536 + (string.byte(s,i+1) or 0)*256 + (string.byte(s,i+2) or 0) end
return n
end
local function numsToBytes(nums)
local t = {}
for _, x in ipairs(nums) do t[#t+1] = string.char(math.floor(x/65536)%256, math.floor(x/256)%256, x%256) end
return table.concat(t)
end
-- ===== wire: chunked send + reassembly =====
local function wireSend(ch, nums, reliable)
if not (Net and Net.SendCustomEvent) then return end
local PAY = M.SLOTS - 2; if PAY < 1 then PAY = 1 end
local total = math.max(1, math.ceil(#nums / PAY))
M._mid = ((M._mid or 0) + 1) % 255
local me = localId()
for c = 0, total - 1 do
local a = { M._mid * 65536 + c * 256 + total, me * 65536 + ch } -- slot1=header, slot2=sender+channel
for p = 1, PAY do local v = nums[c * PAY + p]; if v ~= nil then a[#a+1] = v end end
Net.SendCustomEvent("MrxFactionManager", M.EV, a, reliable ~= false)
end
end
local function dispatch(ch, sender, nums)
local c = M._chan[ch]; if not c then return end
if c.raw then pcall(c.fn, sender, nums)
else pcall(c.fn, sender, unserialize(numsToBytes(nums))) end
end
local function wireRecv(tArgs)
local h = tArgs[1] or 0
local mid = math.floor(h/65536) % 256; local seq = math.floor(h/256) % 256; local total = h % 256
local s2 = tArgs[2] or 0
local sender = math.floor(s2/65536) % 256; local ch = s2 % 65536
local nums = {}; local i = 3; while tArgs[i] ~= nil do nums[#nums+1] = tArgs[i]; i = i + 1 end
local key = sender .. "/" .. ch .. "/" .. mid
local m = M._rx[key]; if not m then m = { total = total, parts = {}, t = now() }; M._rx[key] = m end
m.parts[seq] = nums; m.t = now()
local have = 0; for _ in pairs(m.parts) do have = have + 1 end
if have >= m.total then
M._rx[key] = nil
local all = {}
for c2 = 0, m.total - 1 do local pr = m.parts[c2]; if pr then for _, v in ipairs(pr) do all[#all+1] = v end end end
dispatch(ch, sender, all)
end
end
M._recv = wireRecv -- routed through M so a reload picks up edits
-- ===== public: messages + raw =====
function M.On(name, fn) M._chan[chash(name)] = { fn = fn, raw = false, name = name } end
function M.OnRaw(name, fn) M._chan[chash(name)] = { fn = fn, raw = true, name = name } end
function M.Send(name, value, reliable) wireSend(chash(name), bytesToNums(serialize(value)), reliable) end
function M.SendRaw(name, nums, reliable) wireSend(chash(name), nums, reliable) end
-- ===== public: identity / authority =====
local function T(x) return x == true or x == 1 end -- engine flags are sometimes bool, sometimes 1/0
function M.Me() return localId() end -- this machine's player id (0/1)
function M.IsCoop() return T(try(Net and Net.IsMultiplayer)) end -- in a live co-op session?
function M.IsHost() return M.IsCoop() and T(try(Net and Net.IsServer)) end -- true only on the host/authority
function M.IsAuthority() return not M.IsCoop() or M.IsHost() end -- SP OR co-op host = "should I run the sim?" (IsHost alone is FALSE in single-player)
-- ===== public: synced state (last-writer-wins) + tracked locals =====
local STATE = "ModNet$state"
local function broadcastKey(ns, key)
local e = M._store[ns] and M._store[ns][key]; if not e then return end
M.Send(STATE, { ns, key, e.ver, e.src, e.v }, true)
end
function M.setv(ns, key, value)
local st = M._store[ns]; if not st then st = {}; M._store[ns] = st end
local e = st[key]; local ver = (e and e.ver or 0) + 1
st[key] = { v = value, ver = ver, src = localId() }
broadcastKey(ns, key)
end
function M.getv(ns, key) local st = M._store[ns]; local e = st and st[key]; if e then return e.v end end
function M.Shared(ns)
return setmetatable({}, {
__index = function(_, k) return M.getv(ns, k) end,
__newindex = function(_, k, v) M.setv(ns, k, v) end,
})
end
function M.Set(key, value) M.setv("_", key, value) end
function M.Get(key) return M.getv("_", key) end
function M.Track(key, getter, ns) -- idempotent: safe to call again on a reload
ns = ns or "_"
for _, w in ipairs(M._watch) do if w.ns == ns and w.key == key then w.get = getter; return end end
M._watch[#M._watch+1] = { ns = ns, key = key, get = getter, last = nil }
end
-- state channel receiver: apply last-writer-wins (ver, then higher sender id breaks ties -> converges)
if not M._stateOn then
M._stateOn = true
M.On(STATE, function(_, msg)
if type(msg) ~= "table" then return end
local ns, key, ver, src, value = msg[1], msg[2], msg[3], msg[4], msg[5]
if ns == nil or key == nil then return end
local st = M._store[ns]; if not st then st = {}; M._store[ns] = st end
local e = st[key]
if not e or ver > e.ver or (ver == e.ver and (src or -1) > (e.src or -1)) then
st[key] = { v = value, ver = ver, src = src }
end
end)
end
-- ===== heartbeat: poll tracked locals; periodically re-broadcast state for late joiners =====
local function heartbeat()
if Net and Net.IsMultiplayer and Net.IsMultiplayer() then
for _, w in ipairs(M._watch) do
local ok, v = pcall(w.get)
if ok and v ~= w.last then w.last = v; M.setv(w.ns, w.key, v) end
end
M._hbN = (M._hbN or 0) + 1
if M._hbN % 5 == 0 then -- every ~5 beats: full reconcile (cheap dupes; receiver's LWW ignores them)
for ns, st in pairs(M._store) do for key in pairs(st) do broadcastKey(ns, key) end end
end
local t = now(); for k, m in pairs(M._rx) do if t - (m.t or 0) > 10 then M._rx[k] = nil end end
end
if Event and Event.Create then Event.Create(Event.TimerRelative, { M.HB }, M._hb) end
end
M._hb = heartbeat
-- ===== install once =====
import("MrxFactionManager") -- always-resident hijack target
if not M._hijacked then
M._hijacked = true
local orig = MrxFactionManager.NetEventCallback
MrxFactionManager.NetEventCallback = function(evt, tArgs)
if evt == M.EV then M._recv(tArgs) elseif orig then orig(evt, tArgs) end
end
Loader.Printf("[ModNet] receiver installed on MrxFactionManager")
end
if not M._hbStarted then
M._hbStarted = true
if Event and Event.Create then Event.Create(Event.TimerRelative, { M.HB }, M._hb) end
end
Loader.Printf("[ModNet] v" .. M.VERSION .. " ready (EV=" .. M.EV .. " SLOTS=" .. M.SLOTS .. " HB=" .. M.HB .. ")")
return "ModNet v" .. M.VERSION
See also
coopchat.luaβ the hand-rolled, single-purpose transport this library generalizes; read this one first for how the underlying wire mechanics were originally worked out.ModNet_CoopChat.luaβ the same chat feature, rebuilt on top of ModNet β compare the two directly to see what the library actually absorbs.- Custom Networked Events β the
NetEventCallbackhijack mechanism, the event-ID masking constraint, and the fullNETEVENT_*catalog this and every other custom-networking page here builds on. - A Basic Co-op Text Chat β the original discovery story.
- WaveDefense β a full gamemode built on
IsAuthority()/Shared/On/Sendtogether with Contract Framework and UI Kit; the fullest worked example of this libraryβs design in practice.