Skip to content

Bounties

Bounties turn the personal-reputation system into a hunt-and-collect loop. They give every player on the server a financial incentive to take down the worst offenders, and they give wronged players a way to put a real price tag on revenge.

A bounty is an amount of credits attached to a target. Anyone who lands the killing blow against that target in ship-vs-ship combat collects the entire pot.

Two kinds of bounty

Kind Created by Stored where Refundable
Player-placed Any player paying the placement fee Player.settings["bounties"] JSONB list (on the target) Yes (principal only)
System Auto-attached by the Federation Bounty Board based on the target's personal_reputation Computed on demand from reputation tier n/a

System bounties never live in the database — they are derived by reading the target's personal_reputation and looking up SYSTEM_BOUNTY_TIERS. Player-placed bounties are persistent JSONB entries on the target.

✅ Shipped — service-layer bounty placement, system-tier derivation, collection on kill, placer-only cancel/refund, REST endpoints (router mounted 2026-06-11). 📐 Design-only — the realtime bounty_updated emit: bounty_service locks rows and flushes but emits no event on place, cancel, or collect, so connected clients do not yet refresh without polling.

🚧 Partial — admin tooling for cancelling stuck bounties on deleted targets, and a soft cap that auto-collapses many small placements.

📐 Design-only — faction-issued bounties (Federation putting a bounty on a specific pirate captain that pays out only to faction members).

Placing a bounty

A player calls POST /ranking/bounties/place with {target_id, amount}. The placer pays amount + 10% up front:

amount             >= 1000 credits      (BOUNTY_MIN_AMOUNT)
fee                = floor(amount * 0.10)  (non-refundable)
total_cost         = amount + fee
placer.credits    -= total_cost
target.settings["bounties"].append({
    id, placed_by, placed_by_name,
    amount, placed_at, type: "player"
})

Self-bounties are rejected. The placer's row is row-locked during placement to keep two simultaneous placements from double-spending the same credits. ✅ Shipped.

A bounty_updated event (action: "placed") is emitted on the realtime bus to the placer, the target, and any global bounty-board subscribers. 📐 Design-only — the emit is unwired (placement locks and flushes only).

System bounty tiers

The Federation attaches a system bounty to any player whose personal_reputation falls to −500 or below. The bounty is a stored pot that accrues over time (growing faster on dastardly acts via npc_scheduler) and resets to 0 after a hunter collects it. The pot grows toward the cap of the deepest reputation tier the criminal currently matches — only the deepest tier's cap applies.

personal_reputation Pot cap
−500 5,000
−750 75,000
−1,000 250,000

The pot re-accrues from 0 after each collection. Reputation recovery above −500 stops accrual; the residual pot stays until collected or the player's reputation rises above the threshold for long enough for it to decay.

✅ Shipped — stored-pot model, tiered caps, and scheduler accrual. See services/gameserver/src/services/bounty_service.py and DECISIONS.md § system-bounty-accrual-model.

Collection on kill

When the combat resolver finishes a ship-vs-ship fight with a clear winner (see combat.md), it calls BountyService.collect_bounty(collector_id, target_id):

  1. Lock both player rows.
  2. Sum player-placed bounties on target.
  3. Sum system bounties (computed from reputation).
  4. Credit the collector with the full total.
  5. Clear target.settings["bounties"] (player-placed only — system bounties go away when the target's reputation recovers).
  6. Emit bounty_updated (action: "collected") to the collector, the target, and globally.

System-bounty payouts come from the Federation treasury; the model treats them as a pure credit source (the Federation is not modeled as a wallet). Player-placed payouts come from the escrow held in each placement.

✅ Shipped — credit and clear on kill. 📐 Design-only — the collect emit: collect_bounty credits and clears under row locks but emits no bounty_updated event.

Cancellation

The placer can cancel an uncollected bounty:

POST /ranking/bounties/{bounty_id}/cancel  {target_id}
1. row-lock both the placer and the target
2. find entry by bounty_id in target.settings["bounties"]
3. verify placed_by == placer.id, not a system bounty, still present
4. remove the entry from the list FIRST
5. credit the uncollected principal (NOT the 10% fee) to placer.credits
6. emit bounty_updated (action: "cancelled")

Only the placer may cancel, and only a not-yet-collected bounty refunds. Removing the entry before crediting, under the dual row-lock, makes a double-refund impossible. The 10% fee is gone forever (canon invariant #9). Cancellation is a soft escape hatch, not a frictionless undo. ✅ Shipped.

Expiration

Bounties do not auto-expire. They sit on the target until collected or cancelled.

📐 Design-only — optional expiry timestamp on placement (expires_at); auto-refund-minus-fee on expiry. Useful for time-pressure events (e.g., a 48-hour vendetta).

Browsing bounties

GET /ranking/bounties/available returns the top-N targets ranked by total bounty value (player + system combined), filtered to Player.is_active = true. Result shape per target:

{
  "player_id": "...",
  "player_name": "...",
  "reputation_tier": "Villain",
  "total_bounty": 137000,
  "bounty_count": 4,
  "current_sector": 442
}

This is the public bounty board. ✅ Shipped (service method get_available_bounties).

🚧 Partial — UI surface in the player client (a styled board with portraits, last-seen sector, recent kill log).

Reputation interactions

Bounties and reputation are tightly coupled:

  • Killing a player who had bounties awards +100 reputation (defeat_bounty_target) — bounty hunting is heroic.
  • Killing a player who had no bounty (an "innocent") costs −100 reputation (attack_innocent).
  • Killing a player in an escape pod costs −500 reputation regardless of bounty status (kill_escape_pod).
  • Defending against an attacker awards +50 (defend_against_attacker).

Drift to personal_reputation ≤ −500 automatically attaches a system bounty, so attacking innocents and pod-killers are self-targeting — they place a bounty on themselves the more they offend.

See SYSTEMS/bounty-and-reputation.md for the full reputation tier table and combat-hook contract.

Edge cases

Case Behaviour
Two attackers fight the same target simultaneously Combat resolver determines a single winner; collect_bounty runs once for that winner. Both rows row-locked during the call.
Target has zero bounties when killed collect_bounty returns {success: false}; combat result is unaffected.
Bounty placed on a player who deletes their account Placer can refund via admin tool. 🚧 Partial.
Bounty list grows unbounded (50+ small placements on one target) Soft cap at 50 entries; older entries collapsed by placer (sum amounts). 📐 Design-only.
Reputation recovers after system bounty was attached System bounty disappears the moment personal_reputation rises above the threshold (computed live).

Source map

Concern Path (target)
Bounty service services/gameserver/src/services/bounty_service.py
Reputation service (deltas) services/gameserver/src/services/personal_reputation_service.py
Combat hook calling collect_bounty services/gameserver/src/services/combat_service.py
Player JSONB field services/gameserver/src/models/player.py (settings["bounties"])
REST routes services/gameserver/src/api/routes/ranking.py (/ranking/bounties/*)
Realtime events services/gameserver/src/services/websocket_service.py