duels

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.

Category Combat Seams 1 Services 1 Options 1 Wire ids 6 Unity types 1
Server
services.AddCrossplayDuels();
Unity package
com.crossplay.duels
Depends on
core

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> Ended

    Raised when a duel ends, as (winnerId, loserId, reason) — 0 ids when nobody won.

  • event Action<long, long> Started

    Raised 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.

6701 DuelChallengeRequest

Client → server: challenge somebody to a duel.

6702 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.

6703 DuelRespondRequest

Client → server: answer the pending challenge.

6704 DuelStarted

Server → both duellists: it is on.

6705 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.

6706 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> Challenged

    Somebody is challenging you. Answer with Respond before it lapses.

  • event Action<DuelStarted> Started

    A duel you are in has begun.

  • event Action<DuelEnded> Ended

    A duel (or a challenge) is over — including a decline or a lapse.

  • bool InDuel

    Whether 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.