All configuration lives in shared/. Three files, all hot-editable — restart the resource to apply.
shared/config.lua — core resource
Currency
config.currency = {
name = 'VICE',
shortName = 'VICE',
startingBalance = 1000,
}
| Field | Effect |
|---|
name / shortName | Display labels for the built-in currency. Shown everywhere VICE is referenced in the dashboard. |
startingBalance | VICE deposited into a brand-new profile the first time it’s created. Set to 0 to start players broke. |
Marketplace generation
config.rotation = {
dripIntervalMs = 3 * 60 * 1000, -- 3 min: how often a new listing is added
maxListings = 9, -- generation pauses once we hit this many
listingExpiryMs = 60 * 60 * 1000, -- 60 min: listings older than this get cleared
expirySweepIntervalMs = 10 * 60 * 1000, -- 10 min: how often we sweep for expired listings
}
The marketplace fills over time instead of re-rolling in batches. Every dripIntervalMs, a new listing is added — picked from the registry weighted by each contract’s weight field. Generation pauses once the marketplace hits maxListings. Listings older than listingExpiryMs are removed by a background sweep that runs every expirySweepIntervalMs.
| Field | Effect |
|---|
dripIntervalMs | How often the marketplace tries to add a new listing. Skipped if we’re already at maxListings. |
maxListings | Cap on simultaneous listings. Generation resumes when one is bought or expires. |
listingExpiryMs | A listing is removed if it sits unbought longer than this. |
expirySweepIntervalMs | How often the expiry sweep runs. Doesn’t need to match listingExpiryMs — a listing is at most one sweep interval over-age. |
On a fresh boot the marketplace starts empty and the first listing appears after dripIntervalMs. There’s no initial fill — by design, so a server restart doesn’t wipe and re-spawn a fresh catalog at the same time. Lower the interval temporarily if you need listings now for testing.
Contract rarity (per-contract weight)
Each contract template can declare an optional weight field (default 1.0) controlling how likely it is to be picked when the marketplace adds a new listing. Higher weight = more common, lower = rarer.
exports.cc_heistcontracts:RegisterContract({
id = 'paleto_bank',
-- ...
weight = 0.25, -- about a quarter as likely as a default-weight contract
}, entryPoint)
Weights are relative, not probabilities. If you register three contracts with weights 1.0, 0.5, 0.5, the first is picked 50% of the time and the others 25% each.
The same contract can appear as multiple listings simultaneously — each listing is its own unique instance with its own listingId. Buying a Fleeca doesn’t stop another Fleeca listing from existing or being bought by someone else.
Slots
config.slots = { max = 3 }
How many contracts a single player can hold at once. Slots are filled by purchase and emptied on start, discard, or transfer-out.
Invite expiry
config.inviteExpiryMs = 60 * 1000
Applies to both contract transfer invites and crew invites. After this elapses, the invite auto-cleans server-side.
XP / leveling
The XP curve is configurable two ways. Quadratic by default:
config.xp = {
base = 100,
exponent = 2,
-- requirements = nil,
}
Cumulative XP needed to reach level N from scratch is base * (N - 1) ^ exponent. With the defaults:
| Level | Cumulative XP |
|---|
| 2 | 100 |
| 3 | 400 |
| 4 | 900 |
| 10 | 8 100 |
| 20 | 36 100 |
For an explicit table, set requirements:
config.xp = {
requirements = { 0, 100, 250, 600, 1200, 2200 },
}
Index N is the cumulative XP needed to reach level N. Index 1 must be 0. Past the end, the last delta repeats — in the example above, level 7 needs 2200 + (2200 - 1200) = 3200.
The dashboard’s level ring is rendered server-side per profile, so curve changes flow through automatically — no client-side curve duplication.
Debug
Toggles utils.dprint (server-side [DEBUG] logging). Default is off; flip to true when investigating drip/expiry behaviour or entry-point errors locally.
shared/marketplace_config.lua — items + pickups
Two top-level tables: items (what’s for sale) and locations (where the player has to go to claim a checkout).
Items
M.items = {
explosives = {
label = 'Explosives',
items = {
{ id = 'c4', name = 'C4', price = 2500 },
{ id = 'thermite', name = 'Thermite', price = 1800 },
...
},
},
hacking = { ... },
masks = { ... },
vehicles = { ... },
tools = { ... },
}
| Field | Notes |
|---|
top-level key (e.g. explosives) | Internal category id; appears in the dashboard URL state but not the UI. |
label | Human-readable category header. |
items[].id | Must match your inventory system’s item name. This is what cc_lib.Inventory.AddItem is called with on pickup. |
items[].name | Display name in the dashboard. |
items[].price | VICE cost per unit. |
items[].icon | Optional. Maps to a key in ui/src/icons.ts. Falls back to the category icon. |
Locations
M.locations = {
{ name = 'El Burro Heights', coords = vector3(1128.21, -2034.86, 32.07), window = 600, radius = 0.6 },
{ name = 'Harmony Warehouse', coords = vector3(614.71, 2783.87, 43.66), window = 600, radius = 0.6 },
...
}
| Field | Notes |
|---|
name | Shown to the player and on the GPS waypoint label. |
coords | World coords of the door the player has to knock on. |
window | Pickup time window in seconds. After this elapses, the pickup is dropped server-side and the player loses the items. The default for shipped locations is 600 (10 minutes). |
radius | ox_target sphere radius. Defaults to 0.6 if omitted. |
On checkout, the server picks one location uniformly at random and starts a timer to drop the pickup if it’s not claimed.
Multiple checkouts by the same player while a pickup is still active merge into the existing pickup instead of rolling a new location. Item quantities are summed; the original deadline is preserved.
shared/training_config.lua — training sandbox
config.accessPrice = 5000
VICE fee to unlock the training sandbox at all. Charged once. Set to 0 to make access free.
Minigames list
Each entry is one practice card in the dashboard’s Training tab.
{ id = 'drill', label = 'Drill', source = 'cc_minigames', minigame = 'Drill', price = 0, includedFree = true },
{ id = 'crack', label = 'Crack', source = 'cc_minigames', minigame = 'Crack', price = 1500, levelRequired = 2 },
{
id = 'my_custom',
label = 'My Custom Game',
source = 'custom',
price = 3000,
invoke = function() return exports['my_resource']:Run({ difficulty = 'medium' }) end,
},
| Field | Required | Notes |
|---|
id | Yes | Stable identifier; used as the DB key in training_minigames JSON. Don’t rename existing ids in production — players will lose unlocks. |
label | Yes | Display name on the card. |
source | Yes | 'cc_minigames' or 'custom'. |
minigame | If source = 'cc_minigames' | Export name on cc_minigames (e.g. 'Drill', 'Crack'). The dashboard’s iframe loads this game directly. |
invoke | If source = 'custom' | Callable run on the client in the cc_heistcontracts resource context after the dashboard closes. Return value (success bool) is logged. |
price | Yes | VICE cost. 0 paired with includedFree = true makes it free with the access fee. |
includedFree | No | If true, the entry is auto-unlocked the moment the player pays for access. |
levelRequired | No | Hides name/buttons and shows a red ring with the required level until the player reaches it. Server enforces too. |
A lookup index config.byId is built once on require. Don’t mutate it directly; rebuild by editing config.minigames and restarting.