Skip to main content

Asynchronous PvP API contract

Status and purpose

This document is the issue #178 contract for the planned asynchronous player-versus-player mode. It is an implementation input for persistence issue #179, API and deadline-processing issue #180, and player-experience issue #181. It does not claim that these routes, tables, background jobs, or screens are implemented or deployed.

The contract keeps asynchronous PvP separate from the later one-minute live mode. Asynchronous play uses persisted state, polling, and 24-hour response windows. It does not introduce WebSockets, live presence, spectators, matchmaking, ratings, seasons, or client-authoritative results.

The API and PostgreSQL remain authoritative. The browser submits authenticated intent, renders viewer-safe state, and may poll committed resources. It never submits or calculates damage, effects, randomness, legal-action results, deadlines, winners, completion, forfeiture, or progression awards.

Confirmed and unresolved decisions

The following rules are fixed by issue #178 and this contract:

  • challenge discovery uses the existing unique Player Name and discloses no email, Auth0 subject, role, location, internal player UUID, or private history;
  • invitations expire 24 hours after their server-recorded creation time;
  • each decision window in an accepted match lasts 24 hours;
  • a missed match decision automatically forfeits the match rather than applying the shared rules package's live/CPU timeout action;
  • deck and card values are validated and snapshotted before use;
  • existing deterministic move, effect, replacement, and victory rules are shared with CPU battles;
  • only participants can inspect invitations, matches, and history;
  • polling is the only planned update mechanism;
  • every client mutation is idempotent and contains intent only; and
  • match completion exposes a stable exactly-once progression source without inventing client-controlled or unapproved award values.

The first delivery deliberately does not add challenge cancellation or a voluntary resignation intent. Account deletion invalidates pending invitations and finalizes an active match as an account-deletion forfeit for the opponent. Completed records retain an anonymous player tombstone under the existing retention policy.

The following social and privacy decisions are unresolved and require separate product authority before a later implementation claims them:

  • opting out of challenges or limiting challenges to approved contacts;
  • player blocking, reporting, moderation, and abuse-response workflows;
  • notification channels and notification retention;
  • per-player pending-invitation and active-match limits beyond the one-pair rule below;
  • retention periods for declined and expired invitations; and
  • whether a reused Player Name should receive additional protection after account deletion.

Until those decisions are approved, exact lookup, the generic authenticated rate limit, and one pending invitation per unordered player pair are the bounded first-delivery protections. They are not substitutes for blocking, reporting, or moderation.

Identity and discovery

Only an authenticated active player with a non-null Player Name can look up or challenge another player. Lookup is an exact canonical Player Name match; it is not prefix search, autocomplete, recommendations, or a player directory.

The lookup response contains only:

FieldTypeMeaning
playerNamestringExact canonical challenge name
requestIdstringServer-generated safe correlation identifier

No response contains the target's internal UUID, provider identity, account role, email, location, activity, deck, collection, rank, or challenge history. A missing, null-name, deleted, deletion-pending, or self target uses the same PVP_PLAYER_UNAVAILABLE response. Invitation creation resolves the supplied Player Name again inside its transaction, so a prior lookup is never authority.

Player Name is the only client-visible challenge identity. Internally, foreign keys use server-resolved player UUIDs. The browser cannot submit a player UUID as an ownership or authorization claim.

Invitation lifecycle

An invitation has one of these states:

pending -> accepted
-> declined
-> expired

accepted, declined, and expired are terminal and immutable. There is no first-delivery cancellation transition.

Creation

The challenger supplies a saved deck and an initial Creature from that deck. The server:

  1. derives the challenger from the validated token;
  2. resolves the exact opponent Player Name to an active player;
  3. rejects self-challenges;
  4. validates deck ownership and the complete three-Creature/two-Power, ten-point rule;
  5. validates that initialCreatureId is one of the deck's Creature copies;
  6. snapshots the challenger deck and battle-ready card values;
  7. records createdAt from the server clock and expiresAt = createdAt + 24 hours; and
  8. commits the invitation and submission receipt atomically.

Only one pending invitation may exist for an unordered player pair. Two players challenging one another concurrently therefore produce one committed pending invitation; the other request returns PVP_INVITATION_ALREADY_PENDING. This does not prohibit a later rematch or establish a global active-match limit.

The recipient sees the challenger Player Name and invitation timestamps, but not the challenger deck, snapshot, card identities, initial selection, or collection. Invitation views do not expose either deck or initial selection; the server retains them only in the private persistence snapshot needed to start an accepted match.

