Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/DerBasilisk/SEA-ServicioEvaluaconAsistida/llms.txt

Use this file to discover all available pages before exploring further.

Duels are Sealearn’s real-time PvP mode. Two users compete head-to-head on questions from a shared lesson, each answering independently and simultaneously. The system is built entirely on Socket.IO — there are no REST endpoints for gameplay. A short-lived invite flow in Redis coordinates the match setup before the duel state is committed to MongoDB, keeping latency low during the critical start window. When the duel ends a result message is automatically posted to the shared chat conversation, giving both players a permanent record of the match.
The duel socket runs on the default Socket.IO namespace (/) — not /chat. Connect to the root path. The /chat namespace is separate and used only for messaging.

The Duel Model

Duel documents are persisted in MongoDB once an invite is accepted. During active gameplay the live state (player progress, scores, modifiers) is held in Redis for low-latency reads and synced to MongoDB at the end of the match.
FieldTypeDescription
_idObjectIdMongoDB document ID.
duelIdstringUUID generated at invite-accept time; used as the Redis key and the room name duel:<duelId>.
lessonObjectId → LessonThe lesson whose questions were used.
conversationObjectId → Conversation | nullThe chat conversation the duel was initiated from, if any. The result is posted here when the duel ends.
type"direct" | "group"Always "direct" for 1v1 duels.
creatorObjectId → UserUser who sent the original invite.
playersarrayEmbedded player subdocuments (see below).
winnerObjectId → User | nullSet when the duel finishes; null while in progress.
questionsObjectId[] → QuestionQuestions used in this duel (for audit / replay).
status"waiting" | "active" | "finished" | "abandoned"Current duel state.
startedAtDate | nullWhen the duel transitioned to "active".
endedAtDate | nullWhen the duel transitioned to "finished" or "abandoned".
maxPlayersnumberMaximum participants; defaults to 2 for direct duels.
inviteCodestring | nullRandom alphanumeric code for group duels.
questionCountnumberNumber of questions selected for this duel (default 5).
Player subdocument fields:
FieldTypeDescription
userObjectId → UserThe participant.
scorenumberTotal XP-equivalent points scored.
correctnumberNumber of correctly answered questions.
timeSpentnumber | nullTotal milliseconds from start to last answer.
finishedAtDate | nullWhen this player answered their last question.
abandonedbooleantrue if the player left before the duel ended.

Modifiers

Modifiers are power-ups a player can activate during a duel to hinder their opponent. Each modifier may be used once per duel. They are passed as IDs in the duel:use_modifier event.
IDLabelIconEffect
extra_questionsPreguntas extraAdds 3 extra questions to the opponent’s queue.
reduced_timeTiempo reducido⏱️Limits the opponent to 10 seconds per question.
blackoutPantalla oscura🌑Triggers a 3-second black screen for the opponent.

Authentication

Every socket must present a JWT in socket.handshake.auth.token. The auth middleware decodes the token synchronously and attaches socket.userId. Connections without a valid token are rejected with "Token inválido".
import { io } from "socket.io-client";

const duelSocket = io("https://api.sealearn.app", {
  auth: { token: "<jwt>" },
  path: "/socket.io",
});
On connection each socket automatically joins the personal room user:<userId>. Invite and start events are delivered to this room so they are received regardless of what UI screen the user is on.

Client → Server Events

duel:invite

Challenge a friend to a duel on a specific lesson. The server stores the invite in Redis (TTL: 60 seconds) and forwards it to the recipient. If the recipient is offline, the event is silently lost — the client should inform the user that their friend may be unavailable.
FieldTypeDescription
friendIdstringMongoDB _id of the friend to challenge.
lessonIdstringMongoDB _id of the lesson to duel on.
conversationIdstringThe chat conversation ID where the duel result will be posted on completion.
duelSocket.emit("duel:invite", {
  friendId: "664a1f2e8b3c2a001e4d8e10",
  lessonId: "665e3a4b5c6d7e008f9a0b11",
  conversationId: "665c1a2b3d4e5f006a7b8c90",
});

duel:accept

Accept a pending invite. The server fetches the invite from Redis, builds the duel state (selecting questions via the adaptive service), persists a Duel document in MongoDB, and emits duel:start to both players.
FieldTypeDescription
inviteIdstringUUID received in the duel:invited event.
duelSocket.emit("duel:accept", { inviteId: "a3b2c1d0-e5f6-7890-abcd-ef1234567890" });

duel:reject

Decline an incoming invite. The invite is removed from Redis and the requester receives duel:rejected.
FieldTypeDescription
inviteIdstringUUID received in the duel:invited event.
duelSocket.emit("duel:reject", { inviteId: "a3b2c1d0-e5f6-7890-abcd-ef1234567890" });

duel:join

Rejoin the duel room after a page reload or reconnection. The server responds with duel:state containing the full current duel snapshot from Redis.
FieldTypeDescription
duelIdstringUUID of the duel to rejoin.
duelSocket.emit("duel:join", { duelId: "b4c3d2e1-f7a8-9012-bcde-f23456789012" });

duel:answer

Submit an answer to a question. The server evaluates correctness, updates the player’s score in Redis, emits progress to the opponent via duel:opponent_progress, and returns the result to the answering player via duel:answer_result. If both players have finished all their questions, endDuel is triggered automatically.
FieldTypeDescription
duelIdstringUUID of the active duel.
questionIdstringMongoDB _id of the question being answered.
answerstring | boolean | string[]The answer value. Format depends on question type: option _id for multiple_choice, true/false for true_false, a string for fill_blank, and an ordered array of item IDs for order_items.
// Multiple choice — pass the selected option _id
duelSocket.emit("duel:answer", {
  duelId: "b4c3d2e1-f7a8-9012-bcde-f23456789012",
  questionId: "665f4a5b6c7d8e009f0a1b22",
  answer: "665f4a5b6c7d8e009f0a1b23",
});

duel:use_modifier

Activate a modifier to apply a disadvantage to the opponent.
FieldTypeDescription
duelIdstringUUID of the active duel.
modifierId"extra_questions" | "reduced_time" | "blackout"The modifier to activate.
targetIdstringThe opponent’s user ID.
duelSocket.emit("duel:use_modifier", {
  duelId: "b4c3d2e1-f7a8-9012-bcde-f23456789012",
  modifierId: "blackout",
  targetId: "664a1f2e8b3c2a001e4d8e10",
});

duel:abandon

Forfeit the duel. The duel is marked "abandoned" in both Redis and MongoDB, and the opponent receives duel:opponent_abandoned.
FieldTypeDescription
duelIdstringUUID of the duel to abandon.
duelSocket.emit("duel:abandon", { duelId: "b4c3d2e1-f7a8-9012-bcde-f23456789012" });

Server → Client Events

duel:invite_sent

Confirmation sent back to the requester after duel:invite is processed and forwarded.
FieldTypeDescription
inviteIdstringUUID of the newly created invite. Use this to cancel the invite if the user changes their mind.
duelSocket.on("duel:invite_sent", ({ inviteId }) => {
  showPendingInviteUI(inviteId);
});

duel:accepted

Sent back to the socket that emitted duel:accept, confirming that the accept was processed successfully and the duel has been created. The duel:start event follows immediately on the same socket.
FieldTypeDescription
duelIdstringUUID of the newly created duel.
duelSocket.on("duel:accepted", ({ duelId }) => {
  console.log("Duel created:", duelId);
});

duel:state

Sent in response to duel:join. Contains the full current duel snapshot from Redis, allowing a reconnecting client to restore its UI state without missing progress.
duelSocket.on("duel:state", (duel) => {
  restoreDuelUI(duel);
});

duel:invited

Delivered to the recipient’s user:<userId> room when they receive a duel challenge.
FieldTypeDescription
inviteIdstringUUID to pass back to duel:accept or duel:reject.
lessonIdstringLesson the challenger wants to duel on.
requesterIdstringUser ID of the challenger.
conversationIdstringChat conversation the invite originated from.
duelSocket.on("duel:invited", ({ inviteId, lessonId, requesterId, conversationId }) => {
  showIncomingChallengeModal({ inviteId, lessonId, requesterId });
});

duel:start

Emitted to both players when the duel is ready to begin. Contains all questions and opponent information. The client should transition to the duel gameplay screen immediately upon receiving this event.
If the requester is offline when the invite is accepted, their duel:start payload is stored in Redis for up to 120 seconds. It is delivered to their personal socket room the next time they connect.
FieldTypeDescription
duelIdstringUUID of the duel — required for all subsequent gameplay events.
questionsarraySanitised question objects (correct answers stripped). Question format varies by type — see below.
opponentIdstringUser ID of the opponent.
Question formats by type:
  • multiple_choiceoptions array with { _id, text } objects in randomised order; correct answer removed.
  • true_falsecorrectBoolean field removed; client receives only the statement.
  • fill_blankcorrectAnswers removed; client receives the prompt text.
  • order_itemsitems replaced by shuffledItems (same items in random order).
  • match_pairspairs replaced by leftItems and rightItems arrays, each shuffled independently.
duelSocket.on("duel:start", ({ duelId, questions, opponentId }) => {
  startDuelCountdown({ duelId, questions, opponentId });
});

duel:answer_result

Returned to the answering player immediately after duel:answer, confirming whether they were correct.
FieldTypeDescription
isCorrectbooleanWhether the submitted answer was correct.
explanationstring | nullOptional explanation from the question document.
correctAnswerstring | boolean | nullThe correct answer value, revealed after submission. null for order_items.
duelSocket.on("duel:answer_result", ({ isCorrect, explanation, correctAnswer }) => {
  renderAnswerFeedback({ isCorrect, explanation, correctAnswer });
});

duel:opponent_progress

Broadcast to the opponent’s socket (in the duel:<duelId> room) after every answer, so the client can animate the opponent’s progress bar in real time.
FieldTypeDescription
userIdstringThe opponent’s user ID.
currentIndexnumberNumber of questions the opponent has answered so far.
scorenumberOpponent’s current score.
correctnumberNumber of correct answers so far.
finishedbooleantrue when the opponent has answered all their questions.
duelSocket.on("duel:opponent_progress", ({ currentIndex, correct, finished }) => {
  updateOpponentProgressBar(currentIndex, correct, finished);
});

duel:modifier_received

Delivered to the target player when an opponent activates a modifier against them. The client must apply the visual/mechanical effect immediately.
FieldTypeDescription
modifierobjectThe full modifier object: { id, label, icon, description }.
duelSocket.on("duel:modifier_received", ({ modifier }) => {
  if (modifier.id === "blackout") triggerBlackoutEffect(3000);
  if (modifier.id === "reduced_time") setPerQuestionTimer(10);
  if (modifier.id === "extra_questions") addExtraQuestions(3);
});

duel:modifier_used

Confirmation sent back to the player who triggered the modifier.
FieldTypeDescription
modifierobjectThe activated modifier object.
targetIdstringThe user ID the modifier was applied to.

duel:finished

Emitted to the duel:<duelId> room when both players have answered all questions. Contains final scores and the winner’s ID. A duel_result message is also automatically posted to the associated chat conversation, if one was provided when the invite was created.
FieldTypeDescription
winnerstringUser ID of the winner (determined by most correct answers; ties broken by timeSpent).
playersarrayFinal stats for each player.
Player result fields:
FieldTypeDescription
userIdstringPlayer’s user ID.
scorenumberFinal score.
correctnumberTotal correct answers.
totalnumberTotal number of questions in the duel.
finishedAtnumber | nullUnix timestamp (ms) when the player finished.
timeSpentnumber | nullTotal milliseconds spent answering.
duelSocket.on("duel:finished", ({ winner, players }) => {
  const me = players.find(p => p.userId === myUserId);
  showResultScreen({ winner, me, players });
});

duel:opponent_abandoned

Emitted to the remaining player when their opponent fires duel:abandon. The remaining player is considered the winner by default.
duelSocket.on("duel:opponent_abandoned", () => {
  showOpponentAbandonedBanner();
  endDuelAsWinner();
});

duel:rejected

Delivered to the requester when the recipient declines the invite.
FieldTypeDescription
inviteIdstringUUID of the declined invite.
duelSocket.on("duel:rejected", ({ inviteId }) => {
  dismissPendingInviteUI(inviteId);
  showToast("Tu rival rechazó el duelo.");
});

duel:error

