Arena integrations
Arena provides private, turn-based competitions for partner platforms. Your backend supplies questions and authenticates learners. The hosted service owns timing, scores, persistence and authorization.
The open-source package contains a TypeScript client, accessible player, WebSocket helper, OpenAPI description, tests and localhost demonstration server. See licensing scope.
Availability
The hosted contract requires deployment of the Arena API and migration
20260925_0001_public_arena.sql. Publishing the SDK alone does not deploy the
service. A 404 on /v1/arena/competitions may indicate that the target deployment
does not yet contain this route. Use the local demonstration server to evaluate
the player independently. GitHub publication does not imply npm publication.
This release supports turns-v1: one to six players, supplied text questions,
participant/spectator sessions and durable completion events. It is separate from
MirrorMingo's nationwide class competition. Public matchmaking, the branded 3D
scene, chat, voice, generated questions, translation, curriculum access and general
tutoring are outside this contract.
Ownership and credentials
Organiser operations require Authorization: Bearer mirrormingo_…. Keep this key
on your server. Keys owned by the same developer account share competitions;
different accounts cannot read or change each other's competitions. Use separate
developer accounts for independent tenant ownership. This version has no hosted
test/live environment separation.
Arena access is represented by the developer product arena_api in the shared
MirrorMingo entitlement catalogue. Deployments can enforce it with
MIRRORMINGO_ARENA_REQUIRE_ENTITLEMENT=true; when enabled, a valid key without
an active Arena Developer API entitlement receives 403. Keep this disabled only
for an approved internal migration or pilot. Configure Paystack plan codes with
PAYSTACK_PLAN_ARENA_API_MONTHLY and PAYSTACK_PLAN_ARENA_API_YEARLY, then use
the normal developer subscription flow. This is separate from learner and school
subscriptions.
Your backend must authenticate and authorize a learner before issuing a session.
The browser receives only an expiring arena_… token scoped to one competition
and role. Keep it in memory, never URLs, logs or persistent browser storage.
Spectator sessions cannot answer and never create participant seats.
External learner IDs are hashed with the developer account and competition. Display names are visible to other participants and authorized spectators; use nicknames where appropriate. A new session for the same learner restores the same seat and issues a new token. It does not revoke previous tokens. Default expiry is one hour; allowed TTL is 60–86400 seconds. Organisers can revoke all sessions immediately. Expiry and revocation also terminate active sockets.
Server integration
import { MirrorMingo } from "@mirrormingo/sdk";
const mm = new MirrorMingo({ apiKey: process.env.MIRRORMINGO_API_KEY });
const match = await mm.arena.create({
request_id: "class-maths-2026-09-25",
title: "Maths challenge", max_players: 2, seconds_per_question: 30,
questions: [
{ id: "q1", prompt: "2 + 2 = ?", options: ["4", "5"], correct_index: 0 },
{ id: "q2", prompt: "3 + 3 = ?", options: ["5", "6"], correct_index: 1 },
],
});
const ada = await mm.arena.createSession(match.id, {
learner_id: "student-101", display_name: "Ada",
});
const musa = await mm.arena.createSession(match.id, {
learner_id: "student-102", display_name: "Musa",
});
const audience = await mm.arena.createSession(match.id, { role: "spectator" });
// Deliver each token only to its intended authenticated recipient.
await mm.arena.start(match.id);
Provide questions you have permission to use: unique IDs, two to six distinct nonempty options and a zero-based correct index. Up to 120 questions are accepted. Partner questions are not automatically verified or endorsed. Content is immutable after creation; create another competition to change it.
Embed a player
Build the standalone @mirrormingo/arena package from the public repository, or
import mountArena from the updated main SDK. Registry publication is separate.
import { mountArena } from "@mirrormingo/arena";
const session = await fetch("/api/my-arena-session", { method: "POST" }).then(r => r.json());
const player = mountArena(document.getElementById("arena")!, {
session,
onComplete: state => console.log(state.phase, state.players),
onError: error => console.error(error),
});
// On component/page teardown: player.destroy();
The player uses semantic HTML, keyboard-operable buttons, text-only content
rendering and no React/Three.js dependency. Style .mm-arena in your application.
For React, mount inside an effect and return player.destroy as its cleanup.
Polling runs every second, backs off after failures and stops on terminal state
or invalid credentials. Obtain a fresh session from your backend after expiry.
Reconnection does not pause or extend a turn. Live competitive play is online-only.
Rules and lifecycle
waiting → running → reveal → running → … → finished, or cancelled.
- The organiser starts explicitly. Optional
opens_atandcloses_atare timezone-aware ISO 8601 timestamps enforced on the server. - Registration closes at start. Questions must divide equally among the actual registered players, ensuring equal turn counts. Seat order is stable and turns alternate round-robin. Partners must balance difficulty across seats themselves.
- Turn windows are 10–300 seconds. Correct answers score 100; wrong answers and timeouts score zero. Each result has a three-second reveal.
- Ranking uses score, then total elapsed answer time, then stable participant ID. Timeouts consume the full window. Players who never answer have no final rank.
- Snapshots withhold future prompts and all unrevealed answers. Only the current question is shown; its answer/explanation appears after submission or timeout.
- Deadlines survive disconnects/restarts. PostgreSQL serializes transitions and a lifespan clock worker advances inactive matches. Redis/sticky sessions are unnecessary for this public format.
- Starting requires enough remaining time for all full windows and reveals. Cancellation does not produce a completed ranked result.
HTTP reference
Paths are relative to https://api.mirrormingo.com/v1.
| Method | Path | Credential | SDK |
|---|---|---|---|
| POST | /arena/competitions | Developer key | arena.create(input) |
| GET | /arena/competitions/{id} | Developer key | arena.get(id) |
| POST | /arena/competitions/{id}/sessions | Developer key | arena.createSession(id, input) |
| POST | /arena/competitions/{id}/start | Developer key | arena.start(id) |
| POST | /arena/competitions/{id}/cancel | Developer key | arena.cancel(id) |
| POST | /arena/competitions/{id}/revoke-sessions | Developer key | arena.revokeSessions(id) |
| GET | /arena/play/{id} | Arena session | arena.state(id) |
| POST | /arena/play/{id}/answers | Participant session | arena.answer(id, input) |
| GET | /arena/events?after=0&limit=50 | Developer key | arena.events(after, limit) |
The API reference defines all schemas. Timestamps/deadlines in
snapshots are Unix seconds. Use server_time to compensate for device clock skew.
version increases only when persisted state changes.
WebSocket protocol
Connect to /v1/arena/play/{id}/ws over WSS and send within ten seconds:
{"type":"hello","token":"arena_SESSION_TOKEN"}
Receive {"type":"state","state":{...}} on version changes, otherwise
{"type":"heartbeat","server_time":1234567890}. Reconnection returns the current
snapshot, not missed intermediate messages. Submit answers over HTTP for explicit
acknowledgements. Invalid/expired/revoked credentials close with 4401; terminal
states close normally with 1000.
import { connectArena } from "@mirrormingo/arena";
const disconnect = connectArena({
getSession: () => fetch("/api/my-arena-session", { method: "POST" }).then(r => r.json()),
onState: state => renderYourUI(state),
onError: error => showError(error),
});
The helper reconnects with backoff after transport failures, requests credentials again, detects missing heartbeats, and stops on authorization failure. It never sends a token in a URL. Call the returned function to disconnect.
Retries and trusted results
Creation is idempotent by (developer account, request_id); answers by
(competition, participant, request_id). Reusing an ID with changed input returns
409. An answer retry returns current state without awarding duplicate points.
Persist IDs across retries. The SDK never automatically retries mutations.
Session creation deliberately returns a fresh token.
Errors: 401 invalid credential; 403 spectator cannot answer; 404 missing/unowned
competition; 409 stale question, wrong turn, full roster or state conflict;
422 invalid input; 429 rate limit. Refresh state before retrying a 409. Respect
Retry-After. Existing HTTP rate limiting applies when enabled by the deployment;
it is not a billing quota. Configure production request/connection limits at the
gateway. A live socket sends at most one snapshot/heartbeat per second.
Finished/cancelled matches produce durable arena.finished/arena.cancelled
events. Poll /arena/events, process event_id idempotently, then persist the
returned cursor. Continue while has_more is true. Events contain the final
snapshot and are isolated by developer account. Reconcile with arena.get(id).
The existing exam webhook dispatcher does not deliver Arena events in this
version. Browser completion callbacks are for UI; consume server events for grades.
Deployment and tests
Apply the migration before deploying. The application mounts /v1/arena and
starts the clock maintenance task in its lifespan. Tests cover PostgreSQL
ownership, concurrency, replay, expiry and completion, plus SDK transport and
player behaviour. Validate your deployed gateway, authentication, WebSocket proxy
and actual devices separately. No production acceptance is implied by local tests.