Acceptance

The recipient accepts with their saved deck and initial Creature. In one locked transaction the server:

  1. locks the invitation through a recipient-scoped lookup;
  2. performs the fresh post-lock submission-receipt check, returning an identical committed replay before applying a deadline;
  3. finalizes a still-pending invitation as expired when now >= expiresAt;
  4. checks expectedVersion and lifecycle state;
  5. validates and snapshots the recipient deck;
  6. applies both hidden initial selections through the shared rules package;
  7. uses server-side randomness to choose the first side;
  8. creates one active asynchronous match and its first decision deadline;
  9. marks the invitation accepted and links the match; and
  10. persists the response used for idempotent replay.

The match starts only when all steps commit. Neither participant can observe a partially accepted invitation, an unmatched snapshot, or the opponent's hidden initial selection. The first turnDeadline is 24 hours after the acceptance transaction's server timestamp.

Decline and expiry

Only the recipient may decline. Decline records the server timestamp and no match or progression source is created.

Invitation expiry is successful when now >= expiresAt. It may be finalized by the background sweep or by an invitation read or mutation that locks the overdue row. Expiry has no winner and no progression effect. A late accept or decline leaves the invitation persisted as expired and returns PVP_INVITATION_EXPIRED rather than reviving it.

Match lifecycle and deadlines

An asynchronous match is either active or completed. A completed match has one immutable completion reason:

ReasonMeaning
defeatShared battle rules produced a winner
turn_forfeitThe required participant missed a 24-hour window
account_deletion_forfeitAn active participant began permanent deletion

The API maps the internal player and cpu rule-engine sides to viewer-relative self and opponent. CPU-oriented side names and internal player UUIDs do not cross the PvP HTTP boundary.

Each committed state identifies either no actor because it is terminal, or one participant who must act. An active move turn and a mandatory replacement both receive a fresh deadline:

turnDeadline = decisionStartedAt + 24 hours

A mandatory replacement remains a separate shared-rule decision and does not consume the replacement player's following normal turn. Committing the replacement therefore creates a new 24-hour deadline for that player's normal turn.

At the exact boundary, now >= turnDeadline means the deadline has passed. The overdue participant loses by turn_forfeit; the opponent wins. The API never converts this event to the rules package's { "type": "timeout" } transition, because that action skips a turn in CPU/live play instead of completing an asynchronous forfeit.

Reads and polling never extend a deadline. Only a committed legal action that creates another required decision changes it.

Viewer-safe match projection

All match fields are protected. In this document, shared means visible to both authenticated participants, not anonymous or spectator-visible.

DataSelfOpponentNon-participant
Player NameYesYesNo
Internal player UUID/Auth0 dataNoNoNo
Match status, version, timestamps, current actorYesYesNo
Own full Creature and Power snapshotsYesNoNo
Own current HP, Power, modifiers, and used cardsYesNoNo
Opponent active and defeated CreaturesYesViewer-relative equivalentNo
Opponent unused Creature identitiesNo; count onlyNo; count onlyNo
Opponent unused Power-card identitiesNo; count onlyNo; count onlyNo
Legally played Power cards and resolved effectsYesYesNo
Legal actionsOnly when self actsNoNo
Unplayed cards in completed historyNoNoNo
Authoritative outcome and completion reasonYesYesNo

The response does not use null card objects as hidden placeholders because their number or position could leak deck ordering. It returns explicit aggregate counts and separate revealed-card arrays. A history replay contains only actions and card information that became legally visible during play.

The common match response is viewer-relative:

FieldTypeRequiredNotes
matchIdUUID stringYesOpaque match identifier
mode"asynchronous"YesNever aliases live PvP
status"active" | "completed"YesAuthoritative state
versionnon-negative integerYesOptimistic-concurrency version
opponent.playerNamestringYesOnly approved opponent identity
currentActor"self" | "opponent" | nullYesViewer-relative
turnDeadlineRFC 3339 timestamp or nullYesNull only when completed
stateAsyncPvpStateViewYesViewer-safe battle projection
legalActionsaction arrayYesEmpty unless self may act
lastActionPvpActionResultView or nullYesLegally visible committed result
outcome"win" | "loss" | nullYesNon-null when completed
completionReasonterminal reason or nullYesNon-null when completed
startedAtRFC 3339 timestampYesServer timestamp
updatedAtRFC 3339 timestampYesPolling reconciliation field
completedAtRFC 3339 timestamp or nullYesServer timestamp
requestIdstringYesSafe request correlation

