cc_heistcontracts doesn’t ship any heists — it just runs the marketplace, slot economy, and dashboard around them. The whole integration lives in one RegisterContract call. No separate exports need to live on the heist resource.
The minimum integration
For a custom heist you control:RegisterContract (once), FinishHeist (whenever the heist ends, fired from inside the handler above), and optionally ApplyCooldown if the contract has a cooldown set.
Why register the completion handler inside
entry_point rather than at the top of the file?- It only listens while a heist is actually running.
- The handler closes over
runtimeData.group, so it knows which run the event belongs to. A top-level handler would have to figure that out from the event payload alone.
RemoveEventHandler(handler)) prevents the listener list from growing every time the heist starts. The wider pattern is “one handler per run, scoped to that run.”The three exports
| Export | When |
|---|---|
exports.cc_heistcontracts:RegisterContract(data, entry_point, opts?) | On resource start. Registers the contract so it can drip into the marketplace. |
exports.cc_heistcontracts:FinishHeist(groupName, success) | When the heist ends. Loops every member of groupName and finishes their slot. |
exports.cc_heistcontracts:FinishContract(src, success) | Single-player variant of FinishHeist. Use when you already have a specific src. |
| Export | When |
|---|---|
exports.cc_heistcontracts:ApplyCooldown(contractId) | When you want to lock the contract server-wide for the configured cooldown. |
RegisterContract
Signature
data is the contract table. entry_point(runtimeData) is called when a player starts the contract from a slot. opts is an optional table that currently accepts two callbacks:
cancel isn’t set — the integration shouldn’t offer a control that does nothing.
RegisterContract returns the contract id.
Field reference
| Field | Type | Default | Notes |
|---|---|---|---|
id | string | — | Stable key. Required unless you pass name (then id = name). Don’t change after release — players’ slot rows reference it. |
name | string | falls back to id | Legacy display field, kept for back-compat. |
title | string | falls back to name | Header shown in the dashboard. |
description | string | '' | One- or two-line pitch. |
difficulty | string | 'medium' | Free-form; the dashboard renders it as a chip. Common values: easy, medium, hard. |
weight | number | 1.0 | Relative probability of being picked when the marketplace drips a new listing. Higher = more common. Set to 0 to disable from marketplace generation entirely. |
levelRequired | int | 1 | Server enforces on purchase. The dashboard greys out the buy button below the threshold. |
price | int | 0 | VICE cost to acquire a slot. |
crewSize | { min, max } | { min = 1, max = 4 } | Display-only today. Your entry point is responsible for enforcing actual crew size. |
duration | int (seconds) | 0 | Display-only. |
reward | { vice, cash, xp } | {} | vice and xp are awarded automatically by FinishContract/FinishHeist on success. cash is display-only — your entry point handles cash and item payouts. |
itemsRequired | array of { id, qty } | {} | Display-only. The dashboard shows whether the player has them; nothing is consumed at start. |
locations | array of { id, label } | {} | If non-empty and the caller didn’t pick one, the server rolls a random entry and threads it into runtimeData.location. |
objectives | array of strings | {} | Display-only checklist in the dashboard. |
cooldown | int (seconds) | 0 | Server-wide cooldown duration. Stamped only when you call ApplyCooldown. While active, Purchase.Start rejects with 'on_cooldown' for everyone. 0 disables. See Cooldowns. |
What happens after registration
Marketplace drips a listing
On the next
dripIntervalMs tick, the marketplace may pick your contract (weighted by weight) and add a new listing. The same contract can be listed multiple times in parallel.Player buys a listing
The server validates VICE balance, level, and slot availability, debits the price, fills a slot, and removes the listing.
Player starts a slot
The server checks the contract isn’t on cooldown and the player isn’t already in another heist, marks the slot active, rolls a random
location if you didn’t pass one, and calls your entry_point function.Heist runs
Your code runs. The dashboard, if reopened, shows an “in progress” panel with elapsed time and (if you provided
opts.cancel) a Give Up button.FinishHeist and FinishContract
FinishHeist(groupName, success)— call once with acc_lib.Groupsname. Walks every member and finishes their slot.FinishContract(src, success)— single-player variant. Use when you already have a specific source id and don’t want to walk a group.
true for success or false for failure.
What happens on success:
- The player’s active slot is consumed (gone after this call).
reward.viceis credited to their VICE balance with a ledger entry.reward.xpis credited to their profile, advancing level if a threshold is crossed.stats.completedandstats.byContract[id].completedincrement.stats.favoriteContractupdates if this contract is now their most-completed.- A fresh profile snapshot is pushed to the player’s dashboard.
- The slot is consumed.
stats.failedandstats.byContract[id].failedincrement.- No rewards.
(false, 'not_active'). Safe to call defensively from multiple code paths.
Picking your finish moment
Where you callFinishHeist defines what “the heist is over” means for your players. Typical patterns:
- Objective-driven: when the last objective is completed (
cc_lib.TaskUI.Createaccepts anonCompletecallback that fires when every task hits'complete'). - Escape-driven: when the player crosses an escape boundary or hands the goods off.
- Timer-driven: when the heist’s overall timer runs out — call with
success = false.
FinishHeist is a pure server-side state mutation — players don’t see anything happen visually from it alone. Your heist resource is still responsible for the in-world feedback (notifications, animations, world reset).
opts.cancel — handling Give Up
When a player presses Give Up on the dashboard, cc_heistcontracts:
- Calls your
opts.cancel(ctx)callback. Your job here is to tear the heist down forctx.group(notify players, hide UI, reset world state). - After the callback returns, automatically finishes every member of
ctx.groupas failure. You don’t need to callFinishContractyourself for cancellation.
opts.cancel, the Give Up button is hidden on the dashboard’s active-heist panel. The player can still finish the heist normally, or wait it out — they just can’t bail mid-run. This is the right default for heists that don’t have a clean abort path.
Why this isn’t an export on your resource
opts.cancel is a closure registered alongside your contract data. It lets a third-party heist integration live entirely inside a single wrapper file — the wrapper doesn’t need to add an export to the escrowed heist resource (which you couldn’t modify anyway).
Granting rewards
reward.vice and reward.xp from your contract definition are credited automatically on success. You don’t need to call Profile.AddVice or Profile.AddXp yourself for those.
reward.cash is not handled by cc_heistcontracts — it’s there for the dashboard’s display only. Cash and inventory payouts are your responsibility:
Cooldowns
Cooldowns are server-wide per contract, not per player. Setcooldown = <seconds> on the contract and call ApplyCooldown from your entry point when the heist starts (or when it ends — your choice). While the cooldown is active, Purchase.Start rejects with 'on_cooldown' for every player. Buying is not blocked — players can still acquire a slot during cooldown, they just can’t start it until the timer ends.
ApplyCooldown export
contractId and schedules the reset callback. Returns the unix-seconds expiry timestamp, or 0 if the contract has no cooldown configured (or the id is unknown). Calling it again before the timer expires re-stamps from “now” — the previously-scheduled reset will silently no-op.
Behaviour notes
- In-memory only. Cooldowns do not persist across resource restarts.
- Single timer per contract.
resetfires exactly once per cooldown cycle, even ifApplyCooldownis called multiple times during the cycle. - No automatic apply.
cooldownon the contract definition is just a duration — nothing happens until your entry point callsApplyCooldown. - Surfaced in the rotation payload. Active cooldowns ship to the dashboard as
rotation.cooldowns = { [contractId] = expiresAtUnixSeconds }, so the UI can grey out cooldowned cards.
reset vs. cancel
Both are callbacks you can register in opts. They fire on different events and have different jobs:
reset | cancel | |
|---|---|---|
| Trigger | Cooldown expiry timer | Player pressed Give Up |
| Scope | Server-wide, world state | Per-player, the active run |
| Fires per | Cooldown cycle (once) | Cancellation (once per Give Up) |
| Context | { contractId, firedAt } | { src, group, contractId, slotIndex } |
| Typical job | Respawn props, re-arm alarms, reset NPCs so the next run starts fresh | Notify the crew, hide the task UI, abort the in-world heist for ctx.group |
Integrating a third-party (escrowed) heist
The same shape works for heists you didn’t write — including ones whose source is escrowed. Create a small wrapper resource (call itmyserver_heists) with a single Lua file:
opts.cancel. The Give Up button will be hidden on the dashboard — players have to finish or wait it out.
What if the escrowed heist doesn’t fire a completion event? That’s the one case you genuinely can’t wrap around. Players’ slots will get stuck “active” because nothing ever calls FinishHeist. Ask the escrowed heist’s author to add an event.
Add cc_heistcontracts and cc_lib to your wrapper’s fxmanifest.lua dependencies so boot order is correct: