Loot
Dropped-item entities: opaque payload, atomic first-claim, timed expiry. Server-only.
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayLoot(); and it wins.
ILootClaimPolicy game SPI
SPI the game implements to gate claims — consulted BEFORE the atomic take in both the manual claimer-aware TryClaim path and the AutoClaimRadius auto-claim pass. A veto leaves the drop in the world, still claimable by the next eligible entity (a full car/character can decline so the box is NOT consumed). The shipped default allows everything, so installing the piece changes nothing. How to implement. Register your policy in DI BEFORE AddCrossplayLoot (it is a TryAdd seam). It runs on the poll thread — for auto-claim it is asked once per nearby candidate per tick, so keep it a cheap lookup. The legacy claimer-less TryClaim overload consults it with claimer 0 (unknown).
public sealed class CargoRoomPolicy : ILootClaimPolicy
{
private readonly IMyCargo _cargo;
public CargoRoomPolicy(IMyCargo cargo) => _cargo = cargo;
// A full vehicle declines, so the crate stays for the next one that has room.
public bool CanClaim(long claimerEntityId, LootDropInfo drop)
=> _cargo.HasRoom(claimerEntityId, drop.Item, drop.Count);
} -
bool CanClaim(long claimerEntityId, LootDropInfo drop)Whether claimerEntityId may take this drop right now. False leaves the drop unclaimed in the world.
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
ILootService
Dropped-item entities: Drop spawns a pickup at a position and holds its OPAQUE item payload; TryClaim atomically takes it (first claimer wins) and despawns the entity; unclaimed drops expire. Delivery is the GAME's call — its interaction policy claims and hands the payload to whatever container system it uses. Authored SpawnPoints (or runtime RegisterSpawnPoint points) drop and re-drop themselves.
-
event Action<long, byte[], int> ClaimedRaised after a successful AUTO-claim (AutoClaimRadius > 0, or a per-drop claim radius): (claimerEntityId, item, count). The first argument is the CLAIMER, never the drop. Worth stating plainly, because the drop id is also a long from the same API and reading it the wrong way round compiles cleanly — a composition bridge did exactly that, called Remove(id) on a drop-keyed table, and silently removed nothing forever. The drop id is deliberately absent: it is already DESPAWNED by the time this fires, so if you need to forget per-drop state, subscribe to IWorldService.EntityRemoved, which covers claimed, expired and despawned alike. The claim went through the same atomic TryClaim path. Manual TryClaim calls do NOT raise it: the piece never learns who a manual claimer is — the caller does, and applies its own delivery. Composition bridges subscribe here to hand the payload to the auto-claimer.
-
long Drop(string zone, float x, float y, float z, byte[] appearance, byte[] item, int count, double despawnSeconds = 0)Spawns a loot entity at a position and remembers its opaque payload.
-
long Drop(string zone, float x, float y, float z, byte[] appearance, byte[] item, int count, double despawnSeconds, float claimRadius)Drop with a per-drop auto-claim radius. The default implementation ignores claimRadius and defers to the radius-less overload, so custom ILootService implementations keep compiling; the shipped service honors it.
-
event Action<long> ExpiredRaised when a drop expires unclaimed: (entityId) — the entity is already despawned by the time this fires.
-
bool IsLoot(long entityId)True when the entity is an unclaimed drop.
-
long RegisterSpawnPoint(LootSpawnPoint point)Registers a game-supplied loot spawn point at runtime — the programmatic sibling of SpawnPoints. The point drops immediately into its declared zone (created on first use, unless InstancesOnly) and into every live instance zone currently materialized from that declared zone, then re-drops after each claim/expiry like an authored row. Points declared for exactly a zone that is later released are auto-cleaned with it (authored option rows persist and re-materialize when the zone comes back). The default implementation does not support spawn points and throws; the shipped service overrides it.
-
bool TryClaim(long entityId, out byte[] item, out int count)Atomically claims a drop (first caller wins) and despawns the entity — call this from your game's pickup/interaction policy, then hand item to whatever container system you use. The atomic first-claim is the piece's guarantee; delivery is yours. Consults the ILootClaimPolicy with claimer 0 (unknown) — prefer the claimer-aware overload when you know who is taking it.
-
bool TryClaim(long entityId, long claimerEntityId, out byte[] item, out int count)Claimer-aware TryClaim: consults the ILootClaimPolicy for claimerEntityId BEFORE the atomic take, so a vetoed claim (a full inventory, a wrong role) leaves the drop in the world for the next eligible claimer instead of consuming it. The default implementation defers to the claimer-less overload, so custom ILootService implementations keep compiling.
-
bool UnregisterSpawnPoint(long spawnPointId)Removes a registered spawn point: pending respawns are cancelled everywhere; a currently live drop stays in the world as a PLAIN drop (claimable, expiring as usual) — it just never respawns. Option-declared points can be unregistered too (their id order follows SpawnPoints, starting at 1). The default implementation holds no points, so every id is unknown.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
LootOptions
Loot tuning (defaults here, never inline).
-
float AutoClaimRadius { get; set; }When > 0, any PLAYER entity within this radius (world units) of an unclaimed drop claims it automatically on the piece's tick — the nearest player wins, through the same atomic TryClaim path (first-claimer-wins is preserved), and Claimed reports who got it. 0 (the default) = off: claiming stays entirely the game's call. A drop's own claim radius (the claimRadius argument of Drop / a spawn point's ClaimRadius) overrides this per drop.
-
Dictionary<byte, List<DropGroup>> DeathDropGroups { get; set; }WEIGHTED drop groups per entity kind — "pick N of these, in proportion to weight", with quantity ranges. See DropGroup for why independent chances cannot express this. ADDITIVE to DeathDrops / DeathDropSets rather than replacing them, so a kind keeps its guaranteed coin and gains a "pick 1 of 8" on top, and a config with no groups rolls exactly what it always did. Rolled by LootTableRoller; empty (the default) = inert.
-
Dictionary<byte, List<DropTemplate>> DeathDropSets { get; set; }Multi-entry death drops: an entity kind → SEVERAL drops, each rolled by its own Chance (a boss leaves a coin AND a card; a regular enemy a coin plus a chance heal). When a kind has an entry here it is used INSTEAD of the single-entry DeathDrops table; a composition bridge rolls and drops each. Empty (the default) = only the single-entry table applies.
-
Dictionary<byte, DropTemplate> DeathDrops { get; set; }Pure DATA describing what an entity of a given kind leaves behind, keyed by the entity's appearance-kind byte. The piece itself never watches deaths — a composition bridge reads these templates and turns them into Drop calls when its game decides an entity of that kind is gone. Empty (the default) = inert.
-
double DefaultDespawnSeconds { get; set; }Despawn delay for unclaimed drops when a call leaves it at 0.
-
string InstanceSeparator { get; set; }Separator between an instance zone's prefix and its id for spawn-point template resolution. Matches the Dungeons piece's zone naming ("prefix:id") by default; empty disables instance resolution entirely.
-
Dictionary<string, string> SpawnPointTemplates { get; set; }Instance-zone template map for SpawnPoints: instance prefix → the declared zone whose points the instance inherits. Runtime-created instance zones are named prefix + InstanceSeparator + id (e.g. the Dungeons piece's "dungeon:17"); mapping "dungeon" to e.g. "crypt-level" makes every instance materialize the points declared under "crypt-level". An unmapped prefix falls back to the prefix itself (points declared under "dungeon" apply to "dungeon:17" directly).
-
List<LootSpawnPoint> SpawnPoints { get; set; }Authored loot spawn points — config/content-bindable rows the piece itself materializes: each row's item is dropped at its position at boot (and whenever its zone becomes available), and RE-dropped RespawnSeconds after that drop is claimed, expires, or is removed. Rows declared for a template zone also apply to that zone's prefix:id instances (the same template-resolution convention as WorldBoundsOptions — see SpawnPointTemplates / InstanceSeparator). Empty (the default) = inert; games can also register points at runtime via RegisterSpawnPoint.
-
bool SweptAutoClaim { get; set; }Swept auto-claim (off by default): additionally tests the segment from each candidate's LAST-tick position to its current one against the drop's claim radius, so a fast mover cannot cross a small radius entirely between two ticks without claiming. Costs a per-tick last-position record for the players near drops; positions are only remembered while the auto-claim pass runs, and only for entities inside a drop's padded query. An entity moving farther than SweptClaimPadding per tick can still out-run the query itself — size the padding at or above your fastest entity's per-tick travel distance.
-
float SweptClaimPadding { get; set; }Extra query radius (world units) added around each drop's claim radius while SweptAutoClaim is on, so entities whose last→current segment may have crossed the radius are seen even when both endpoints lie outside it. Set it to at least the farthest distance your fastest entity travels in one server tick.