AsyncPvpStateView is a closed object with these required fields:

FieldTypeVisibility rule
phase"active" | "replacement" | "completed"Shared
turnNumberpositive integerShared
selfSelfParticipantViewFull viewer-owned battle snapshot
opponentOpponentParticipantViewRevealed cards plus hidden-card aggregate counts

SelfParticipantView contains the viewer's Player Name, every Creature and Power-card snapshot, active Creature identifier, current HP and Power, modifiers, and used Power-card identifiers. OpponentParticipantView contains the opponent Player Name, active and defeated Creature snapshots, remaining Creature count, legally played Power cards and resolved effects, and remaining Power-card count. It contains no array, identifier, position, or placeholder for an unrevealed card.

Creature, move, effect, modifier, legal-action, and public action-result objects reuse the corresponding closed public schema shapes already served by CPU matches. Issue #180 must extract shared OpenAPI components for those shapes and add viewer-relative participant and side enums; it must not fork their battle semantics. PvpActionResultView maps any public actor or target side to self or opponent and contains only the committed public result. All three legal-action variants are closed objects, and legalActions is non-empty only when currentActor is self.

Success response schemas and request schemas use additionalProperties: false so authoritative-looking fields cannot be smuggled into otherwise valid payloads.

Every mutation carries a UUID submissionId. State-changing invitation and match intents also carry expectedVersion. Replaying the same submission with the same authenticated actor, operation, resource, and canonical payload returns the stored outcome. Reusing the ID with a different payload returns PVP_IDEMPOTENCY_CONFLICT.

The complete first-delivery endpoint set is:

Method and pathSuccessResponse schemaIntent
GET /api/v1/matches/pvp/players?playerName={name}200ChallengePlayerResponseExact challenge lookup
POST /api/v1/matches/pvp/invitations201PvpInvitationResponseCreate one invitation
GET /api/v1/matches/pvp/invitations200PvpInvitationListResponseList a bounded page of the viewer's invitations
POST /api/v1/matches/pvp/invitations/:invitationId/accept200AcceptPvpInvitationResponseAccept with a deck and initial Creature
POST /api/v1/matches/pvp/invitations/:invitationId/decline200PvpInvitationResponseDecline a pending invitation
GET /api/v1/matches/pvp200PvpMatchListResponseList a bounded active-match page
GET /api/v1/matches/pvp/history200PvpHistoryResponseList a bounded completed-history page
GET /api/v1/matches/pvp/:matchId200AsyncPvpMatchResponseRead one viewer-safe match
POST /api/v1/matches/pvp/:matchId/actions200AsyncPvpMatchResponseSubmit one legal battle intent

Static /history routing must be registered without allowing it to be captured as a match identifier.

Every success envelope requires requestId and uses additionalProperties: false:

SchemaRequired content
ChallengePlayerResponseplayerName, requestId
PvpInvitationResponseinvitation, requestId
PvpInvitationListResponseinvitations[], nextCursor, requestId
AcceptPvpInvitationResponseterminal accepted invitation, created match, requestId
PvpMatchListResponseviewer-relative active matches[], nextCursor, requestId
AsyncPvpMatchResponsethe complete viewer-relative match fields defined above
PvpHistoryResponsecompleted viewer-relative matches[], nextCursor, requestId

The list item components are also closed and viewer-relative:

ComponentRequired fields
PvpMatchSummarymatchId, mode, status, version, opponent.playerName, currentActor, turnDeadline, updatedAt
PvpHistorySummarymatchId, mode, status, opponent.playerName, outcome, completionReason, startedAt, completedAt

An active list returns only PvpMatchSummary values with mode = "asynchronous" and status = "active". History returns only PvpHistorySummary values with mode = "asynchronous" and status = "completed"; a deleted opponent is represented by the literal safe display value Deleted player, not an internal identifier.

Bounded collection queries

All three collection routes use keyset cursor pagination. Their query objects are closed and accept:

RouteQuery fields
Invitationscursor?, limit?, direction?, status?
Active matchescursor?, limit?
Match historycursor?, limit?

limit is an integer from 1 through 50 and defaults to 20. The server never returns more than the effective limit. direction is incoming or outgoing; status is one invitation status. Omitting either invitation filter includes both directions or all statuses respectively. This lets the Web application request a bounded actionable page such as pending incoming invitations without loading retained terminal invitations.

