Legacy /api/ endpoints outside /api/social work without authentication. Optional X-Player-ID selects a UUID data
partition; no header uses 00000000-0000-4000-8000-000000000001. Requests are limited per selected
player per minute. This is unverified state selection, not account isolation: anyone can choose
another player's UUID. Gameplay membership, host actions, review and idempotency rules remain.
All API responses are non-cacheable and carry an X-Request-ID. See API.md.
Existing learning paths remain compatible except the explicit changes below. OpenAPI is generated
from the actual routes and typed models at /openapi.json.
| Method | Path | Behavior |
|---|---|---|
| GET / PUT | /api/me/profile |
Read or replace display name, goal, avatar reference, and accessibility preferences |
| DELETE | /api/me/data |
Delete owned application content/jobs/progress and leave all rooms; auth account remains active |
| GET | /api/me/progress |
Factual completion, first quiz-answer, correct-answer, card-review, and submission totals |
| GET | /api/resources/{kind} |
Owned metadata summaries; after cursor and limit 1–100; no text or answer keys |
| POST | /api/materials |
Upload one PDF or 1–20 JPG/PNG/WebP photos; extract text, then embed it for search by meaning |
| GET / DELETE | /api/materials/{id} |
Read metadata or delete material and its derived learning records |
| GET | /api/materials/{id}/pages |
Read extracted pages, revision, and confirmation state |
| PUT | /api/materials/{id}/pages |
Replace corrected pages with an expected revision; clears confirmation |
| GET | /api/materials/{id}/revisions/{revision} |
Read the immutable extracted-text revision referenced by an older lesson |
| POST | /api/materials/{id}/confirm |
Explicitly confirm the current extracted-text revision |
| POST | /api/chat |
Chat, optionally with a confirmed upload: answers from the PDF when it helps and says so (used_pdf, pages, label) |
| GET / PUT | /api/memory/profile |
The player's role label (parent or learner) and display name |
| POST / GET | /api/memory/conversations |
Start a remembered chat / list this player's chats with a count per subject |
| GET / PATCH / DELETE | /api/memory/conversations/{id} |
Open a chat with every message / rename it or change its subject / forget it |
| GET | /api/me/home |
My home, coin balance, cheers received and version; the first call creates the living room, its starter items and 240 coins |
| POST | /api/me/home/rooms |
Build the next room (every item of the newest room at level 3+; 250 coins × rooms built) |
| POST | /api/me/home/items/{room}/{item}/place |
Place a non-starter item at level 1 for 30 coins, with at and rot |
| POST | /api/me/home/items/{room}/{item}/upgrade |
Level +1 for 20 × the new level in coins, up to level 10 |
| PUT | /api/me/home/rooms/{room}/layout |
Replace one room's positions and rotations (free) |
| PUT | /api/me/home/visibility, /api/me/home/card |
Who may visit (advisory) / the name and avatar friends see |
| GET | /api/me/friends |
My friend code, friends and pending requests |
| POST / DELETE | /api/me/friends/requests, /api/me/friends/requests/{player_id}/accept or decline, /api/me/friends/{player_id} |
Ask by friend code / answer a request / remove a friend or cancel a request |
| GET | /api/homes?scope=friends or family, /api/homes/{player_id} |
Cards of homes I may visit / visit one (read-only, no coins) |
| POST | /api/homes/{player_id}/cheers |
Cheer a room or the whole home, once a day |
| POST | /api/me/coins/claim-quiz |
10 coins per first-correct answer of a quiz, once per question of its set |
| POST | /api/me/coins/demo-topup |
Add coins to try the home; 404 unless DEMO_MODE=true |
| GET | /api/memory/digest |
Subjects and topics this player has studied, with no chat text |
| GET | /api/challenges/shared-topics?room_id= |
Topics every member of a room has studied (from memory, matched by words and by meaning), and nothing else |
| POST | /api/challenges |
The AI writes a game (mcq, heads_up or diagram) for a topic and it is frozen for the room |
| GET | /api/challenges/{id} |
The challenge without answers (the creator and the room's members) |
| GET / DELETE | /api/challenges/{id}/review, /api/challenges/{id} |
Full content with answers (creator only) / forget it |
| POST | /api/matches |
Turn one of your challenges into a match (solo, friends, parent_child, duo) |
| POST / GET | /api/matches/{id}/start, /api/matches/{id} |
The creator starts a shared countdown / what this player sees now (poll about once a second) |
| POST / PUT / POST | /api/matches/{id}/answer, /placements, /cards, /finish |
Play: answer a question (mcq), place words and finish (diagram), mark a card (heads_up) |
| GET | /api/matches/{id}/results |
The ranking, once the game is over |
| GET / POST | /api/weekly/next, /api/weekly/current, /api/weekly/next/prepare |
Show-only preview of the weekly challenge, and writing its samples |
| POST | /api/chat/image |
Chat about 1-4 pictures (multipart): the model looks at the picture itself; nothing is stored |
| GET | /api/materials/{id}/find?topic=… |
Preview selected pages/chapter/source text; no lesson is written (by_meaning says whether embeddings took part) |
| POST / GET | /api/lessons, /api/lessons/{id} |
Generate draft lesson / read owned lesson |
| POST | /api/lessons/{id}/revisions |
Create a new draft lesson ID with edited lesson content |
| POST | /api/lessons/{id}/approve |
Record owner confirmation of a nonempty revision |
| POST | /api/lessons/{id}/visuals |
Generate planned or requested visuals; new visuals invalidate approval |
| GET | /api/lessons/{id}/visuals/{visual_id}/view |
Authorized sandboxed visual document |
| POST | /api/lessons/{id}/quiz |
Generate private practice questions and initial attempt |
| GET | /api/quiz/{id} |
Read public questions/options, excluding answer keys |
| POST | /api/quiz/{id}/attempts |
Start an independent attempt using the same immutable question set |
| POST | /api/quiz/{id}/check |
Check a choice; only the first answer contributes to saved progress |
| GET | /api/quiz/{id}/questions/{index}/view |
Question diagram; completed diagram requires an answer first |
| POST | /api/quiz/{id}/followup |
Generate new practice from recorded mistakes |
| POST | /api/quiz/{id}/flashcards |
Create cards from missed/all questions without a model call |
| POST | /api/lessons/{id}/flashcards |
Generate private lesson flashcards |
| GET | /api/flashcards/{id} |
Read cards and individual review state |
| POST | /api/flashcards/{id}/review |
Mark a card know or again |
| GET | /api/flashcards/{id}/cards/{index}/view |
Sandboxed diagram side of a card |
| POST | /api/lessons/{id}/complete |
Record a completion once; repeated calls return the same completion |
| POST | /api/tournament |
Generate a solo scored question set from an approved lesson |
| GET | /api/tournament/{id} |
Answer-free play representation |
| GET / POST | /api/tournament/{id}/review, /api/tournament/{id}/approve |
Explicit author review and owner confirmation |
| POST | /api/tournament/{id}/submit |
Save one complete scored submission; identical retries return the same result |
| POST / GET / DELETE | /api/jobs, /api/jobs/{id} |
Queue generation / inspect status / cancel queued work |
| POST | /api/rooms |
Create private lobby |
| POST | /api/rooms/join |
Join by invite code; repeat joins do not duplicate membership |
| GET | /api/rooms/{id} |
Authorized snapshot for initial load/reconnection |
| POST | /api/rooms/{id}/commands |
Versioned readiness, leave, or host-close command |
resources kinds: materials, lessons, quizzes, tournaments, decks, jobs, completions.
Pagination order is ascending opaque ID; pass next_cursor as after. Stop when it is null.
Corrected text uses consecutive 1-based page numbers, up to 600 pages and one million characters:
{"expected_revision":1,"pages":[{"number":1,"text":"Corrected source text.","via":"text"}]}Replacing text increments the revision and resets confirmed. Original revisions remain readable;
lessons record source.material_revision as well as material ID and page numbers. Confirm with:
{"expected_revision":2,"confirmed":true}The same approval shape applies to lesson revisions; initial lessons are revision 1. Stale revisions
return 409. Lesson edits use {"lesson":{"title":"…","summary":"…","sections":[{"heading":"…","body":"…"}],"key_points":["…"]}}.
A revision returns a new ID and links to its predecessor. It never edits earlier lesson IDs or attempts.
Material-backed lessons require confirmation before generation. General-knowledge lessons can be created without an upload and remain drafts. Practice is private formative work; scored solo sets require lesson approval followed by explicit question review and approval. The author-review route intentionally contains answers, so these self-reviewed results are not trusted ranked competition.
Profile PUT replaces the complete profile: omitted values reset to defaults. Avatar references are display preferences and grant no cosmetic ownership or purchases.
The initial /quiz ID is an attempt with an immutable question-set reference. /attempts creates
another attempt with the same questions and no answers recorded. Attempts are private to their owner;
selecting the same player UUID accesses the same state. Practice /check can be repeated for feedback, but later choices never
replace the recorded first choice. Flashcard reviews should send Idempotency-Key and expected_reviews (the current review counter).
Identical retries return their saved response; changed payloads or stale counters return 409. Each
deck retains up to 1,000 review receipts. Legacy reviews without these fields remain accepted and
do not provide replay protection. Completion markers are idempotent. No coins, XP, badges, mastery
claims, or inferred time-spent are granted by progress endpoints.
Tournament submission needs an approved question set. A repeated identical submission returns the original score; changed answers return 409. This remains a solo scored practice foundation, not a multiplayer tournament. Review is owner confirmation, not independent verification.
Send Idempotency-Key (8–128 characters) with:
{"kind":"lesson","parameters":{"topic":"photosynthesis","age":12,"material_id":"OWNED_MATERIAL_ID"}}Other kinds: quiz, flashcards, visuals, followup. Supply the owned lesson ID as resource_id
for quiz/cards/visuals; supply an owned quiz ID for followup. Parameters use the matching synchronous
endpoint's request model. Unknown parameters are rejected. Responses contain an ID, status, attempts,
created time, sanitized error, and result references. Inputs and lease credentials are not returned. Source ID/kind/revision are returned when applicable;
a worker rejects a changed material revision rather than silently generating from different text.
States: queued → running → succeeded, with bounded retries back to queued or terminal failed.
A stopped worker's lease expires after 15 minutes; another worker can reclaim the job. A stale worker
cannot commit output after its lease has been replaced. Job retries may repeat paid provider calls;
they do not promise exactly-once provider billing. Only queued work can be cancelled; repeating cancellation is safe. Cancelling running or completed work returns 409.
Generation is opt-in. Workflow-attempt limits count failed calls too, independently of request rollback.
GENERATION_LIMITS_ENABLED=false lifts the daily attempt budget, the per-minute request budget and
the inline "generation is busy" gate (usage is not counted while off). It is for testing only: with it
off nothing caps provider spend or concurrent model calls. The diagram renderer's own busy limit stays.
GENERATION_CONCURRENCY limits inline calls per API process and running durable jobs across workers.
The inline and worker pools are separate; use durable jobs for production scheduling. Durable workers
release database connections during model calls and verify source versions before committing, so
profile/lobby updates and status reads can continue while generation runs. Provider calls
have explicit timeouts and no automatic SDK retries; workflow retries are bounded in the engines.
Uploads currently run synchronously because file streams are not retained. Text PDFs are extracted locally; photo/scanned extraction can call a vision provider. Generation enablement/budgets apply to uploads conservatively even when a particular PDF needs no model call.
After extraction, POST /api/materials cuts the text into pieces of at most 1,000 characters (never across
a page; contents/index pages are skipped), embeds them with EMBEDDING_MODEL (default
openrouter:openai/text-embedding-3-small, EMBEDDING_DIMENSIONS 512; openai:<model> also works) and stores
one unit vector per piece as an owned material_vectors record keyed <material id>:<revision>. The response's
indexed_chunks is the number of pieces; 0 means keyword search only. No extra database extension or
migration is needed: the index is per book, so a plain cosine scan over a few thousand vectors is the search.
A topic request (not pages 40-55, not chapter 3) runs two searches and merges them by reciprocal rank
fusion: BM25 words and the pages whose best piece has cosine similarity of at least 0.25 to the topic. A topic
the words miss ("how do plants make food") or misspell ("photosynthsis") is found by meaning; a topic neither
search finds still returns 422 before any lesson is written. The selected pages' full text, not the pieces,
is what the lesson sees, so source.pages and truncation behave as before.
Embedding is best effort and never blocks anything. With no model, key, or a provider outage, the upload still
succeeds (warnings explains an outage) and topic search uses keywords. Correcting pages (PUT .../pages)
deletes the stored vectors; the next lesson from the confirmed text embeds it again, as does the first lesson
from a book that was uploaded while embeddings were off. GET .../find only uses an existing index (it costs
one tiny embedding call for the topic, only when generation is enabled) and never builds one. Vectors are deleted
with the material and by DELETE /api/me/data, and are never returned by any endpoint.
POST /api/chat takes {"message", "age", "material_id"?, "mode", "history"} and returns
{"answer", "used_pdf", "pages", "label", "mode"}. It is stateless: send the recent turns in history
({"role":"user"|"assistant","content"}, the last 10 are used). Without material_id it is an ordinary chat.
With one (a confirmed upload, else 409) the question is searched in the book by words and by meaning at the
level of 1,000-character pieces, and the best few pieces plus a short outline of the whole file (name, chapters,
samples from across it) go to the model, so "what is on the PDF?" works as well as a specific question.
mode: "auto"(default): the model uses the PDF when it helps and otherwise answers from general knowledge.mode: "pdf_only": answers only from the PDF. If the PDF does not contain the answer the reply is a fixed "I couldn't find that in your PDF" withused_pdf: false; outside knowledge never leaks through. Needs amaterial_id(422 otherwise).
used_pdf says whether the answer drew on the PDF, pages which pages (only pages the model was actually
shown can be cited) and label is ready-to-show text ("Used the PDF for this answer (pages 2-3)"), null when the
PDF was not used. The same child-safety rules as lessons apply: the question is screened, the answer is checked for
the age, and a blocked one is a friendly 422. Each message is one generation attempt against
GENERATION_DAILY_LIMIT, so raise that limit for a chat-heavy demo. The PDF text, history and message are data,
never instructions to the model. The first chat with a book that was uploaded before embeddings were on embeds it.
POST /api/chat/image is multipart: age, images (1-4 files: JPG, PNG or WebP, up to 8 MiB each), an optional
message (up to 1000 characters; leave it out or blank and the picture is simply explained, otherwise the question is
what gets answered) and optional history (a JSON string of {"role","content"} turns, as for /api/chat). It returns
{"answer", "used_image", "images", "label"} (label: "Looked at your image for this answer"). The model is shown the
picture itself, with no text extraction and no search index: a labelled diagram, a graph or a handwritten problem
keeps what matters. A scanned book is different and still goes through POST /api/materials and /api/chat.
- Nothing is stored. Each picture is re-encoded (upright, flat, at most 1600 px; camera location and other
metadata are dropped), used for that request and discarded. For a follow-up the client sends the picture again; the
conversation is kept by the client in
history. A PDF here is a 415 pointing to/api/materials. - Child safety, before anything is written: the question is screened, then the picture(s) are checked for the learner's age with the checker model (nudity, graphic violence, drugs, self-harm, hate symbols, identity documents, bank cards or private addresses; ordinary schoolwork and everyday pictures pass). A blocked picture is the same friendly 422 as other guardrails and never repeats what was in it. If the checker cannot run, young learners get a 502 and nothing is answered. The answer is then checked like every other generated text. A picture already approved for the same age policy is remembered by its fingerprint only (never the picture, bounded, in memory) so follow-ups about it are not checked again. Text written inside a picture is treated as data, never instructions.
- One generation attempt per message against
GENERATION_DAILY_LIMIT; the request body limit for this route is 36 MiB.
Chats can be remembered per player and continued later. This is one SQLite file (MEMORY_DB_PATH, default
memory.db, in memory for tests), separate from the records store, so it survives a restart of the demo server.
Every row carries the player id (X-Player-ID); one id can never read another's chats. A parent and a child just use
two ids. PUT /api/memory/profile stores a role label (parent or learner, the repo's word for the child) and
a display name for each id. The role is a label for the app and for later features, not a login or proof of who is
using the id, and there is no parent-to-child link.
Using it. POST /api/memory/conversations (optionally with a material_id) returns a chat id. Send it as
conversation_id to POST /api/chat or POST /api/chat/image (a form field there). The server then loads the chat's
own memory for the model (so history is ignored), saves the question and answer together, and returns
conversation_id, title, subject and an optional notice. A chat about a PDF carries on with that PDF without
material_id; if the PDF is gone (uploads are not part of this store) notice says so and pdf_only is a 422.
A picture is never saved: only the text, flagged used_image (a picture sent without a message is saved as the note
"(sent a picture)"). Without conversation_id chat stays stateless.
Subjects. After the first answer the model files the chat under one of Science, Math, History, Geography, Languages,
Computing, Arts or Other and names it after its topic (2 to 6 words). PATCH changes the title or subject; if
filing fails the chat goes under Other. The list endpoint returns the chats, newest first, and a count per subject for a
sidebar. Empty chats count under Other.
Long chats. The whole transcript is always kept and shown. What the model is given is bounded: once the unsummarised
part holds more than 6000 characters or 24 messages, all but the last 6 messages are folded into a rolling summary
(plain text, at most 1500 characters, written by the model, no personal details). The model then sees that summary
plus the recent turns. GET .../conversations/{id} returns summary and summarized_messages. Filing and folding
cost one small extra model call each, only when they happen, and never block an answer: if memory fails the answer is
still returned with a notice.
Digest. GET /api/memory/digest returns the player's subjects and, under each, the topics studied with
timestamps and message counts: no chat text, no model call. It exists so friend competitions can later match two
players on subjects they have both studied; reading another player's digest is not implemented and would be a
separate product decision.
Limits and deletion. 500 chats per player, 2000 messages per chat (a full chat says so in notice; creating
more is a 409). DELETE /api/memory/conversations/{id} forgets one chat; DELETE /api/me/data forgets all of a
player's chats and profile.
The home is stored per player in a SQLite file (HOME_DB_PATH, default home.db, in memory for tests), separate
from the records store and the chat memory. Only what a player chose is saved: rooms built, each placed item's
level (1-10), and where it was moved and turned (at in floor tiles 0-2.7, rot 0-3). Room art, item looks,
default spots and the numbers come from the frontend's lib/home-data.ts, copied into
learning_service/home_rules.json (its source field names the commit). Item ids repeat across rooms (plant,
sink), so an item is always addressed as room + item.
Spending. Building, placing and upgrading each run in one transaction: the wallet is debited only if it holds
enough, the change is written, a row is appended to the coin ledger and version goes up, or nothing happens.
402 not enough coins ("You need 40 coins."), 422 a rule is broken (the message says which), 404 unknown room
or item, 409 expected_version is out of date (send the version you last saw; omit it to skip the check).
The first two items of each room are starters: they begin at level 1 for free when the room is built. Every
change returns the whole home and the balance. PUT .../layout replaces the room's layout: placed items left out
go back to their default spot. The server checks that items are placed and positions are on the floor; it cannot
check furniture footprints, so the app still resolves overlaps when it draws.
Coins. A new wallet holds 240 coins. POST /api/me/coins/claim-quiz {"quiz_id": "..."} pays 10 per question
answered correctly on the first try, read from the answers the server recorded; each question of a question set
pays once, so claiming again or retrying the same questions adds nothing. Every coin change is a row in an
append-only ledger. Duel and quest rewards and shop purchases are not on the server: they still change the
browser's own balance and will not match this one.
Friends and visits. GET /api/me/friends returns my friend code; another player sends it to
POST /api/me/friends/requests and I accept or decline (the path id is the player who sent the request). Visibility
friends (the default) lets accepted friends visit; family and nobody admit only the owner, because no family
link exists. A stranger, a private home and a missing home all answer the same 404. Visitors get no coins. One cheer
per room (or home) per visitor per day (409 on a repeat). PUT /api/me/home/card sets the name and avatar shown
to friends; the default name is Player, and a card change does not change the home version.
Not verified. X-Player-ID selects a data partition; it does not prove who is calling, so anyone who knows a
player's UUID can act as them, and a friend is whoever shared a code. Friendships and visibility are an advisory
privacy setting, not access control. A parent does not approve friends here, and nothing links a parent to a child.
DELETE /api/me/data also removes the player's home, wallet, friend code, friendships and cheers. It is a
single-process store, like chat memory.
The content layer for competitions between friends. The AI writes a game for a topic the players have studied, it is frozen, and everyone in the room gets exactly the same content. No play engine exists yet (no timers, scoring, winners or rematches); this is the content those engines will run.
Finding the topic. GET /api/challenges/shared-topics?room_id= compares the memory of everyone in a room you belong to
and returns only the topics all of them have studied (a chat counts once it has had an answer). Nothing about a topic
only one player studied, and nothing from anyone's chats, is shown; each player's display name and memory role are listed.
Topics are matched in two steps. By words: one name's words contain the other's, so "Photosynthesis" and "Plant
photosynthesis" are the same; numbers count, so "World War 1" and "World War 2" are not. By meaning, when an embedding
model is configured and model calls are on: names worded differently ("Photosynthesis" and "How plants make food") are
matched, the topic comes back with reworded: true, and the response has by_meaning: true. Embeddings only shortlist
look-alike names, because they rate neighbouring topics as close as rewordings ("Ancient Egypt" and "Ancient Rome" 0.74,
"Photosynthesis" and "How plants make food" 0.50); a small model then confirms each shortlisted pair. If it cannot, only
names with a similarity of 0.80 or more count; if the embedding model fails, words alone are used and by_meaning is
false. Only topic names are sent to the models: no chats, subjects or player ids. The check adds roughly 2 to 8 seconds
when players have look-alike topics and nothing when their words already match, so show a spinner. It spends one of
the caller's daily generation attempts only when the models are needed, and shares the server's generation slots; with no
attempt left, or the server busy, the answer uses words only (by_meaning false) instead of failing.
The confirming step is deliberately strict, so a miss is likelier than a wrong match. Measured on pairs it was not tuned on, 11 of 12 rewordings matched and none of 14 neighbouring topics did. It does not treat a small part of a topic as the topic ("Pyramids and pharaohs" is not "Ancient Egypt"), and "Climate change" and "Global warming" were not matched.
Making a game. POST /api/challenges takes game, topic, age (use the lowest age in the room, so it is fair and
suitable for everyone), an optional room_id (the room's members at that moment can read it; later joiners cannot) and
optional count and seconds. The topic is screened and the content checked for the age like all generated text. One
generation attempt is used. Three games:
game |
Content | Defaults and limits |
|---|---|---|
mcq |
Fixed multiple-choice questions for a timer per question. Questions too long to read in the time are dropped. | 10 questions (5 to 15), 30 s each (15 to 60) |
heads_up |
Terms, each with 3 to 5 forbidden words (Taboo style). A forbidden word that is, or contains, part of its term is removed; weak cards are skipped. | 20 terms (8 to 30), 60 s per turn (30 to 120) |
diagram |
A flowchart of 5 to 10 boxes with about 45% blanked out (?), and a word bank of the right words plus 3 distractors. The model describes boxes and arrows; the Mermaid is built and validated in code. |
120 s for the whole diagram (60 to 300) |
What players see. The response and GET /api/challenges/{id} are the public view and never contain answers:
mcq has the questions and options; heads_up has only the deck size and turn length (the guesser must not see the
terms, so an engine deals the cards one at a time); diagram has the blanked flowchart, the slot ids, the word bank and
the time. The creator reads everything, with answers, explanations, the whole deck and the solution, from
GET /api/challenges/{id}/review; other players get a 404 so a game cannot be spoiled.
Casual by design. Every challenge carries label ("Written by AI for casual play and not reviewed") and
casual: true: it is checked for the age but not reviewed by a person, which is why it is separate from the reviewed
quizzes and duels. It expires after 24 hours. diagram records wrong_finish_penalty_seconds: 10: the rule agreed for
the race is that each wrong finish adds 10 seconds to a player's time (the existing diagram race already counts
submissions, so this is a small change there).
Storage. Challenges live in the same SQLite file as chat memory (MEMORY_DB_PATH) because several players read
one challenge; DELETE /api/me/data removes the challenges a player created. A PostgreSQL deployment would move these
tables behind the same MemoryStore interface.
A match is one played instance of a challenge: the server runs the clock and ranks the players. There are no
background timers. The server stores when play begins and works out everything else from the time of each request, so
clients only send actions and poll GET /api/matches/{id} about once a second. Clients never send times; use the
returned server_time_ms to line a countdown up with the server. Every poll counts towards the player's request limit (API_REQUESTS_PER_MINUTE, 120 by default), so poll once a second, not faster. All matches are casual (times include network delay;
nothing here is an anti-cheat boundary) and expire after 24 hours.
Flow. POST /api/matches with challenge_id (your own) and mode creates it; the creator calls POST .../start,
which sets play_at_ms five seconds ahead for everyone; players act; GET .../results returns the ranking once the game
is over (409 before). Only the match's players can read it (404 otherwise), only the creator starts (403).
mode |
Players | Rules |
|---|---|---|
solo |
You | Not available for heads-up. |
friends |
Everyone in the challenge's room (2 to 8) | Each for themselves, on equal terms. |
parent_child |
Exactly one parent and one learner, each with a memory profile | The parent gets two thirds of the time (the agreed handicap, 5 s minimum). |
duo |
Four players and teams: [[a, b], [c, d]] |
Points are added up per team; parents get two thirds of the time. Not available for the diagram yet. |
Timed quiz (mcq). Questions open for everyone at the same moment, one at a time: each lasts the challenge's
seconds_per_question, then a 3-second pause shows its answer. A parent's own time is shorter, but the screen moves on
together (closes_at_ms is shared, your_closes_at_ms is yours). POST .../answer {question_index, choice}: your first
answer to the open question is final (the same answer again is a replay, a different one is a 409); an answer after your
own time (plus 0.8 s network allowance) is a 409. Whether it was right, with the explanation, appears in the view only
once the question is over for everyone, so a fast answer tells the others nothing. Ranking: most correct, then less time
spent on the correct ones; equal results share a rank; a winner needs at least one correct answer. In duo a team's
correct answers and times are summed (both members answer every question, so its total is twice the question count).
Heads-up (heads_up). Players take turns: one guesses (phone on the forehead) while another explains. Everyone
guesses once; in duo the teams alternate and the teammate explains, so each team plays two turns. A turn lasts the
challenge's seconds_per_round (a parent guessing gets two thirds), with a 15-second handover between turns. The server
deals the cards one at a time from a shuffled deck, across turns, so nobody sees a card twice. GET shows the current
card to the two players of the turn only. They (either of them, since one phone is passed around) send POST .../cards
{action, card_index}: correct +1, skip 0, violation -1 (the explainer said a forbidden word). Sending the same
card again is a harmless replay, a card not dealt yet is a 409, marking while no turn is being played is a 409, and anyone but the two players of the turn gets a 403. A turn's points
never go below zero and count for the guesser (their team in duo). Ranking: most points.
Diagram (diagram). Everyone races at once on the same board (board: the flowchart with ? boxes, slots,
options). PUT .../placements sends your whole arrangement as {slot id: word index} and replaces the previous one
(partial is fine, a word only once). POST .../finish needs every blank filled: a wrong finish returns how many are right
and which slots are wrong, never the right words, and adds +10 s to your final time (penalty_seconds);
a perfect one records your time. Your time limit is the challenge's seconds (two thirds for a parent), after which you can
no longer place or finish. Results unlock when everyone has finished or run out of time. Only perfect reconstructions are
ranked, by time plus penalties; anyone who did not finish is listed unranked.
view in GET /api/matches/{id} depends on the game: mcq has phase (waiting, countdown, question, reveal, finished),
the open question, reveals and running scores; heads_up has the turns plan, the current turn (with card for its
two players), cards_left and scores; diagram has the board, you (placements, wrong finishes), your deadline and
who has finished (your_deadline_ms is your own cut-off). Matches live in the same SQLite file as chat memory (MEMORY_DB_PATH); DELETE /api/me/data removes the
ones you created.
GET /api/weekly/next?band= (and /current) previews the weekly tournament: one common school topic per week that
needs nobody's memory (a curated list that rotates by ISO week), the three games, the four modes, the rules, and an
example leaderboard that is made up and labelled so. Leagues are age bands 10-12, 13-15 and 16-18 (content is
written for 11, 14 and 17). It is a preview: there is no sign-up, no scoring and no real leaderboard yet.
POST /api/weekly/next/prepare {band, games?} has the AI write sample content (five quiz questions, an eight-card
heads-up deck and a fill-in-the-blanks diagram, checked for the band's age and without answers in the preview) and caches
it for the week and band, so showing the preview again costs nothing; games already prepared are kept. It takes about
45 s the first time and uses one generation attempt.
Create: {"display_name":"Alex","capacity":4}. Join: {"code":"INVITECODE","display_name":"Sam"}.
Codes are eight uppercase characters; send the actual returned code. Default capacity 4, range 2–8.
Rooms expire after 24 hours. Snapshots contain ID, code, host, capacity, version, expiry, closed flag,
and member display names/readiness. They contain no auth tokens, emails, lesson text, or answer keys.
Commands: {"expected_version":2,"action":"ready","ready":true}; actions also include leave and
close. Stale versions return 409; reread before retrying. Only the host closes a lobby. When the host
leaves, the earliest remaining member becomes host. An empty lobby is deleted. A closed room rejects
new joins and readiness changes. Lobby readiness never automatically starts a game.
On Supabase, the migration installs private room:<id> receive authorization and optional
room_changed notifications. Subscribe privately and refetch the authorized snapshot on each event
or reconnect. Room membership is checked by the API on every snapshot. Notifications carry only the
room ID; do not rely on a transport subscription remaining authorized after someone leaves. Presence
publishing and active-game synchronization are not implemented by these lobby endpoints.
JSON bodies: 4 MiB. Multipart bodies: 61 MiB, including framing; each file 25 MiB, combined file bytes 60 MiB, 20 photos, 600 PDF pages, at most 100 vision pages per upload. Actual received bytes are bounded, including chunked or falsely declared requests. The generation workflow can return fewer reviewed questions than requested; inspect the returned count and dropped count.
403: forbidden host action. 404: absent or unowned record. 409: stale state, review required, or conflicting replay. 413/415: size/type. 422: invalid request. 429: request, attempt, or concurrency limit. 502: sanitized provider failure. 503: storage unavailable or generation disabled.
Deleting material removes its derived lessons/revisions, quizzes, question sets, decks, completions,
and associated jobs. /me/data removes owned application records and lobby membership without
changing any external account. Minimal anti-abuse counters remain; no raw provider prompts,
images, or extracted pages are retained in those counters. Original uploads/images are not retained
by this API after extraction; extracted text persists until explicit deletion.
Automated content retention periods, guardian permissions, reward economies, public matchmaking, rankings, gameplay, and the proposed age-safety output gate remain pending. No frontend files or hosted database were changed by this API implementation.
The economy contract adds server-scored same-device duel submissions, parent-child MCQ reward claims, 20 participation coins per completed backend game, daily/weekly quests with Europe/Berlin calendar resets, shared wallet history, server-priced purchases, inventory and equipment. Clothing/accessories cost 100 coins each. This supersedes earlier statements that duel/quest rewards and shop purchases are pending. The frontend remains unchanged and needs API wiring. See the contract for request examples, activity definitions, migration 010, storage boundaries, and the existing unverified player selector.
Presence and invitations adds friend-request send/inbox/outbox/accept/decline/cancel APIs, expiring multi-session online/away/busy status, friend-only presence reads, visibility/invite preferences, and idempotent lobby invitations. Recipients accept/decline; senders cancel. Acceptance atomically joins the lobby in PostgreSQL. The existing challenge/match and reward APIs handle play after everyone joins. Apply migration 011 before deploying; readiness checks it. Frontend wiring, push delivery, async saved-question duels, and verified account authentication are outside this backend-only addition.
The frontend-compatible social API implements all sixteen /api/social operations
from frontend 2ff2ec5: verified Clerk JWTs, signed username/player binding, search, friend
requests, presence and server-clocked live duels with automatic wallet/quest integration.
Only this prefix requires Clerk tokens, per the user's updated decision; legacy APIs remain
unchanged. Swagger has typed schemas, examples and a scoped Bearer authorization scheme.
Configure the issuer and signed claims before switching SOCIAL_API=backend. This supersedes
earlier statements that these routes and scoped token verification were still pending.