Emitted back to the originating socket when any handler throws an unhandled error.
FieldTypeDescription
messagestringHuman-readable error description.
duelSocket.on("duel:error", ({ message }) => {
  console.error("Duel error:", message);
  showErrorToast(message);
});

Full Client Example

The example below shows a complete duel flow from connection through result screen.
import { io } from "socket.io-client";

const duelSocket = io("https://api.sealearn.app", {
  auth: { token: localStorage.getItem("token") },
  path: "/socket.io",
});

const myUserId = getCurrentUserId();
let activeDuelId = null;
let questions = [];
let currentIndex = 0;

// ── Connection ────────────────────────────────────────────────
duelSocket.on("connect", () => {
  console.log("Duel socket connected:", duelSocket.id);
});

// ── Sending an invite ─────────────────────────────────────────
function challengeFriend(friendId, lessonId, conversationId) {
  duelSocket.emit("duel:invite", { friendId, lessonId, conversationId });
}

duelSocket.on("duel:invite_sent", ({ inviteId }) => {
  showWaitingForOpponent(inviteId);
});

// ── Receiving an invite ───────────────────────────────────────
duelSocket.on("duel:invited", ({ inviteId, lessonId, requesterId, conversationId }) => {
  showIncomingChallengeModal({
    inviteId,
    lessonId,
    requesterId,
    onAccept: () => duelSocket.emit("duel:accept", { inviteId }),
    onDecline: () => duelSocket.emit("duel:reject", { inviteId }),
  });
});

// ── Duel starts ───────────────────────────────────────────────
duelSocket.on("duel:start", (payload) => {
  activeDuelId = payload.duelId;
  questions = payload.questions;
  currentIndex = 0;
  navigateToDuelScreen({ duelId: activeDuelId, opponentId: payload.opponentId });
  renderQuestion(questions[currentIndex]);
});

// ── Answer submission ─────────────────────────────────────────
function submitAnswer(answer) {
  duelSocket.emit("duel:answer", {
    duelId: activeDuelId,
    questionId: questions[currentIndex]._id,
    answer,
  });
}

duelSocket.on("duel:answer_result", ({ isCorrect, explanation, correctAnswer }) => {
  renderAnswerFeedback({ isCorrect, explanation, correctAnswer });
  currentIndex++;
  if (currentIndex < questions.length) {
    setTimeout(() => renderQuestion(questions[currentIndex]), 1000);
  }
});

// ── Opponent progress ─────────────────────────────────────────
duelSocket.on("duel:opponent_progress", ({ currentIndex, correct, finished }) => {
  updateOpponentProgressBar({ currentIndex, correct, finished });
});

// ── Modifiers ─────────────────────────────────────────────────
function useModifier(modifierId, opponentId) {
  duelSocket.emit("duel:use_modifier", {
    duelId: activeDuelId,
    modifierId,
    targetId: opponentId,
  });
}

duelSocket.on("duel:modifier_received", ({ modifier }) => {
  if (modifier.id === "blackout") {
    triggerBlackoutOverlay(3000);
  } else if (modifier.id === "reduced_time") {
    setTimerLimit(10);
  } else if (modifier.id === "extra_questions") {
    appendExtraQuestions(3);
  }
});

// ── Result ────────────────────────────────────────────────────
duelSocket.on("duel:finished", ({ winner, players }) => {
  const me = players.find((p) => p.userId === myUserId);
  const isWinner = winner === myUserId;
  showResultScreen({ isWinner, me, players });
  activeDuelId = null;
});

// ── Opponent abandoned ────────────────────────────────────────
duelSocket.on("duel:opponent_abandoned", () => {
  showBanner("Tu oponente abandonó — ¡ganaste!");
  activeDuelId = null;
});

// ── Leave early ───────────────────────────────────────────────
function abandonDuel() {
  if (!activeDuelId) return;
  duelSocket.emit("duel:abandon", { duelId: activeDuelId });
  activeDuelId = null;
  navigateHome();
}

// ── Error handling ────────────────────────────────────────────
duelSocket.on("duel:error", ({ message }) => {
  showErrorToast(message);
});

duelSocket.on("disconnect", (reason) => {
  console.warn("Duel socket disconnected:", reason);
});

Build docs developers (and LLMs) love