cursor is an opaque server-issued string with a maximum length of 512 characters. It binds the authenticated viewer, endpoint, filters, effective limit, ordering fields, and final row of the preceding page. A malformed, tampered, cross-viewer, cross-endpoint, or filter-mismatched cursor returns the safe VALIDATION_ERROR; the client never constructs or edits one and repeats the same filters and limit when requesting the next page.

Ordering and cursor comparisons are deterministic:

CollectionOrder
InvitationscreatedAt DESC, then invitationId DESC
Active matchesupdatedAt DESC, then matchId DESC
Match historycompletedAt DESC, then matchId DESC

The opaque cursor advances strictly after that tuple. UUIDs are the stable tie-breakers and are already visible identifiers for the corresponding participant-owned resources. Each list response includes required nextCursor: string | null; null means that query currently has no later page. The implementation may fetch one extra row internally to decide whether to emit a cursor, but it does not return that row in the current page.

Active-match ordering can change while a player traverses pages. Consumers deduplicate by matchId, keep the greatest version, and restart from the first page for the next refresh; a cursor traversal is not a long-lived database snapshot. Background polling refreshes only the visible first page or an open match detail. It must not automatically drain every cursor page on each interval. Explicit navigation or recovery may request later pages one at a time.

A safe first-page envelope is:

{
"matches": [],
"nextCursor": null,
"requestId": "server-generated-request-id"
}

The lookup query requires exactly one playerName and rejects unknown query properties. Invitation and match path parameters require UUID format. List operations accept no browser-supplied player identifier. A first-page request omits cursor; an empty page still returns nextCursor: null.

Invitation request schemas

Create invitation:

{
"opponentPlayerName": "wits_wanderer",
"deckId": "11111111-1111-4111-8111-111111111111",
"initialCreatureId": "22222222-2222-4222-8222-222222222222",
"submissionId": "33333333-3333-4333-8333-333333333333"
}

Required fields are exactly opponentPlayerName, deckId, initialCreatureId, and submissionId. The three identifiers use UUID format; Player Name uses the shared account-policy length and syntax.

Accept invitation:

{
"deckId": "44444444-4444-4444-8444-444444444444",
"initialCreatureId": "55555555-5555-4555-8555-555555555555",
"submissionId": "66666666-6666-4666-8666-666666666666",
"expectedVersion": 0
}

Decline invitation:

{
"submissionId": "77777777-7777-4777-8777-777777777777",
"expectedVersion": 0
}

The accept body requires exactly deckId, initialCreatureId, submissionId, and expectedVersion. The decline body requires exactly submissionId and expectedVersion. Both version values are non-negative integers, and all shown identifiers use UUID format.

PvpInvitationView is a closed object:

FieldTypeRequired
invitationIdUUID stringYes
direction"incoming" | "outgoing"Yes
status"pending" | "accepted" | "declined" | "expired"Yes
versionnon-negative integerYes
opponent.playerNamestringYes
createdAtRFC 3339 timestampYes
expiresAtRFC 3339 timestampYes
respondedAtRFC 3339 timestamp or nullYes
matchIdUUID string or nullYes

respondedAt is null only while pending. matchId is non-null only when the invitation was accepted. Both incoming and outgoing views omit deck and initial selection data. direction and opponent are relative to the authenticated viewer.

Match action schema

The action request is:

{
"submissionId": "88888888-8888-4888-8888-888888888888",
"expectedVersion": 4,
"action": {
"type": "use_move",
"moveId": "99999999-9999-4999-8999-999999999999"
}
}

action is exactly one of:

{ type: "select_replacement", creatureId: UUID }
{ type: "use_move", moveId: UUID }
{ type: "use_power_card", powerCardId: UUID }

Initial selection is captured during invitation creation and acceptance. select_initial_creature and timeout are therefore never accepted at the match-action endpoint. The body cannot contain effects, card statistics, damage, HP, Power, randomness, target results, winner, completion reason, deadline, or progression fields.

The server still validates the requested identifier against the current authoritative legalActions; returning an action earlier does not reserve it or bypass version and deadline checks.

Polling and history

GET /matches/pvp and GET /matches/pvp/:matchId are the recovery authority after refresh, restart, or an ambiguous network response. Local storage, guessed state, and browser simulation are not authoritative.

Polling uses the returned version and updatedAt to recognize a new committed snapshot. The contract does not require a fixed polling interval; the Web implementation must use bounded polling only while the relevant page is visible, stop on unmount, and back off after failures or rate limiting. Collection polling follows the bounded first-page rules above; match-detail polling remains the authority while a battle is open.

