Level Objects
Authored static level objects with server-authoritative state — pickups, crates, chests, levers, doors, checkpoints, moving platforms, goals. A designer places them as CONTENT (the piece defines its own content types, so the editors show a ready-made form) and the piece owns spawning, stable ids, the 3D reach test, the state blob and its replay on interest reveal. Five built-in rules cover the recurring shapes — claim-once, durability, counter, flag, clock-epoch — with an ILevelObjectRule SPI for anything novel; which verb acts on which kind is authored, not coded. Zero new wire: spawns ride World, state rides EntityState, verbs ride Interactions.
Seams you implement 2
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayLevelObjects(); and it wins.
ILevelObjectResetPolicy game SPI
WHO may reset a zone's authored objects over the wire (GAP-35's LevelObjectResetRequest) — the game's rule, because the framework cannot know what a "run", a "match" or a "lobby leader" is. The shipped default is DenyAllLevelObjectResetPolicy: a client-triggerable world reset is a griefing weapon, so nothing is resettable until the game says so — secure by default. Server-side code (a match end, a Rounds phase change, an admin verb) never consults this policy: it calls ResetZone directly, because the server is already the authority. This seam gates only what CLIENTS may request.
// A co-op game where any member of the run's party may restart the level:
public sealed class PartyMemberResetPolicy : ILevelObjectResetPolicy
{
public bool CanReset(ISession session, string zone) => _runs.IsInRunFor(session, zone);
}
// services.AddSingleton<ILevelObjectResetPolicy, PartyMemberResetPolicy>(); // before AddCrossplayLevelObjects -
bool CanReset(ISession session, string zone)Whether session may reset every authored object in zone right now. Consulted BEFORE any state changes; a refusal is reported to the requester as LevelObjectNotificationCodes.ResetRefused and nothing else happens.
ILevelObjectRule game SPI
SPI for an object rule the five built-in archetypes cannot express — the escape hatch, not the normal path. A kind names its rule in content (RuleName) and the piece still owns everything around it: spawning, the id map, the appearance blob, the reach test, publishing the state and replaying it on interest reveal. All you write is the transition. How to implement. Register your rule in DI (as ILevelObjectRule; several may coexist, each matched by its Name) and set the kind's rule name to match in the content editor. The framework has already checked that the actor is in the world, that the target exists in the same zone, that the payload is within its cap, and — unless the kind opted out — that the actor is close enough.
// An item box that dispenses in an authored ORDER rather than just counting down.
public sealed class OrderedDispenser : ILevelObjectRule
{
public string Name => "ordered-dispenser";
public LevelObjectOutcome Apply(in LevelObjectContext context, ISession actor, WorldEntity actorEntity,
byte[]? data)
{
int dispensed = context.Value;
if (dispensed >= context.Kind.InitialValue)
return LevelObjectOutcome.Reject(InteractionNotificationCodes.Rejected); // empty
_game.GiveItem(actor, _order[dispensed]); // the game's own meaning
return LevelObjectOutcome.Accept(LevelObjectState.Value(dispensed + 1));
}
}
// services.AddSingleton<ILevelObjectRule, OrderedDispenser>(); -
LevelObjectOutcome Apply(in LevelObjectContext context, ISession actor, WorldEntity actorEntity, byte[] data)Decides what this interaction does to the object. Called on the poll thread, after the framework's generic gates and the reach test.
-
LevelObjectOutcome ApplyServer(in LevelObjectContext context, byte[] data)The same decision with NO actor — a scripted event, a timer, a cross-zone effect, anything server code drives through TryApplyServer. Return the same outcomes as Apply; the state you publish reaches observers identically.
-
string Name { get; }The name a content-authored kind uses to select this rule (RuleName). Matched case-insensitively.
Providers you can swap 2
Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.
ILevelObjectHarmPolicy provider
Optional gate + feedback for the one archetype that is genuinely COMBAT: Durability. Absent — the default — a durability object behaves exactly as it always has: anyone in reach may hit it. It exists because "a destructible" and "a destructible somebody OWNS" are different things. For a co-op crate there is no question to ask: level objects are not players, so no hostility rule applies. But a faction's siege wall or a base's turret is a legitimate level object whose damage must obey the same consent / flag / safe-zone rules as damaging its owner — and a game that installed a hostility system would otherwise find those rules quietly bypassed for exactly the objects that most need them. Why a piece-owned seam rather than an IHarmResolver reference (rule 13): a game can install LevelObjects without any hostility piece and it still does its whole job, so hostility is a seam, not a foundation. The composition layer binds this to the Hostility piece with zero reference in either direction; a game with its own vocabulary registers its own implementation instead.
-
bool CanHarm(WorldEntity actor, WorldEntity target)Whether actor may damage target right now. Consulted BEFORE any state change, so a refusal can never spend a hit.
-
void Destroyed(WorldEntity actor, WorldEntity target)The hit that took the object to zero. Separate from Harmed because "damaged" and "destroyed" escalate differently in every hostility model that has both.
-
void Harmed(WorldEntity actor, WorldEntity target, int remainingValue)A hit landed. Feed a hostility/threat/aggro system here.
ILevelObjectTable provider
Where the authored level-object table comes from. The default implementation reads it from the CONTENT store, so a designer adds objects in the Hub / web / Unity content editor and the server needs no change — but the seam exists so a game with its own bake pipeline can supply the same rows from a file, a database, or a level-editor export.
-
event Action ChangedRaised when the authored table changed (a designer edited content), so the service can re-realise the level. Implementations that cannot change at runtime may leave this unraised.
-
IReadOnlyList<LevelObjectInstance> Instances { get; }Every authored instance to spawn.
-
IReadOnlyDictionary<ushort, LevelObjectKind> Kinds { get; }Every authored kind, by kind id.
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
ILevelObjectService
Server-side API for the authored level objects. A game rarely calls this directly — the piece's own interaction policy drives it — but it is exposed so a game that owns its IInteractionPolicy can offer this piece's objects the first refusal and then handle its own verbs.
-
int LiveCount { get; }How many authored objects are currently live in the world. Diagnostics and tests.
-
bool Owns(WorldEntity target)Whether target is one of this piece's objects at all. Cheap; use it to decide whether to delegate before calling TryHandle.
-
int ResetZone(string zone)Returns every realized object in zone to its authored initial state (GAP-35): latched flags unlatch, counters and durability return to their authored values, triggered clocks re-arm, and consumed ClaimOnce objects respawn (under the ordinary per-tick spawn budget). Every session currently observing the zone receives the refreshed state through the existing EntityState broadcast / World spawn machinery — nothing new rides the wire. The game calls this when ITS notion of a run/match/round ends; clients may request it over the wire only through the ILevelObjectResetPolicy gate. An empty or unknown zone is a safe no-op. The default implementation resets nothing and returns 0, so existing ILevelObjectService fakes keep compiling (the UnregisterSpawnPoint precedent).
-
event Action<LevelObjectChange> StateChangedRaised after an authored object's state changed — by a player's verb OR by server code.
-
bool TryApplyServer(long entityId, ushort actionId, byte[] data, out ushort reasonCode)Runs a verb against one of this piece's objects with NO actor — the archetype or the kind's ILevelObjectRule decides, and the resulting state publishes identically to a player's interaction.
-
bool TryHandle(ISession actor, WorldEntity actorEntity, WorldEntity target, ushort actionId, byte[] data, out ushort reasonCode)Applies an interaction to one of this piece's objects.
-
bool TryResolve(string zone, string identifier, out long entityId)Resolves an authored object's stable identifier, in a concrete zone, to its live world-entity id.
-
bool TrySetValue(long entityId, int value, out ushort reasonCode)Sets one of this piece's objects to an exact state, server-authoritatively — no actor, no reach test — and publishes it exactly as an interaction would.
-
bool TryStamp(long entityId, uint serverMs, out ushort reasonCode)Publishes a clock stamp on a ClockEpoch object, server-authoritatively. Separate from TrySetValue because a stamp is a UInt32 of server milliseconds and would truncate through an Int32 after ~24 days of uptime.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
LevelObjectOptions
Configurable level-object parameters (defaults here; never inlined at a use site — rule 4). Bound from Crossplay:LevelObjects.
-
byte AppearanceMarker { get; set; }First byte of this piece's appearance blobs, distinguishing a spawned level object from a game's own avatar appearance. Change it only if your avatar blobs already begin with DefaultMarker; both tiers must agree, so the client's LevelObjectClientOptions carries the same value.
-
bool ArbitrationDelegated { get; set; }NOT a tunable — a wiring fact, recorded by AddCrossplayLevelObjects when it found a game-registered IInteractionPolicy and left it standing (the documented seam). The service uses it to tell "the game owns arbitration by choice" from "nothing consults this piece at all": only the second is the silent GAP-17 failure, and only the second is warned about at startup. Setting it by hand merely silences that warning.
-
string DefaultZone { get; set; }Zone the objects spawn into when a content row leaves the zone field empty. Empty = the world's default zone.
-
bool DefineContentTypes { get; set; }Whether the piece defines its own content TYPES at startup, so the Hub / web / Unity content editors immediately show a ready-made form and a designer can add level objects without a server change or a single line of code. Leave it on unless you author the type definitions yourself.
-
bool Enabled { get; set; }Master switch. Off = nothing is spawned and no verb is handled, so the piece is inert even with content authored. On by default: an installed piece with an EMPTY content table already does nothing, so the switch exists to silence it without deleting the designer's rows (a level-editing / debugging convenience), not as an opt-in gate.
-
string InstanceSeparator { get; set; }Separator between an instance zone's prefix and its id for template resolution. Matches the Dungeons piece's zone naming ("prefix:id") by default; empty disables instance resolution entirely.
-
string InstanceTypeKey { get; set; }Content type key for a level-object INSTANCE (one placed object in a level).
-
Dictionary<string, string> InstanceZoneTemplates { get; set; }Instance-zone template map: instance prefix → the authored zone whose rows the instance realizes. 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 realize the rows authored under "crypt-level". An unmapped prefix falls back to the prefix itself (rows authored under "dungeon" apply to "dungeon:17" directly) — the same template-resolution convention as WorldBoundsOptions.Templates and LootOptions.SpawnPointTemplates.
-
string KindTypeKey { get; set; }Content type key for a level-object KIND (the shape + rule + verb of a family of objects).
-
int MaxResetZoneNameLength { get; set; }Longest zone name a wire-borne reset request may carry; anything longer is refused before the policy is even asked. Aligned with WorldOptions.MaxZoneNameLength's default, for the same reason: the string comes from the client, so its length is an input, not a fact.
-
int MaxSpawnsPerTick { get; set; }Most objects spawned per tick while the table is being realised. Bounds the startup cost of a large level (rule 11: unbounded work gets a budget) — the remainder spawns on following ticks, which is invisible because it happens before or as the first player enters. 0 = no limit (spawn all at once).
-
bool ReachCheck { get; set; }Whether the reach test runs at all by default. A kind can opt out individually (authored content field), which is what a MOVING object needs: its authored position is only a spawn point, so checking against it would refuse legitimate interactions with something that has walked away.
-
float ReachSlackUnits { get; set; }Slack added to an object's authored radius when validating that an actor is close enough, in world units. This is an anti-cheat envelope, not a gameplay rule — the gameplay rule is the authored trigger on the owning client, which is what makes an interaction feel identical to single-player. The server only refuses requests made from implausibly far away, and a false refusal is a visible fidelity break, so the default is derived rather than guessed: with a client-authoritative avatar the server's copy of a position trails the client's by up to MaxSpeed / SendRateHz — at the Motor's defaults (100 u/s, 20 Hz) that is 5 units. Anything the Motor accepted therefore lands inside this reach. Raise it in step with a higher MaxSpeed or a lower report rate. The protection that actually matters — claiming one object twice — comes from the archetype's state, never from this distance.
-
bool RealizeInInstanceZones { get; set; }Whether authored rows are realized into INSTANCE zones (GAP-35): when a zone named prefix:id (see InstanceSeparator) comes into existence, the rows authored for the template zone prefix — or its InstanceZoneTemplates mapping — spawn into that instance with FRESH state, keyed by the full instance zone name, so each run/party gets a virgin copy of the level and IWorldService.ZoneReleased reclaims it. ON by default, with the same back-compat reasoning as the Loot piece's spawn-point templates: before GAP-35 an instance zone revealed NOTHING from this piece (rows bake a literal zone), so realizing template rows is purely additive — it only fires for an instance zone whose prefix matches a zone that actually has authored rows, which is exactly the composition asking for it. Turn it off to pin authored objects to their literal zones only.
-
int ResetRequestBurst { get; set; }Maximum burst of reset requests a session can spend at once (bucket capacity).
-
double ResetRequestsPerSecond { get; set; }Sustained LevelObjectResetRequests allowed per second, per session (the per-message token-bucket seed this piece contributes to RateLimitOptions.PerMessage). A reset is a zone-wide fan-out, and no human restarts a level several times a second.
Wire messages 2
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
LevelObjectResetRequest Client → server: "start this zone over" — return every authored object in Zone to its authored initial state and respawn what was consumed (GAP-35). WHO may say this is entirely the game's ILevelObjectResetPolicy; with none registered the request is always refused.
LevelObjectResetResponse Server → requester: the reset verdict. Deliberately does not echo the requested zone — the client's reset RPC is one-in-flight, so there is nothing to correlate and nothing to amplify.
Unity seams you implement 1
The client half's sockets. Bind your implementation in the client context and Crossplay's client calls it — this is where your models, animators, UI and controls plug in.
ILevelObjectBinder unity SPI
The game seam for authored level objects — where presentation begins and this piece ends. The piece resolves each spawned object to its authored row and tracks its authoritative state; what an object LOOKS like is entirely yours. Bind a 3D prefab, a sprite, a UI list row, or a line of text — the piece cannot tell the difference, which is the whole point: a 2D top-down client and a text-only client use it unchanged. How to implement. Register your binder in DI (it is resolved optionally, so a game with no binder still runs — the piece just tracks state and raises events). Read Kind to tell one family from another and Record for the authored fields; decode State with LevelObjectState in StateChanged.
-
void Bound(LevelObjectView view)A level object came into view and was resolved. Attach your visual here. Record may still be null if content has not delivered the authored row yet — RecordResolved follows when it does.
-
void StateChanged(LevelObjectView view)The object's authoritative state changed (or was replayed when it entered view). Read State.
-
void RecordResolved(LevelObjectView view)The authored content row for an already-bound object arrived (or was edited by a designer while the game was running — content hot-reloads, so this can fire at any time).
-
void Unbound(long entityId)The object left view or was consumed. Release your visual. Note that for a ClaimOnce object this arrives a tick AFTER the interaction that consumed it, precisely so the "who took it" event reaches you first and you can play the pickup.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
ILevelObjectClient
Read access to the level objects currently in view, and the send side that goes with it. Both directions. Inbound is the events below (and the ILevelObjectBinder seam): something appeared, its state moved, it went away. Outbound is Interact and the two lookups — because a game that predicts locally knows which authored OBJECT the player just touched (its content record id) and needs the ENTITY id to send, and this piece already owns that mapping. Without them every predicting game rebuilds the same scene-object → entity-id dictionary off Bound/Unbound.
-
IReadOnlyCollection<LevelObjectView> AllEvery level object currently in view.
-
bool TryGet(long entityId, out LevelObjectView view)Looks up one object by its entity id.
-
bool TryGetByRecord(long recordId, out LevelObjectView view)Looks up one object by the CONTENT RECORD that authored it — the id a game holds against its own scene objects, and the direction it needs when SENDING.
-
bool TryGetEntityId(long recordId, out long entityId)The entity id currently realising an authored object — what goes in an InteractRequest. The mapping is maintained from the piece's own spawn/despawn feed, so it can never drift from what the server believes.
-
bool Interact(long recordId, byte[] payload = null)Acts on an authored object with the verb its KIND was authored with — one call, no maps, no verb constants. Resolves the record to its live entity and sends through the Interactions piece.
-
bool Interact(long recordId, ushort actionId, byte[] payload = null)Acts on an authored object with an explicit verb — for a game whose kind takes several verbs through an ILevelObjectRule, or that wants to send a verb the kind row does not carry.
-
UniTask<LevelObjectResetResponse> ResetAsync(string zone, CancellationToken ct = default)Asks the server to reset every authored object in zone to its authored initial state — "this run is over, give the level back" (GAP-35). Latched goals unlatch, counters and durability return to their authored values, and consumed collectibles respawn; everyone observing the zone receives the refreshed state through the piece's ordinary feeds (StateChanged for live objects, Bound for respawns). WHO may reset is entirely the server's ILevelObjectResetPolicy — the shipped default refuses everyone, so this fails with ResetRefused until the game registers a policy. One request in flight at a time (the piece's RPC convention); a timeout resolves to a failed response rather than throwing, so a game may call it optimistically.
-
LevelObjectBindStatus BindStatusWhat bound, right now — how many level objects are in view and how many of them carry their authored row. Free of side effects, so a diagnostics overlay may poll it every frame.
-
LevelObjectBindStatus AuditBinding()Runs the bind audit now: asks for any authored row that has not arrived, then reports the result to the log (once per change, plus one warning when rows are genuinely missing). The piece runs this itself once the spawn burst has settled — this entry point exists because the failure it detects is invisible by construction (GAP-13: the piece disables itself and the game keeps working), so a game that wants the answer on its own schedule, or a test that wants it without waiting out BindAuditGraceSeconds, can force it.
-
event Action<LevelObjectView> BoundA level object came into view and was resolved.
-
event Action<LevelObjectView> StateChangedA level object's authoritative state changed (or replayed on reveal).
-
event Action<long> UnboundA level object left view or was consumed.