Skip to main content
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,
}
FieldEffect
name / shortNameDisplay labels for the built-in currency. Shown everywhere VICE is referenced in the dashboard.
startingBalanceVICE 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.
FieldEffect
dripIntervalMsHow often the marketplace tries to add a new listing. Skipped if we’re already at maxListings.
maxListingsCap on simultaneous listings. Generation resumes when one is bought or expires.
listingExpiryMsA listing is removed if it sits unbought longer than this.
expirySweepIntervalMsHow 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:
LevelCumulative XP
2100
3400
4900
108 100
2036 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

config.debug = false
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 = { ... },
}
FieldNotes
top-level key (e.g. explosives)Internal category id; appears in the dashboard URL state but not the UI.
labelHuman-readable category header.
items[].idMust match your inventory system’s item name. This is what cc_lib.Inventory.AddItem is called with on pickup.
items[].nameDisplay name in the dashboard.
items[].priceVICE cost per unit.
items[].iconOptional. 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 },
    ...
}
FieldNotes
nameShown to the player and on the GPS waypoint label.
coordsWorld coords of the door the player has to knock on.
windowPickup 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).
radiusox_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,
},
FieldRequiredNotes
idYesStable identifier; used as the DB key in training_minigames JSON. Don’t rename existing ids in production — players will lose unlocks.
labelYesDisplay name on the card.
sourceYes'cc_minigames' or 'custom'.
minigameIf source = 'cc_minigames'Export name on cc_minigames (e.g. 'Drill', 'Crack'). The dashboard’s iframe loads this game directly.
invokeIf source = 'custom'Callable run on the client in the cc_heistcontracts resource context after the dashboard closes. Return value (success bool) is logged.
priceYesVICE cost. 0 paired with includedFree = true makes it free with the access fee.
includedFreeNoIf true, the entry is auto-unlocked the moment the player pays for access.
levelRequiredNoHides 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.