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)
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)
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)
Screenshot coming soon
The aging configuration panel, hovering the graph.
assets/projects/brewing/aging-config.png (tight crop to the panel)
-- 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))
endDrinking 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)
Screenshot coming soon
A blood analyzer or breathalyzer result.
assets/projects/brewing/analyzer-result.png (tight crop)
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)