All work

Built, in testing

Brewing, aging and intoxication

The design piece: a brewing and aging system with designer-tuned probabilities, plus a blood-alcohol model that drives intoxication effects and police testing.

Role
Design and implementation
Stack
Lua (GLua)
Context
Crafting and economy on Diverge Networks’ roleplay server
Status
Built, waiting in the server’s testing queue

The problem

The server wanted alcohol to be something players make, not just buy: a barrel where your ingredients decide what you might get, aging that rewards patience, and drinking with consequences the police can test for. The recipes had to be tunable by the server’s designers without touching code.

Brewing

A base ingredient defines the possible results. Grapes, for example, can become red wine, white wine or vinegar, the bad outcome. Each extra ingredient adds weight to the results it shares with the base, and an ingredient that shares nothing pushes the brew toward the bad outcome. Sealing the barrel rolls a weighted result, ready after a set time.

Players see the possible results and their odds before they seal. Designers edit recipes in-game through a rank-gated configuration panel that validates every value.

Video coming soon

Placing the barrel, adding a base ingredient so the possible results appear, adding ingredients while the odds update, then sealing it and collecting the result.

assets/projects/brewing/brewing-seal.mp4 (30 to 45 seconds)

From first ingredient to finished drink.

Screenshot coming soon

The brewing interface with ingredients in place and the odds visible.

assets/projects/brewing/brewing-ui.png (tight crop to the panel)

The brewing barrel.

Aging

Players choose how long to age a drink when they seal the cask, from a minute to an hour and a half. I wanted a little gambling and a little mastery in that choice: a shorter age can still land a better result, and players who experiment learn the sweet spots.

Each possible result of aging is a bell curve over time: when it peaks, how strong it gets, and how quickly it falls off. At any moment the curves give the odds of each result, and the configuration panel graphs them so a designer can see exactly what a 30-minute age will produce. Leave a drink too long and the vinegar curve takes over.

Video coming soon

The aging configuration panel with its graph, then one aging cycle.

assets/projects/brewing/aging.mp4 (about 15 seconds)

Tuning and running an aging cask.

Screenshot coming soon

The aging configuration panel, hovering the graph.

assets/projects/brewing/aging-config.png (tight crop to the panel)

Designers see the odds over time.
Try it: drag across the graph or move the slider to change the aging time, tune a curve, then seal a cask. Example values, not the live tuning.
-- Each possible result of aging is a bell curve over time, tuned by a designer:
--   time  = when it peaks (minutes),  peak = how strong it gets,  sigma = how fast it falls off.
-- (Example values, not the live tuning.)
--   ["young_wine"] = { time = 20, peak = 0.75, sigma = 5 },
--   ["vinegar"]    = { time = 90, peak = 0.85, sigma = 8 },

function AgingBarrel.ComputeProbability(o, t)
    local sigma = o.sigma or 6
    local peak = o.peak or 1
    local d = t - o.time
    return peak * math.exp(-(d * d) / (2 * sigma * sigma))
end

-- The score of every result at time t, keyed by result.
function AgingBarrel.ComputeProbabilities(outcomes, t)
    local scores = {}
    for name, o in pairs(outcomes) do
        scores[name] = AgingBarrel.ComputeProbability(o, t)
    end
    return scores
end

-- Weighted roll over the scores. They don't need to sum to 1.
function AgingBarrel.Roll(probs)
    local total = 0
    for _, p in pairs(probs) do
        total = total + p
    end
    local roll = math.Rand(0, total)

    local cumulative = 0
    local bestName, bestP = nil, -1
    for name, p in pairs(probs) do
        cumulative = cumulative + p
        if p > bestP then bestName, bestP = name, p end
        if roll <= cumulative then
            return name
        end
    end

    -- Floating-point safety net: fall back to the most likely result.
    return bestName
end

function AgingBarrel.ResolveOutcome(config, t)
    return AgingBarrel.Roll(AgingBarrel.ComputeProbabilities(config, t))
end
Adapted from production code, lightly edited for readability.