An ambiguous mutation response is reconciled by reading the affected invitation or match and then replaying the same submissionId only when necessary. The client must not manufacture a new submission identifier merely because the first response was lost.

History is participant-only. List rows contain the opponent Player Name or Deleted player, viewer-relative outcome, completion reason, and timestamps. Detail may include the legally revealed action history, but never unplayed opponent cards or private persistence snapshots.

Idempotency and concurrency boundary

Persistence issue #179 must reinforce this contract with foreign keys, terminal-state constraints, unique submission receipts, snapshot immutability, and indexes for pending invitations, active participant matches, history, and overdue work.

Invitation creation has no existing resource row to lock. Its transaction must serialize the canonical unordered player pair, enforce the one-pending-pair constraint, and use an actor-and-operation-scoped unique submission receipt. A same-submission or same-pair unique race is resolved with a fresh receipt and pending-invitation lookup so an identical replay returns its committed result and a different request receives the appropriate conflict.

Each mutation of an existing invitation or match follows this order:

  1. begin a transaction and lock the invitation or match through a participant-scoped lookup, returning the safe not-found response for a missing resource or wrong actor;
  2. load a previously committed submission receipt in a fresh statement after acquiring the lock;
  3. return that result for an identical replay or reject a payload conflict;
  4. compare the authoritative server time with the persisted deadline;
  5. finalize overdue state before considering client intent;
  6. validate version, lifecycle, ownership, and legal intent;
  7. apply the shared deterministic transition and any server randomness;
  8. persist state, version, action/history, terminal result, submission receipt, and progression hook atomically; and
  9. commit before returning the response.

The fresh post-lock receipt lookup preserves the existing CPU-match lesson: a statement snapshot created while waiting for another writer may not observe that writer's newly committed receipt through a subquery in the locking statement.

Two requests with one expected version cannot both advance a match. The first commit increments the version; the second returns the stored receipt when it is a replay or PVP_MATCH_VERSION_CONFLICT when it is a different intent.

Deterministic deadline processing

The deadline processor is an at-least-once convergence mechanism, not a source of authority. PostgreSQL timestamps and terminal constraints decide outcomes.

The future processor must:

  • run one sweep after API startup and repeat on a configurable interval;
  • select bounded overdue batches ordered by deadline and stable identifier;
  • lock candidates with FOR UPDATE SKIP LOCKED or an equivalent non-overlapping pattern;
  • recheck status and deadline after locking;
  • finalize each invitation expiry or match forfeit transactionally;
  • write no duplicate history or progression under retry;
  • contain one-item failures without exposing sensitive state in logs;
  • stop cleanly during API shutdown; and
  • catch up all overdue work after restart.

The current single Azure API instance with Always On can host the processor in the API process, following the account-deletion retry scheduler pattern. Database locking and idempotency remain mandatory so correctness survives a recycle or future scale-out. Issue #180 must add safe interval configuration and update the deployment guide when the processor is actually implemented; this contract does not claim that configuration exists today.

Progression boundary

Asynchronous PvP completion creates one stable server-internal progression source per participant, keyed by the authoritative match and player. It records the viewer-independent result, completion reason, and completion timestamp in the same transaction as the terminal match.

The source is processed exactly once even when an action response is replayed, a deadline worker retries, or two workers race. Zero-point outcomes must still be recordable when the approved progression rules require activity evidence.

Merged issue #171 and migration 0026 support exactly two source kinds: challenge_completion and cpu_match. Open issue #172 owns HTTP exposure and transaction wiring for those approved sources only; it is not the owner of an asynchronous PvP rule or schema.

The concrete extension gate is recorded in the progression rules and progression persistence design. The Product Lead must approve the outcome values and activity/achievement effects there. Persistence issue #179 must then add the forward schema, constraints, and one transaction-aware recordAsynchronousPvpCompletion(client, matchId) adapter operation for the asynchronous_pvp source kind, keyed uniquely by match and participant. Issue #180 depends on both merged gates and must call that operation in the terminal match transaction.

Until both gates exist, no implementation may invent a point value, spendable currency, coin result, achievement, or client-provided award. Issue #180 cannot claim its exactly-once progression criterion is complete, even if match completion itself is implemented.

Safe errors

Errors use the existing problem-style envelope with type, title, status, code, safe detail, and requestId. The proposed domain codes are:

