NPC Sales
Server-side NPC sales simulation for passive vending revenue in Oxide Vending.
How vending machines earn money on their own, and how to tune it.
How it works
Every so often (once a minute by default), the server gives each machine a chance to make a "pretend" sale, as if a pedestrian walked up and bought something. No actual pedestrian is involved in the money side — the whole thing is calculated on the server, so it costs almost nothing in performance and works even in empty areas of the map.
Each round, the server:
- skips machines that are broken, switched off, or empty
- works out each remaining machine's chance to sell (see below)
- rolls the dice for each machine
- for winners: picks an item, takes it from stock, adds the money to the machine, records the sale, applies a little wear, and awards the business XP
Default timing in config/npc_sales.lua:
TickInterval = 60— one round every 60 secondsBaseChancePerTick = 0.05— every machine starts at a 5% chance per round, before the factors below
What decides the sale chance
The base chance is multiplied by six factors:
chance = base × time of day × location × machine type × pricing × stock variety × competition| Factor | What drives it |
|---|---|
| Time of day | The hour multipliers in the config — lunch rush is busy, night is dead |
| Location | Is the machine inside a configured busy or quiet area ("hotzone")? |
| Machine type | Drinks sell fast, electronics sell slowly |
| Pricing | Cheap machines attract more buyers, overpriced ones fewer |
| Stock variety | A fuller machine with more different items sells better |
| Competition | Nearby machines of the same type steal each other's customers |
Each factor is explained below.
Time of day
If oxide-weather is installed, the in-game hour is used. Otherwise the real-world server time is used. Either way the hour is looked up in TimeMultipliers.
Examples from the shipped config:
- midnight →
0.2(very slow) - 8 AM →
1.3(morning rush) - noon →
1.5(lunch rush, the daily peak) - 5 PM →
1.4(evening commute) - 11 PM →
0.3(winding down)
Location (hotzones)
Hotzones are circles on the map with their own traffic multiplier, in Config.NPCSales.Hotzones.
The shipped config makes busy city spots sell much better — Legion Square (2.5x), Del Perro Pier, the airport, Vespucci Beach (around 2x) — and rural spots much worse: Sandy Shores, Paleto Bay, Grapeseed, Harmony, and Chumash sit between 0.3x and 0.5x.
A machine outside every hotzone uses DefaultLocationMultiplier (1.0 — average).
Adding your own zone
{
name = 'custom_zone',
coords = vector3(x, y, z), -- center of the area
radius = 150.0, -- size in meters
multiplier = 1.5, -- 1.5 = 50% more sales here
}Machine type
From MachineTypeModifiers:
| Type | Multiplier |
|---|---|
drinks | 1.5 |
snacks | 1.3 |
general | 0.8 |
electronics | 0.4 |
This is part of the game balance: cheap fast-moving goods sell constantly, expensive electronics rarely — but for much more money per sale.
Pricing
The simulation looks at how the machine's average price compares to the items' base prices:
- selling at base price or below → strong bonus (1.5x)
- the default 1.5x markup → still favorable (about 1.25x)
- double base price → neutral
- triple base price → heavy penalty (0.5x)
(For the curious, the formula is 1.5 - ((average markup - 1.0) × 0.5), clamped between 0.5 and 1.5.)
Stock variety
The multiplier is simply filled slots ÷ total slots. A machine with all 8 slots stocked sells at full speed; a machine with 2 of 8 slots stocked sells at a quarter speed. Keeping machines full and varied matters.
Competition
When machines of the same type sit within Config.Competition.Radius (500 meters by default) of each other, they compete:
- only same-type machines compete (a snack machine doesn't hurt a drinks machine)
- a business's own machines never compete with each other (unless you flip
SameOwnerCompetes) - the cheapest competitor gets an extra bonus (
CheapestBonus = 1.5) - the result is clamped between
MinMultiplier(0.3) andMaxMultiplier(1.8)
The dashboard's competition page shows owners who they're up against and suggests a price slightly below the local average (95% of it, but never below 85% of base price). That analysis is recalculated at most every 5 minutes (AnalysisCacheTime).
What gets bought
When a machine wins its roll, the simulation picks an item from its stock, weighted so that cheaper items and well-stocked items are picked more often. It then buys 1 or 2 of them (MinQuantity / MaxQuantity).
With AvoidLastItem = true (the default), NPCs never take a machine's last copy of an item — real players always find something left to buy.
What a sale does
Each NPC sale:
- removes the items from stock
- adds the money to the machine's takings (after
RevenueMultiplierand the business's level-based NPC bonus) - records a transaction of type
npc_sale(shown with "NPC" as the buyer in the history) - wears the machine down slightly (less than a player sale: 0.3 vs 0.5)
- awards the business XP: 5 base, plus 0.1 per dollar of the sale
- counts toward the business's sales milestones
The walking-pedestrian animations
When players are near a machine that makes an NPC sale, the resource may grab a real nearby pedestrian and play a "using the machine" scene. This is purely visual:
VisualFeedback = {
Enabled = true,
PlayerRange = 50.0, -- a player must be this close for the scene to play
PedSearchRadius = 30.0, -- how far around the machine to look for a pedestrian
MachineCooldown = 30000, -- at most one scene per machine per 30 seconds
}Turning this off does not reduce the income — it only removes the visual flavor.
Admin tools
/vendingnpc toggle— turn the simulation on/off until restart/vendingnpc stats— sales and revenue since the last restart/vendingnpc force— run a boosted test round right now
For developers
local stats = exports['oxide-vending']:GetNPCSalesStats()
-- stats.totalSales, stats.totalRevenue, stats.simulationActive,
-- stats.lastTickTime, stats.tickIntervalPerformance notes
- everything runs server-side; no entities are spawned for the sales themselves
- each round is a simple pass over the machine list with distance checks
- the dashboard's competition analysis is cached, so opening it repeatedly is cheap