openapi: 3.1.0
info:
  title: ZenXGames Hosted Game API
  version: 0.1.0
  description: >-
    Publisher-neutral API for embedding versioned ZenXGames HTML5 titles in any
    website or application. Publisher API keys are server-only. A direct game
    URL without a signed launch contract retains the game's complete standalone
    behavior.
  contact:
    name: ZenXGames developer support
    email: support@zenxgames.com
servers:
  - url: https://games.zenxgames.com
    description: Production
tags:
  - name: Catalog
  - name: Matches
  - name: Launch
  - name: Player data
  - name: Matchmaking
paths:
  /health:
    get:
      operationId: health
      summary: Check platform health
      responses:
        "200":
          description: Platform is healthy
  /v1/games:
    get:
      operationId: listGames
      tags: [Catalog]
      summary: List versioned games and capabilities
      description: >-
        Read this catalog before offering an embedded profile. Seat counts, bot
        support, private play, orientation, and allowed mode IDs are properties
        of a specific game version and must not be inferred by a host.
      responses:
        "200":
          description: Active game catalog
          content:
            application/json:
              schema:
                type: object
                required: [games]
                properties:
                  games:
                    type: array
                    items: { $ref: "#/components/schemas/GameManifest" }
  /v1/version:
    get:
      operationId: version
      tags: [Catalog]
      summary: Resolve pinned platform and game versions
      parameters:
        - name: gameId
          in: query
          required: false
          schema: { type: string, default: pool-master }
      responses:
        "200":
          description: Version tuple
        "404": { $ref: "#/components/responses/Error" }
  /v1/matches:
    post:
      operationId: createMatch
      tags: [Matches]
      summary: Create a signed hosted-game session
      description: >-
        Creates a practice, private, or matchmaking session for a verified
        roster and returns one short-lived launch per human player. Deliver only
        the launch whose playerId belongs to the authenticated caller.
      security: [{ ApiKey: [] }]
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateMatchRequest" }
            examples:
              practice:
                summary: One human; the manifest selects solo or a computer challenger
                value:
                  gameId: pool-master
                  experience: practice
                  seatCount: 1
                  players:
                    - { id: usr_7b81, name: Maya }
              privateDuel:
                summary: Two verified humans; start the approved mode directly
                value:
                  gameId: pool-master
                  experience: private
                  externalRoomId: session-8841
                  seatCount: 2
                  players:
                    - { id: usr_7b81, name: Maya }
                    - { id: usr_f192, name: Noah }
                  allowBots: false
                  gameConfig:
                    modeMenu:
                      source: api
                      playerCount: 2
                      lockPlayerCount: true
                      offeredModes:
                        - {
                            id: versus_2p,
                            label: 2 Players,
                            players: 2,
                            default: true,
                          }
                      blockedModes:
                        [single, bot, online, three-player, four-player]
                      autoSelectWhenSingle: true
                      returnLabel: Done
                  callbacks:
                    matchResultUrl: https://api.publisher.example/zenx/results
      responses:
        "201":
          description: Match and per-human launch URLs
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreateMatchResponse" }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
  /v1/matches/{matchId}:
    get:
      operationId: getMatch
      tags: [Matches]
      summary: Read an authorized match
      security: [{ ApiKey: [] }]
      parameters:
        - name: matchId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Match record
          content:
            application/json:
              schema:
                type: object
                properties:
                  match: { $ref: "#/components/schemas/MatchRecord" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/matches/{matchId}/terminate:
    post:
      operationId: terminateMatch
      tags: [Matches]
      summary: End a match from the publisher backend
      description: >-
        Idempotently records a no-contest result, broadcasts match_over, queues
        the signed result callback, and releases the room.
      security: [{ ApiKey: [] }]
      parameters:
        - name: matchId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  pattern: "^[A-Za-z0-9_-]{1,64}$"
                  default: terminated
      responses:
        "200": { description: Match terminated or already ended }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
  /v1/launch/resolve:
    get:
      operationId: resolveLaunch
      tags: [Launch]
      summary: Resolve a trusted per-player launch
      security: [{ LaunchToken: [] }]
      responses:
        "200":
          description: Trusted player, seats, configuration, and versions
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LaunchResolution" }
        "401": { $ref: "#/components/responses/Error" }
  /v1/realtime:
    get:
      operationId: realtime
      tags: [Launch]
      summary: Join the authoritative room WebSocket
      description: >-
        Upgrade to WebSocket. A token query parameter is accepted for WebView
        clients that cannot attach an Authorization header.
      security: [{ LaunchToken: [] }]
      responses:
        "101": { description: WebSocket upgraded }
        "401": { $ref: "#/components/responses/Error" }
        "426": { $ref: "#/components/responses/Error" }
  /v1/progress:
    get:
      operationId: getProgress
      tags: [Player data]
      summary: Read cross-device player progress
      security: [{ LaunchToken: [] }]
      parameters:
        - name: slot
          in: query
          required: false
          schema: { type: string, default: default, maxLength: 64 }
      responses:
        "200": { description: Saved progress or null }
        "401": { $ref: "#/components/responses/Error" }
    put:
      operationId: saveProgress
      tags: [Player data]
      summary: Save player progress
      security: [{ LaunchToken: [] }]
      parameters:
        - name: slot
          in: query
          required: false
          schema: { type: string, default: default, maxLength: 64 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: Updated progress version }
        "401": { $ref: "#/components/responses/Error" }
  /v1/scores:
    post:
      operationId: submitScore
      tags: [Player data]
      summary: Submit an idempotent score
      security: [{ LaunchToken: [] }]
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [score]
              properties:
                score: { type: number }
                state: { type: string, default: over }
                leaderboardId: { type: string, default: default }
                durationMs: { type: integer, minimum: 0 }
      responses:
        "201": { description: Score stored }
        "200": { description: Duplicate score accepted idempotently }
        "401": { $ref: "#/components/responses/Error" }
  /v1/events:
    post:
      operationId: emitGameEvent
      tags: [Player data]
      summary: Emit a game lifecycle event
      security: [{ LaunchToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PlatformGameEvent" }
      responses:
        "202": { description: Event accepted }
        "401": { $ref: "#/components/responses/Error" }
  /v1/matchmaking/tickets:
    post:
      operationId: createMatchmakingTicket
      tags: [Matchmaking]
      summary: Create or resolve a matchmaking ticket
      security: [{ ApiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateMatchRequest" }
      responses:
        "200": { description: Waiting or immediately matched ticket }
        "400": { $ref: "#/components/responses/Error" }
        "401": { $ref: "#/components/responses/Error" }
  /v1/matchmaking/tickets/{ticketId}:
    get:
      operationId: getMatchmakingTicket
      tags: [Matchmaking]
      summary: Poll a matchmaking ticket
      security: [{ ApiKey: [] }]
      parameters:
        - { name: ticketId, in: path, required: true, schema: { type: string } }
        - { name: gameId, in: query, required: true, schema: { type: string } }
        - {
            name: playerId,
            in: query,
            required: true,
            schema: { type: string },
          }
        - {
            name: mode,
            in: query,
            required: false,
            schema: { type: string, default: default },
          }
        - {
            name: region,
            in: query,
            required: false,
            schema: { type: string, default: global },
          }
      responses:
        "200": { description: Ticket state and launch when matched }
        "401": { $ref: "#/components/responses/Error" }
components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: Publisher-scoped zenx_srv_* credential used only by a trusted backend.
    LaunchToken:
      type: http
      scheme: bearer
      bearerFormat: ZenX-HMAC
      description: Short-lived player launch credential.
  responses:
    Error:
      description: Structured API error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, requestId]
          properties:
            code: { type: string }
            message: { type: string }
            requestId: { type: string }
    Player:
      type: object
      required: [id, name]
      properties:
        id:
          {
            type: string,
            maxLength: 128,
            description: Stable opaque non-PII publisher identifier.,
          }
        name: { type: string, minLength: 1, maxLength: 40 }
        avatar: { type: string, maxLength: 2048 }
        metadata:
          type: object
          additionalProperties:
            oneOf: [{ type: string }, { type: number }, { type: boolean }]
    MatchRequestPlayer:
      allOf:
        - { $ref: "#/components/schemas/Player" }
        - type: object
          properties:
            hostAppId:
              type: string
              maxLength: 128
              description: Registered native-app override; otherwise CreateMatchRequest.hostApp is used.
    MatchPlayer:
      allOf:
        - { $ref: "#/components/schemas/Player" }
        - type: object
          required: [seat, sessionId, isBot, connected]
          properties:
            seat: { type: integer, minimum: 0 }
            sessionId: { type: string }
            isBot: { type: boolean }
            connected: { type: boolean }
    HostAppReference:
      type: object
      additionalProperties: false
      required: [publisherId, appId, platform, bundleId]
      description: Optional registered native application receiving this launch.
      properties:
        publisherId: { type: string, maxLength: 128 }
        appId: { type: string, maxLength: 128 }
        platform: { enum: [ANDROID, IOS] }
        bundleId: { type: string, maxLength: 255 }
    OfferedMode:
      type: object
      additionalProperties: false
      required: [id, label, players]
      properties:
        id: { type: string, pattern: "^[A-Za-z0-9._:-]+$" }
        label: { type: string, minLength: 1, maxLength: 64 }
        players: { type: integer, minimum: 1, maximum: 64 }
        default: { type: boolean }
        aliases:
          type: array
          uniqueItems: true
          items: { type: string, pattern: "^[A-Za-z0-9._:-]+$" }
    ModeMenu:
      type: object
      additionalProperties: false
      description: >-
        Signed host-owned mode restriction. Omit to preserve the game's complete
        standalone menus and navigation. Invalid or contradictory fields fail
        match creation instead of silently widening access.
      properties:
        source: { type: string, const: api }
        playerCount: { type: integer, minimum: 1, maximum: 64 }
        lockPlayerCount: { type: boolean }
        offeredModes:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/OfferedMode" }
        blockedModes:
          type: array
          uniqueItems: true
          items: { type: string, pattern: "^[A-Za-z0-9._:-]+$" }
        autoSelectWhenSingle: { type: boolean }
        returnLabel: { type: string, minLength: 1, maxLength: 64 }
    GameConfiguration:
      type: object
      description: >-
        Signed configuration. modeMenu is generic; other keys are validated by
        the selected versioned game adapter.
      properties:
        modeMenu: { $ref: "#/components/schemas/ModeMenu" }
      additionalProperties: true
    CallbackConfiguration:
      type: object
      additionalProperties: false
      properties:
        matchCreatedUrl: { type: string, format: uri, pattern: "^https://" }
        matchResultUrl: { type: string, format: uri, pattern: "^https://" }
        scoreUrl: { type: string, format: uri, pattern: "^https://" }
    CreateMatchRequest:
      type: object
      required: [gameId, players]
      properties:
        gameId: { type: string, description: ID returned by GET /v1/games. }
        hostApp: { $ref: "#/components/schemas/HostAppReference" }
        experience:
          enum: [practice, private, matchmaking]
          description: Stable publisher intent. Prefer this over the low-level mode field.
        seatCount:
          {
            type: integer,
            minimum: 1,
            maximum: 64,
            description: Bounded by the selected game manifest.,
          }
        externalRoomId:
          {
            type: string,
            maxLength: 128,
            description: Opaque correlation ID,
            never an authentication value.,
          }
        players:
          type: array
          minItems: 1
          maxItems: 64
          items: { $ref: "#/components/schemas/MatchRequestPlayer" }
        mode:
          {
            enum: [single, private, matchmaking, bot],
            description: Backward-compatible low-level mode.,
          }
        minPlayers: { type: integer, minimum: 1, maximum: 64 }
        maxPlayers: { type: integer, minimum: 1, maximum: 64 }
        rounds: { type: integer, minimum: 1, maximum: 5 }
        maxWaitSeconds: { type: integer, minimum: 5, maximum: 600 }
        turnSeconds: { type: integer, minimum: 5, maximum: 180 }
        allowBots: { type: boolean }
        botPolicy: { enum: [disabled, fallback, fill] }
        gameConfig: { $ref: "#/components/schemas/GameConfiguration" }
        callbacks: { $ref: "#/components/schemas/CallbackConfiguration" }
    MatchConfiguration:
      type: object
      required:
        [
          experience,
          mode,
          minPlayers,
          maxPlayers,
          rounds,
          maxWaitSeconds,
          turnSeconds,
          allowBots,
          botPolicy,
          gameConfig,
        ]
      properties:
        experience: { enum: [practice, private, matchmaking] }
        mode: { enum: [single, private, matchmaking, bot] }
        minPlayers: { type: integer, minimum: 1, maximum: 64 }
        maxPlayers: { type: integer, minimum: 1, maximum: 64 }
        rounds: { type: integer, minimum: 1, maximum: 5 }
        maxWaitSeconds: { type: integer, minimum: 5, maximum: 600 }
        turnSeconds: { type: integer, minimum: 5, maximum: 180 }
        allowBots: { type: boolean }
        botPolicy: { enum: [disabled, fallback, fill] }
        gameConfig: { $ref: "#/components/schemas/GameConfiguration" }
    MatchRecord:
      type: object
      required:
        [
          matchId,
          roomId,
          gameId,
          gameVersion,
          protocolVersion,
          rulesVersion,
          status,
          configuration,
          players,
          createdAt,
        ]
      properties:
        matchId: { type: string }
        roomId: { type: string }
        externalRoomId: { type: string }
        gameId: { type: string }
        gameVersion: { type: string }
        protocolVersion: { type: string }
        rulesVersion: { type: string }
        status: { enum: [waiting, active, completed, cancelled] }
        configuration: { $ref: "#/components/schemas/MatchConfiguration" }
        players:
          type: array
          items: { $ref: "#/components/schemas/MatchPlayer" }
        callbacks: { $ref: "#/components/schemas/CallbackConfiguration" }
        createdAt: { type: integer, format: int64 }
        startedAt: { type: integer, format: int64 }
        completedAt: { type: integer, format: int64 }
    PlayerLaunch:
      type: object
      required: [playerId, token, url]
      properties:
        playerId: { type: string }
        token:
          {
            type: string,
            description: Short-lived bearer credential; never log,
            share,
            or prefetch.,
          }
        url: { type: string, format: uri }
        launchConfig:
          {
            type: object,
            description: Optional registered-host runtime configuration.,
          }
    CreateMatchResponse:
      type: object
      required: [match, launches]
      properties:
        match: { $ref: "#/components/schemas/MatchRecord" }
        launches:
          type: array
          items: { $ref: "#/components/schemas/PlayerLaunch" }
    LaunchClaims:
      type: object
      required:
        [
          issuer,
          audience,
          issuedAt,
          expiresAt,
          matchId,
          roomId,
          gameId,
          gameVersion,
          protocolVersion,
          rulesVersion,
          playerId,
          sessionId,
        ]
      properties:
        issuer: { type: string, const: zenxgames }
        audience: { type: string, const: game-launch }
        issuedAt: { type: integer, format: int64 }
        expiresAt: { type: integer, format: int64 }
        matchId: { type: string }
        roomId: { type: string }
        gameId: { type: string }
        gameVersion: { type: string }
        protocolVersion: { type: string }
        rulesVersion: { type: string }
        playerId: { type: string }
        sessionId: { type: string }
        hostAppId: { type: string }
    LaunchResolution:
      type: object
      required: [claims, player, players, configuration, launchUrl]
      properties:
        claims: { $ref: "#/components/schemas/LaunchClaims" }
        player: { $ref: "#/components/schemas/MatchPlayer" }
        players:
          type: array
          items: { $ref: "#/components/schemas/MatchPlayer" }
        configuration: { $ref: "#/components/schemas/MatchConfiguration" }
        launchUrl: { type: string, format: uri }
        launchConfig: { type: object }
    PlatformGameEvent:
      type: object
      required: [event, gameCode, timestamp]
      properties:
        event:
          enum:
            [
              game_loaded,
              match_found,
              match_not_found,
              match_start,
              match_playing,
              round_over,
              match_over,
              match_result,
              score_submitted,
              progress_saved,
              go_home,
              error,
            ]
        gameCode: { type: string }
        matchId: { type: string }
        roomId: { type: string }
        sequence: { type: integer, minimum: 0 }
        timestamp: { type: integer, format: int64 }
        data: { type: object }
    EmbeddedPlayCapability:
      type: object
      required: [enabled, style, requiredPlayers, modes, supportsCrossPlatform]
      properties:
        enabled: { type: boolean }
        style: { enum: [versus, race] }
        requiredPlayers: { type: integer, minimum: 1, maximum: 64 }
        modes:
          type: array
          items: { $ref: "#/components/schemas/OfferedMode" }
        supportsCrossPlatform: { type: boolean }
    GroupPlayCapability:
      type: object
      description: Exact-roster group behavior. parallel-practice creates one signed practice match per participant and ranks signed score callbacks; the other styles use one shared room.
      required: [enabled, style, minPlayers, maxPlayers, scoreOrder, modes, supportsCrossPlatform]
      properties:
        enabled: { type: boolean }
        style: { enum: [shared-board, same-challenge-race, parallel-practice] }
        minPlayers: { type: integer, minimum: 2, maximum: 64 }
        maxPlayers: { type: integer, minimum: 2, maximum: 64 }
        scoreOrder: { enum: [highest, lowest, first-finish] }
        modes:
          type: array
          items: { $ref: "#/components/schemas/OfferedMode" }
        supportsCrossPlatform: { type: boolean }
    GameManifest:
      type: object
      required:
        [
          id,
          name,
          status,
          version,
          protocolVersion,
          rulesVersion,
          launchPath,
          minPlayers,
          maxPlayers,
          supportsBots,
          supportsSinglePlayer,
          practiceStyle,
          practicePlayers,
          orientation,
          runtime,
          embeddedPlay,
          groupPlay,
          hostedExperiences,
          callPlayEnabled,
          callPlayMode,
          requiredPlayers,
          callPlayModes,
          supportsCrossPlatform,
          supportedExperiences,
        ]
      properties:
        id: { type: string }
        name: { type: string }
        status: { enum: [active, planned, disabled] }
        version: { type: string }
        protocolVersion: { type: string }
        rulesVersion: { type: string }
        launchPath: { type: string }
        artworkPath: { type: string }
        artworkRevision: { type: string }
        minPlayers: { type: integer, minimum: 1 }
        maxPlayers: { type: integer, minimum: 1, maximum: 64 }
        supportsBots: { type: boolean }
        supportsSinglePlayer: { type: boolean }
        practiceStyle: { enum: [solo, challenger] }
        practicePlayers: { type: integer, minimum: 1 }
        orientation: { enum: [portrait, landscape, any] }
        runtime: { enum: [client, room, room-wasm, dedicated] }
        embeddedPlay: { $ref: "#/components/schemas/EmbeddedPlayCapability" }
        groupPlay: { $ref: "#/components/schemas/GroupPlayCapability" }
        hostedExperiences:
          type: array
          items: { enum: [practice, private, matchmaking] }
        callPlayEnabled:
          {
            type: boolean,
            deprecated: true,
            description: Compatibility alias for embeddedPlay.enabled.,
          }
        callPlayMode: { enum: [versus, race], deprecated: true }
        requiredPlayers: { type: integer, minimum: 1, deprecated: true }
        callPlayModes:
          type: array
          deprecated: true
          items: { $ref: "#/components/schemas/OfferedMode" }
        supportsCrossPlatform: { type: boolean }
        supportedExperiences:
          type: array
          deprecated: true
          items: { enum: [solo, private_call] }
    DuelSettlementRequestPreview:
      type: object
      x-zenx-status: preview-not-accepted-by-api-0.1
      description: >-
        Reserved shape for a future two-player settlement extension. The winner,
        publisher host, and ZenXGames platform shares come from the immutable
        server-owned profile and sum exactly to the loser's debit. Do not send
        this object to the current API.
      required: [profileId, stake]
      properties:
        profileId: { type: string }
        stake:
          type: object
          required: [currency, amount]
          properties:
            currency: { type: string }
            amount: { type: integer, minimum: 1 }