StatusCodeUse
400VALIDATION_ERRORMalformed parameter or closed request schema
404PVP_PLAYER_UNAVAILABLEExact target cannot safely be challenged
404PVP_INVITATION_NOT_FOUNDMissing invitation or wrong participant
404PVP_MATCH_NOT_FOUNDMissing match or non-participant access
409PVP_INVITATION_ALREADY_PENDINGOne-pair pending constraint
409PVP_INVITATION_EXPIREDAccept or decline lost to expiry
409PVP_INVITATION_VERSION_CONFLICTStale invitation version
409PVP_MATCH_COMPLETEDNew action against a terminal match
409PVP_MATCH_VERSION_CONFLICTStale match version
409PVP_ACTION_ILLEGALIntent is not currently legal
409PVP_IDEMPOTENCY_CONFLICTSubmission ID reused with another payload
429RATE_LIMITEDExisting authenticated per-process limit

Authentication continues to use the existing 401 boundary. Object-level authorization uses the same 404 as absence so a caller cannot probe another player's invitations or matches. Unknown failures remain generic 500 responses; internal SQL, state, tokens, and stack traces are not returned.

A safe invitation-expired example is:

{
"type": "https://wits.world/problems/pvp_invitation_expired",
"title": "PvP invitation expired",
"status": 409,
"code": "PVP_INVITATION_EXPIRED",
"detail": "The invitation is no longer available.",
"requestId": "server-generated-request-id"
}

Downstream verification matrix

Issues #179 through #181 must turn these scenarios into proportionate unit, route, database, component, and integration tests:

Invitation and identity

  • nonexistent, self, null-name, deletion-pending, and deleted lookup targets;
  • absence of email, Auth0 subject, role, UUID, location, rank, and private history in lookup and invitation responses;
  • same-direction and reverse-direction concurrent invitations;
  • invalid, unowned, stale, or incomplete challenger and recipient decks;
  • challenger snapshot stability after saved-deck or catalogue edits;
  • accept versus decline, accept versus expiry, and duplicate accept replay;
  • identical replay versus submission-ID/payload conflict;
  • account deletion during pending invitations; and
  • invitation filters, limit bounds, invalid/cross-viewer cursors, stable tie-break ordering, and insertion between pages.

Match authority and privacy

  • wrong-player read, action, polling, and history access;
  • hidden initial selections and unrevealed Creature and Power cards;
  • legal reveal of active/defeated Creatures, played Power cards, and effects;
  • rejection of result fields and the timeout action;
  • stale legal actions after another committed transition;
  • two different actions submitted against one version;
  • coherent reads before or after, but never across, a concurrent commit;
  • mandatory replacement followed by a fresh normal-turn deadline;
  • immutable completed state and history;
  • anonymous completed history after account deletion; and
  • active/history page bounds, deduplication after mutable ordering, and first-page refresh without automatic cursor draining.

Deadlines, restart, and progression

  • actions received immediately before, exactly at, and after the deadline;
  • request-driven finalization before the periodic sweep runs;
  • two deadline workers selecting the same due population;
  • processor restart and overdue catch-up;
  • rollback and retry without duplicate terminal history;
  • completion action racing a forfeit sweep;
  • exactly-once per-participant progression sources for defeat and forfeiture;
  • replay of persisted outcomes without duplicate points or achievements; and
  • safe logging when a sweep item fails.

Web behavior

  • bounded visible-page polling, recovery after refresh, and lost-response reconciliation;
  • repeated-click protection using the same submission ID;
  • incoming, outgoing, accepted, declined, expired, active, forfeited, completed, loading, empty, forbidden, and retry states;
  • no rendering or storage of unrevealed opponent cards; and
  • responsive, keyboard, focus, screen-reader, non-color, and deadline text behavior.

Review and delivery gates

Before issue #178 is closed:

  • an API/security reviewer must approve identity exposure, object authorization, idempotency, safe errors, hidden-state projections, and deadline concurrency;
  • a game-rule reviewer must approve initial selection, first-side randomness, mandatory replacement, legal actions, forfeiture, and the separation from live timeout behavior;
  • the OpenAPI-ready shapes and examples must be checked against Fastify's runtime-schema conventions;
  • formatting, linting, type checking, tests, and builds must pass;
  • CI and the documentation build must pass;
  • the pull request must link issue #178; and
  • unresolved social/privacy decisions and downstream progression work must remain visible rather than being presented as implemented protections.

This document was planned, generated, edited, and reviewed with the assistance of Codex[GPT-5].