All work

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

Screenshot coming soon

The warrant submission form, filled in with test data.

assets/projects/justice-system/warrant-form.webp (tight crop to the panel)

Filing a warrant: charges by count, plus a link to the supporting document.

Screenshot coming soon

The list of active warrants, showing pagination.

assets/projects/justice-system/warrant-list.webp (tight crop to the panel)

Active warrants, a page at a time.

Screenshot coming soon

The in-game pager message a wanted character receives.

assets/projects/justice-system/pager.webp (tight crop)

The wanted person is paged.

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)

The police team’s Discord feed.

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)

From filing to arrest.
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)
Adapted from production code, lightly edited for readability.

Arrest and jail

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)

From arrest to cell.

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)

Sentencing at the arrest NPC.

Screenshot coming soon

The police computer home screen.

assets/projects/justice-system/police-computer.png (tight crop to the screen)

The police computer.

Screenshot coming soon

The government computer, on the records screen.

assets/projects/justice-system/government-computer.png (tight crop to the screen)

The government computer.

Felonies and records

Screenshot coming soon

A criminal record with the felony flag visible.

assets/projects/justice-system/record-felony.webp (tight crop to the panel)

A record with felony status (empty rows trimmed from the screenshot).

Screenshot coming soon

An ID card showing felon status.

assets/projects/justice-system/id-card-felon.webp (tight crop)

Felon status on an ID card.

Appeals and consent

Try it: you’re looking at Jane Doe’s record. Forge a request to clear Vito Moretti’s murder charge, first against a server that trusts the request, then against the shipped one. Made-up characters; a simulation of the idea, not the server code.
-- 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)
Adapted from production code, lightly edited for readability.