Live
Warrants and criminal justice
A connected justice system for a police roleplay community: arrest warrants, criminal records, felonies, bail and appeals, with server-side checks on every action.
- Role
- Design and implementation
- Stack
- Lua (GLua), MySQL, Discord webhooks
- Context
- Police and criminal roleplay on Diverge Networks
- Status
- Live: felonies, jail and bail since 2025, warrants since 2026
Screenshots and clips on this page: captured on a development server with test characters and staged data.
The problem
Police roleplay depends on its paperwork. The server needed warrants officers could file and act on, records that hold up, and consequences that carry across the game, all without trusting anything a player’s game client sends.
Warrants
- Officers file, search and manage warrants through an in-game police computer, with paginated lists of active warrants.
- Every action checks rank-based permissions on the server, including access for government roles, and database requests are rate-limited per player.
- Submitted charges are rebuilt on the server against the real charge list, and capped at ten counts per warrant.
- The supporting document link has to start with the expected domain, trailing slash included, so lookalike domains are rejected.
- One active warrant per person. Executing a warrant requires the suspect to be cuffed, restrained and inside the jail area, and the server re-checks that the warrant belongs to them before closing it.
- Issuing, rescinding and executing a warrant each fire an event. A pager message to the wanted person, a Discord post for the police team and an in-game wanted flag are separate listeners, so none of them can break the others.
- The Discord webhook is set at runtime through a rank-gated admin command and never stored in code.
- Plate checks, plus stolen plates that can be fitted to stolen vehicles and expire after 30 minutes.
Screenshot coming soon
The warrant submission form, filled in with test data.
assets/projects/justice-system/warrant-form.webp (tight crop to the panel)
Screenshot coming soon
The list of active warrants, showing pagination.
assets/projects/justice-system/warrant-list.webp (tight crop to the panel)
Screenshot coming soon
The in-game pager message a wanted character receives.
assets/projects/justice-system/pager.webp (tight crop)
Screenshot coming soon
The Discord post for a new warrant, with server and channel names blurred.
assets/projects/justice-system/discord-embed.webp (crop to the message only)
Video (optional) coming soon
Filing a warrant, seeing it appear in the list, then arresting the suspect through the context menu.
assets/projects/justice-system/warrant-arrest.mp4 (20 to 30 seconds)
net.Receive("WarrantsDatabase.IssueWarrant", function(_, ply)
-- Permission first, before anything is read from the request.
if (not PLUGIN:CanIssueWarrant(ply)) then return end -- rank-based, checked on the server
-- One database request per second per player.
if (ply.lastWarrantDBQuery and ply.lastWarrantDBQuery > SysTime() - 1) then
ply:notify("You must wait 1 second before querying the warrants database again.")
return
end
ply.lastWarrantDBQuery = SysTime()
local charID = net.ReadUInt(32)
local requested = pon.decode(net.ReadString())
local documentLink = net.ReadString()
-- Rebuild the charges against the server's own charge list.
-- Unknown charge IDs and non-numbers are rejected outright.
local allCharges = nut.plugin.list.police:GetCharges()
local charges, total = {}, 0
for chargeID, count in pairs(requested) do
assert(isnumber(count), "charge count is not a number")
assert(allCharges[chargeID], "not a valid charge ID")
if (count > 0) then
charges[chargeID] = count
total = total + count
end
end
if (total > 10) then
ply:notify("You cannot submit a warrant with more than a total of 10 charges.")
return
end
-- The trailing slash matters: it rejects lookalikes such as docs.google.com.evil.example
if (not documentLink:StartsWith("https://docs.google.com/")) then
ply:notify("Invalid Google Docs link.")
return
end
-- One active warrant per person.
nut.db.preparedCallDeferred("warrantsGetActiveByWantedCharID", charID):next(function(res)
if (res.results and res.results[1]) then
ply:notify("A warrant has already been issued for this individual.")
return
end
-- ... look up the wanted character's name, then issue the warrant (omitted)
end)
end)Arrest and jail
- Officers work a suspect through the context menu: cuff and restrain them, and inside the station, pull up their criminal record on the spot.
- Cuffs survive a disconnect. Logging off used to be a free way out; now the cuffs stay on that character until they’re removed or lockpicked.
- The arrest NPC lists every charge with its law code and maximum sentence, shows the theoretical maximum, and lets officers choose between sending someone to await trial or sentencing them on a plea deal, with the sentence slider kept within limits.
- At the front desk, players can check their own record, and bail can be paid for someone else.
- Bail. Prisoners can refuse being bailed out, which stopped bail being used to pull people out of jail against their will.
- The police computer’s original shell predates me; every screen and button in it since is mine. The government computer, built from it, is mine outright. The jail system underneath is one I started years earlier and rebuilt here.
Video coming soon
Cuffing a suspect, checking their record from the context menu, then sentencing them at the arrest NPC.
assets/projects/justice-system/arrest-to-cell.mp4 (20 to 30 seconds)
Screenshot coming soon
The arrest NPC with charges, the theoretical maximum and the sentence slider, test data only.
assets/projects/justice-system/arrest-npc.png (tight crop to the panel)
Screenshot coming soon
The police computer home screen.
assets/projects/justice-system/police-computer.png (tight crop to the screen)
Screenshot coming soon
The government computer, on the records screen.
assets/projects/justice-system/government-computer.png (tight crop to the screen)
Felonies and records
- Any felony charge marks a character as a felon.
- Felon status shows on in-game ID cards.
- Shops can restrict each item to everyone, felons only or non-felons only, enforced on the server at the moment of trade.
- Staff manage the charge list in-game, with every charge validated against a fixed schema.
- Records management was rewritten to work on the new data system, including for characters who are offline.
Screenshot coming soon
A criminal record with the felony flag visible.
assets/projects/justice-system/record-felony.webp (tight crop to the panel)
Screenshot coming soon
An ID card showing felon status.
assets/projects/justice-system/id-card-felon.webp (tight crop)
Appeals and consent
- Appeals. Government characters can clear felony status, but only on records the server has shown them. The server keeps the list of record IDs it sent each player and rejects anything else, so changing an ID in the request does nothing.
- Consent. Permanent character death as an alternative to a prison sentence requires the player’s consent within a time limit. Refusing sends them to trial instead, and disconnecting doesn’t dodge the decision.
-- When a government character opens someone's record, the server remembers
-- exactly which record IDs it sent to that player, and whose they were.
nut.getCharDataOffline(citizenID, nil, {}, function(charData)
ply.allowedSetRecords = {}
if (charData.jailed) then
ply.allowedSetRecords[0] = { CharID = citizenID } -- 0 = the current sentence
end
for recordID in pairs(charData.jailRapSheet or {}) do
ply.allowedSetRecords[recordID] = { CharID = citizenID }
end
-- ... send the records to the player (omitted)
end)
-- The appeal itself. The client sends a record ID and the new felony status,
-- but never whose record it is: that comes from the server's own list.
net.Receive("GovtManageRecordsSetIsFelony", function(_, ply)
local char = ply:getChar()
if (not char) then return end
if (not nut.faction.indices[char:getFaction()].NYC or not char:getData("allowAppeals")) then return end
if (ply.lastFelonySet and ply.lastFelonySet > SysTime() - 1) then
ply:notify("You must wait 1 second before setting another felony.")
return
end
ply.lastFelonySet = SysTime()
local isFelony = net.ReadBool()
local recordID = net.ReadUInt(32)
-- Anything the server didn't send this player is rejected.
local record = ply.allowedSetRecords and ply.allowedSetRecords[recordID]
if (not record) then return end
nut.getCharDataOffline(record.CharID, nil, {}, function(charData)
local key = (recordID == 0) and "jailed" or "jailRapSheet"
local data = charData and table.Copy(charData[key])
local entry = data and ((recordID == 0) and data or data[recordID])
if (not entry) then
ply:notify("Something went wrong - contact a developer.")
return
end
entry.Felony = isFelony
nut.setCharData(record.CharID, key, data, function()
ply:notify("Successfully updated felony status for selected record.")
-- ... refresh the player's view and log the change to Discord (omitted)
end)
end)
end)