Duels (1v1)
Consensual 1v1: challenge -> accept -> fight -> the winner is decided by a SERVER-reported defeat, a forfeit, or the time limit. There was no challenge/accept flow anywhere (matches is match containers, tournaments is brackets, and hostility models consent as a standing STANCE rather than "this person, now, then we stop"). The piece is CORE-ONLY: mutual harm, ending a duel just before the loser would die, and restoring the health they spent are the composition's job (Crossplay:DuelCombat over Hostility + Stats), so it also composes into a game with neither. IDuelPolicy = eligibility + stakes.
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayDuels(); and it wins.
IDuelPolicy game SPI
Whether a duel may happen — the game's eligibility and stakes (GAP-14). The framework has no opinion on level gaps, safe zones, arena-only rules, or wagers; it only guarantees that both sides CONSENTED. The default allows everything, so installing the piece adds a verb and no rules.
-
bool CanChallenge(ISession challenger, long challengerId, long targetId)May this challenge be issued at all?
-
void OnDuelState(long challengerId, long opponentId, bool started, long winnerId)Called once a duel actually starts and once when it ends — the hook for stakes: escrow a wager on start, pay it out on end.
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IDuelService
Consensual 1v1 duels (GAP-14). The gap this closes. There was no challenge/accept flow anywhere: matches is match containers, tournaments is brackets, and hostility models consent as a STANCE — a standing "I am attackable", not "this one person, right now, and then we stop". So the most ordinary PvP verb in any world with rules was unreachable. The piece is Core-only on purpose. It owns consent, state, forfeit and timeout; the DEFEAT is server-reported through TryReportDefeat and can never be client-forged, exactly like a tournament result. Everything cross-piece — making the pair mutually harmable, ending on a depleted stat, snapshotting and restoring health — lives in the composition bridge, where Hostility and Stats already are.
-
bool Abandon(long entityId)Ends whatever duel an entity is in because it left the world / logged out.
-
bool AreDuelling(long a, long b)Whether these two are duelling EACH OTHER right now — what a harm gate asks.
-
Duel DuelOf(long entityId)The duel this entity is in, or null.
-
event Action<long, long, DuelEndReason> EndedRaised when a duel ends, as (winnerId, loserId, reason) — 0 ids when nobody won.
-
event Action<long, long> StartedRaised when a duel starts, as (challengerId, opponentId).
-
void Tick(float deltaSeconds)Ages pending challenges and the duel time limit.
-
bool TryChallenge(ISession session, long targetEntityId, out ushort reason)Issues a challenge (one pending challenge per player, either direction).
-
bool TryForfeit(ISession session, out ushort reason)Gives up the duel this player is in.
-
bool TryReportDefeat(long loserEntityId)SERVER-reported defeat — the only way a duel is won on merit. Not on the wire, so a client cannot declare itself the winner.
-
bool TryRespond(ISession session, bool accept, out ushort reason)Answers the challenge addressed to this player.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
DuelOptions
Duel tuning — defaults here, never inline (rule 4).
-
float ChallengeTimeoutSeconds { get; set; }Seconds a challenge waits for an answer before lapsing.
-
float TimeLimitSeconds { get; set; }Seconds a duel may run before it times out with no winner. 0 = no limit. A limit matters more than it looks: two players who both refuse to commit would otherwise stay mutually harmable forever, which in a no-PvP world is a standing exemption from the rules.
Wire messages 6
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
DuelChallengeRequest Client → server: challenge somebody to a duel.
DuelChallengeReceived Server → the challenged player: someone wants to duel. Consent is the whole point. Nothing about the duel takes effect — no mutual harm, no stat snapshot — until this is ACCEPTED, which is what separates a duel from being attacked.
DuelRespondRequest Client → server: answer the pending challenge.
DuelStarted Server → both duellists: it is on.
DuelEnded Server → both duellists: it is over. A loser is NOT dead. The bridge that ends a duel on a depleted stat restores what it snapshotted, so losing costs you the duel and nothing else — that is the difference between a duel and being killed, and it is why a duel has to intercept the defeat rather than observe it afterwards.
DuelForfeitRequest Client → server: give up the duel I am in.
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IDuelClient
Consensual 1v1, client side: challenge, answer, forfeit. Note what this cannot do — claim a win. The result only ever arrives from the server, because a duel result a client can send is a duel result it can forge. The piece carries no UI: bind the events to your own challenge prompt and scoreboard.
-
event Action<DuelChallengeReceived> ChallengedSomebody is challenging you. Answer with Respond before it lapses.
-
event Action<DuelStarted> StartedA duel you are in has begun.
-
event Action<DuelEnded> EndedA duel (or a challenge) is over — including a decline or a lapse.
-
bool InDuelWhether this client is in a duel right now, per the last server message.
-
void Challenge(long targetEntityId)Challenges an entity you can see.
-
void Respond(bool accept)Accepts or declines the pending challenge.
-
void Forfeit()Gives up the duel you are in.