Drinking and testing

Every character has a blood alcohol level from 0 to 100. Each drink adds 2.5 times the square root of its ABV, so strong drinks hit harder without scaling linearly: a beer adds about 6 to 8, wine 9 to 10, and a 40% cocktail 16. Intoxication effects scale with that level.

Alcohol clears from the system after 15 minutes and other substances after 30. Breathalyzers read actual drunkenness, and a new blood analyzer handles drug tests.

Video coming soon

A few drinks as the effects build, then a breathalyzer or blood analyzer test and its result.

assets/projects/brewing/intoxication.mp4 (15 to 20 seconds)

Effects, then a test.

Screenshot coming soon

A blood analyzer or breathalyzer result.

assets/projects/brewing/analyzer-result.png (tight crop)

A test result.

Keeping it server-authoritative

When a player seals a barrel, the client sends only the barrel’s ID. The server checks that it really is a brewing barrel, that this player is currently using it, and that every ingredient is valid and in a valid slot, then computes the result itself. The timer that pays out re-checks that the barrel and its state are still valid before delivering anything.

-- The client sends only the cask's item ID. Everything else is checked
-- and decided here, on the server.
net.Receive("nutBrewingCaskSeal", function(_, ply)
    local cask = nut.item.instances[net.ReadUInt(32)]
    if (not cask or cask.uniqueID ~= "brewing_cask") then return reject(ply, "That isn't a brewing cask.") end

    local inv = cask:getInv()
    if (not inv) then return reject(ply, "That cask has no inventory.") end

    -- Only someone who currently has this cask open can seal it.
    if (not inv.currentUsers[ply]) then return reject(ply, "You aren't using this cask.") end

    -- Every ingredient must be valid and sit in one of the four slots.
    local items = {}
    for _, item in pairs(inv:getItems(true)) do
        local x, y = item:getData("x"), item:getData("y")
        if (not x or y ~= 1 or x < 1 or x > 4) then return reject(ply, "An ingredient is in an invalid slot.") end
        if (not PLUGIN:IsValidBrewingIngredient(item)) then return reject(ply, "That isn't a valid ingredient.") end
        items[x] = item
    end

    local base = table.remove(items, 1)   -- slot 1 is the base
    local additives = table.ClearKeys(items)
    if (not base) then return reject(ply, "You need a base ingredient to start brewing.") end

    -- The base defines the possible results. Additives add weight to results
    -- they share with it; an additive that shares nothing pushes toward the bad result.
    local results = table.Copy(PLUGIN.Data.Recipes[base.uniqueID])
    local bad = results._bad
    results._bad = nil

    for _, item in pairs(additives) do
        local shared = false
        for result, weight in pairs(PLUGIN.Data.Recipes[item.uniqueID]) do
            if (results[result]) then
                results[result] = results[result] + weight
                shared = true
            end
        end
        if (not shared) then
            results[bad] = (results[bad] or 0) + 1
        end
    end

    local outcome = weightedRoll(results)   -- same cumulative roll as the aging module
    if (not outcome) then return reject(ply, "Couldn't pick a result. Please report this.") end

    -- Clearing the users list closes the cask for everyone, so a second
    -- seal request fails the check above. Then the ingredients are consumed.
    inv.currentUsers = {}
    for _, item in pairs(inv:getItems(true)) do
        item:remove()
    end

    cask:setData("producing", true)
    local ent = cask.entity

    timer.Create("Brewing_" .. cask:getID(), PLUGIN.BrewingTime, 1, function()
        -- Re-check everything before paying out: the cask may have been
        -- picked up, deleted or already collected while we waited.
        if (not IsValid(ent) or cask.entity ~= ent or not nut.item.instances[cask:getID()]) then return end
        if (not cask:getData("producing") or cask:getData("collect")) then return end

        cask:setData("producing", nil)
        cask:setData("collect", outcome)
    end)

    -- ... sound, chat emote and closing the player's window omitted
end)
Adapted from production code, lightly edited for readability.