2026 10 13 multiplayer shared base design - xopherdeep/do-it-for-the-xp GitHub Wiki

Multiplayer, part 1: the shared base — design

Date: 2026-10-13. Status: design approved in chat by the owner, section by section; this spec awaits review.

Part 1 of 3. Part 2 is A, the party and body double (parties, invites, kid safety rules, live rooms, Quest Together, cheers). Part 3 is B, Family Sync (the family's quests, claims and approvals on every device, remote approvals, Fridge Mode). Each gets its own spec, plan and build. The idea backlog is chemx #1159.

Why

The app started as a star chart and a chore wheel on a fridge. It worked because everyone could see everyone's stars. The owner wants that back on every phone: a Family, Parties with friends, joined by QR code, and above all a gamified virtual body double: doing a chore beside someone, even an avatar, makes it easier to start and to finish. That helps anyone who needs motivation, not only kids.

A and B need the same things underneath: the family known to the server, its heroes, phones that act as one hero, and a way for game events to leave a device. This spec builds that once. On its own it gives families nothing new to do, so it ships hidden until A's first piece is ready.

Decisions (owner, 2026-10-12 and 2026-10-13)

  • Both A and B are in multiplayer v1, built on one shared base.

  • XP Pro stays the one plan, priced per family. No per-kid pricing, no second tier at launch.

  • The free/Pro line:

    Free XP Pro
    The whole chore loop on the shared device ✓ ✓
    Kid phones: hero card, parties, cheers, joining sessions ✓ ✓
    Hosting Quest Together 3 a week Unlimited
    The family's quests and approvals on every phone (Family Sync) ✓
    Remote approvals, Fridge Mode, more than one Game Master ✓
    AI party member, AI helpers, Temple Creator ✓

    The host pays: anyone invited to a session joins free, so the invite loop never meets a paywall.

  • Kid phones are free for party play. So hero device keys and QR linking belong in this base, not in B.

  • The family account is the Town Hall's (2026-10-13-town-hall-wp-bridge-design.md): one WordPress network account per family, held by the Game Master, linked per device with an Application Password; heroes never get an account or an email.

  • A kid phone never holds the parent's Application Password. It carries the parent's powers. Kid phones get a hero-only key instead.

  • Hero keys are not the XP plugin's API keys (xp/v1/keys). Those are the paid developer-API keys: issued only with the scopes of a paid tier, and any Bearer token on an xp/v1 route is checked by Gatekeeper. Hero keys are their own small feature under their own route prefix.

  • WordPress (xophz-compass-xp) is the system of record. The live part of A (presence, session timers) runs on a Cloudflare Durable Object, following Xophz-COMPASS/workers/bubblegum-sync; that is A's spec, not this one.

  • The app stays local-first. Nothing in the game waits on the server.

  • For the owner before multiplayer launches: cap or end the $79 Founder's Lifetime. Live rooms cost money per active user, and a lifetime buyer would get Family Sync and unlimited hosting with no further payment.

1. Scope

In:

  • Tying a linked family to the server, with its heroes and their age groups.
  • Kid phones: one-time link codes shown as a QR, hero device keys, kid mode on the phone, managing and removing phones.
  • The event stream: a send queue on each device, an append-only log on the server, downloading by number.
  • Hero cards: the public-safe face of each hero.
  • Rewards the server grants, paid once on the family's home device.

Out (A): parties, invites, party rules by age group, live rooms, Quest Together, cheers, the activity feed outside the family. Out (B): syncing quests, claims and approvals; more than one home device; the Pro check on events.

Needs first: Town Hall Phase 2 (plan 2026-10-13-town-hall-wp-bridge.md, Tasks 7 to 12): the site client (TH2), the rehearsal network (TH7), the membership hook (TH8) and the link on the device (TH10).

Ships hidden behind the build flag VUE_APP_MULTIPLAYER (off in production) until A's first piece is ready. Development builds also read localStorage["xp:multiplayer"] = "on", so the shared dev server needs no restart. With the flag off nothing is queued and nothing is sent.

2. The server

Everything lives in xophz-compass-xp, in new classes under includes/family/, on the routes xp-family/v1/.... The routes are registered only on the sites in the Town Hall's list (filter xp_town_hall_site_urls, default https://doit.forthexp.com), so the plugin stays inert on every other site of the network. The xp/v1 key filter never sees these routes because it only acts on /xp/v1 paths.

Tables, created per site with dbDelta on activation and on a stored schema version check (the pattern of class-xophz-compass-xp-activator.php):

Table Columns
{prefix}xp_families family_id char(36) primary key; owner_user_id bigint, unique; home_device_id varchar(16); created_at, updated_at
{prefix}xp_family_heroes primary key (family_id, hero_id); hero_id varchar(64); nickname varchar(40); age_group varchar(8); card longtext (JSON); card_at datetime; created_at; removed_at nullable
{prefix}xp_family_devices id bigint auto; family_id; hero_id; device_id varchar(16); label varchar(60); key_hash char(64), unique; created_at; last_seen_at; revoked_at nullable
{prefix}xp_family_link_codes code_hash char(64) primary key; family_id; hero_id; created_by bigint; expires_at; used_at nullable
{prefix}xp_family_events seq bigint auto primary key; event_id char(36), unique; family_id; hero_id; device_id; kind varchar(40); scope varchar(8); occurred_at; received_at; data longtext (JSON); index (family_id, seq)

A WP-Cron job deletes events older than 90 days, link codes past their expiry, and revoked devices after 30 days.

Routes:

Method and route Caller Does
POST /family {family_id, device_id} parent Ties the family to the account; answers created, home, home-elsewhere or other-family (section 4)
POST /family/move {family_id, device_id} parent Makes this device the family's home device
GET /family parent, hero key The family and its heroes' cards; age groups only for the parent
POST /heroes/{hero_id} {nickname, age_group} parent Registers or updates a hero
DELETE /heroes/{hero_id} parent Removes the hero: card gone, phones revoked
POST /heroes/{hero_id}/link-codes parent A new link code (section 5)
POST /devices/claim {code, device_id, label} anyone Trades a link code for a hero key
GET /devices parent The family's hero phones
DELETE /devices/{id} parent; a hero key for its own phone Unlinks a phone
POST /events {events: [...]} parent, hero key Appends up to 50 events (section 6)
GET /events?since={seq}&limit={n} parent, hero key Events after seq that the caller may read, oldest first, limit up to 200

3. Who is calling

  • Parent: a signed-in WordPress user (an Application Password from the Town Hall link, or the hosted app's cookie and nonce) who is a member of the site (is_user_member_of_blog). For every route except POST /family, they must also be the family's owner_user_id. A visitor who is signed in but not a member gets 403 xp_family_not_member, and the app offers Family registration (the Town Hall rule: website login alone is not membership).
  • Hero key: the header X-XP-Hero-Key. The server hashes it (SHA-256), finds an unrevoked device, and acts for that one hero of that one family. It updates last_seen_at at most once a minute. A revoked or unknown key gets 401 xp_family_key_revoked.
  • The server itself: PHP code in the plugin (A's rooms, through a function, never a route).

The app's TownHallAuth (TH2) gains a fourth kind, { kind: "hero"; key: string }, which authHeaders turns into X-XP-Hero-Key. TownHallError gains siteCode?: string, the WordPress error code (xp_family_moved and so on), so the app can tell the cases apart, and retryAfterSec?: number from a Retry-After header. Both are small changes to the TH2 client, made after TH2 lands.

4. The family and its heroes

Binding. When a Game Master's device is linked (or signed in through the website) and the flag is on, the app calls POST /family with its family ID and device ID from familyIdentity.ensureIdentity():

The account Answer The app
has no family created; this device is home Registers the heroes, sends their cards
has this family, this device is home home Carries on
has this family, home is another device home-elsewhere Asks: "This family's home is another device. Make this one home?" Yes calls POST /family/move; the old home's next send gets 409 xp_family_moved
has no family, but this family ID is another account's 403 xp_family_taken Says the family is registered to another account (a second parent joining is B's multi-GM)
has a different family other-family with that family's ID Says the account already runs another family. Choices: link this phone as a hero's phone (with a code from the home device), or move the family over with a backup file. Family Sync (B) is what puts one family on several devices

On the free plan a family has one home device: the device that holds the family's quests, pays rewards and sends cards. Moving home is how a family changes phones. B lifts the limit.

Heroes. The home device registers every hero on it, the Game Masters included, with POST /heroes/{id}, again when a hero is added or their age group changes; a nickname change travels in the hero's next card. Removing a profile on the device calls DELETE /heroes/{id}. Only these go to the server: hero ID, nickname, age group, and the card (section 7). Never the full name, birthday, email, passcode, favourite thing or favourite food.

The nickname is the profile's name.nick. A hero with no nickname is registered only after the Game Master gives one: the family card says so and opens the profile form, and the clerk reminds them that friends will see it. Nicknames are 1 to 40 characters.

Age groups: child (under 13), teen (13 to 17), adult.

  • From the birthday when the profile has one; Game Master profiles (isAdult) are adult.
  • Otherwise the Game Master picks in the family card. Until they do, the hero counts as child.
  • Only a parent call can set it, and the base only stores it. A enforces the party rules that read it.

5. Kid phones

Link codes. On the home device the Game Master taps a hero's Phones in the family card, then Link a phone. POST /heroes/{id}/link-codes returns a code of 8 characters from ABCDEFGHJKMNPQRSTUVWXYZ23456789 (no 0, O, 1, I or L), shown as ABCD-EFGH, valid for 10 minutes and once. The server stores only its hash. A new code for the same hero replaces the old one. The screen shows the QR, the typed code and a countdown.

The QR holds a link to the app: {app origin}/link?code=ABCDEFGH (#/link?code=… with hash history, as the Town Hall builds its return address). Any phone's camera app opens it. Development builds also accept &site= for the rehearsal; production ignores it, so a QR can never point a phone at another site.

Claiming. The kid phone reaches /link from the QR, from the first-run screen ("I'm a hero with a family code": the camera on the phone apps, typing everywhere), or from the hero picker. POST /devices/claim with the code, the phone's device ID and a label ({nickname}'s {platform}, for example "Mia's iPhone") returns:

{ "key": "xph_…", "family_id": "…", "hero_id": "…", "card": { … } }

The key is xph_ plus 32 random bytes in base64url. It is shown once, and the server keeps only its hash. The phone stores the key, calls familyIdentity.adoptFamily(family_id) (so Pro and Gatekeeper later see the right family) and enters kid mode.

A wrong, used or expired code gets 404 xp_family_code_invalid ("That code didn't work. Ask your Game Master for a new one."). Claims are limited to 10 failures per address per hour (429). With 31⁸ codes and a 10-minute life, guessing is not a practical attack.

What a hero key may do: read the family's hero cards (GET /family), send the event kinds A and B mark as written by a hero phone (none in this base), read its own family's events (section 6), and unlink its own phone. Nothing else: no approvals, no posting, no link codes, no other hero.

Kid mode on the phone:

  • The phone shows a small home of its own (/hero-phone): the hero from the card (voxel look, class, level), the line "Your quests are on the family device", and, once A ships, the party. With Pro (B) it becomes the full portal.
  • A router guard sends every other route to /hero-phone. canAccessGameMaster (src/router/_Guards/gm.guard.ts) answers denied in kid mode, whatever profiles the phone holds.
  • No hero picker, no backup or restore, no Game Master console.
  • The card refreshes when the app opens and when it comes back to the front.

Managing phones. The same modal (Game Master only) lists that hero's phones from GET /devices: label, when linked, when last seen, Remove. Removing calls DELETE /devices/{id}. A removed phone's next call gets 401; it shows "This phone was unlinked. Ask your Game Master to link it again." with Start fresh, which clears kid mode and returns the phone to the normal first run.

New dependencies: qrcode (draws the QR on the home device) and @capacitor-mlkit/barcode-scanning (the camera on the phone apps). The web build has no in-app scanner: the phone's camera app opens the QR link, and typing works everywhere.

6. The event stream

On the device. Game events wait in a send queue in FamilyEventsDb, separate from the Town Hall's post queue: posts wait for approvals, game events only get appended. An event:

interface FamilyEvent {
  id: string;        // uuid v4, made on the device
  heroId: string;
  deviceId: string;
  kind: FamilyEventKind;
  at: string;        // ISO time on the device
  data: Record<string, unknown>;
}

The kind decides who may see the event; the device does not choose.

Sending. Up to 50 events per request. Sent when the app starts, a few seconds after an event is queued, on the browser's online event and when the app comes back to the front. One send at a time. An event leaves the queue when the server lists it as accepted or duplicate. An event still marked sending when the app starts goes back to queued.

On the server. Each event is checked and stored, or refused with a reason; the answer lists each event:

{ "accepted": ["id…"], "duplicate": ["id…"], "rejected": [{ "id": "…", "code": "xp_family_event_invalid", "message": "…" }] }
  • The ID must be a v4 UUID. An ID already stored answers duplicate, so a lost reply never makes a second copy.
  • The hero must belong to the caller's family and not be removed; a hero key may only send for its own hero.
  • A parent's events must come from the family's home device (deviceId is home_device_id); any other device gets 409 xp_family_moved for the whole batch.
  • The kind must be known, and the caller must be allowed to write it (table below). Unknown kinds are refused, so the server never stores data nobody has defined.
  • at may be in the past (offline devices catch up) but not more than a day ahead of the server.
  • data must be a JSON object of at most 8 KB. For each kind the server keeps only the listed fields, checks their types, and drops everything else.
  • Limits: 120 events per hero per hour and 600 per family per hour; past that, 429 with Retry-After, and the device waits.
  • Inserts for one family run under a named lock (GET_LOCK("xp_family_{id}"), as Gatekeeper_Xp_Pro_Db does for its per-family work), so a family's numbers are committed in order and a reader asking for "after 104" can never miss a 104 that was still being written.

Downloading. GET /events?since= returns events after that number, oldest first. Numbers only grow; gaps are normal. Each device keeps the last number it has handled. The home device reads every event of its family. A hero key reads the family's public events and the family events of its own hero.

Kinds in this base:

Kind Who may see Written by Fields kept
hero.card public the home device nickname, look (the HeroLook), jobClass, level
hero.leveled public the home device level, jobClass
quest.done public the home device difficulty, xp, fight (true when it was a won battle)
reward.granted family the server only xp, ap, reason, sourceId

Public means the family's own devices in this base. A widens it to the hero's parties. quest.done never carries the quest's name or category, because both are typed by parents and can be private ("take your medicine"). Gold never appears in a public event: it buys real rewards the parent set.

When the flag is on and the family is linked, the home device sends a hero.card for each hero. After that it sends hero.card when the card changes, hero.leveled on a level up, and quest.done when a quest's rewards are paid. These come from the places that already pay rewards and level heroes up (QuestCompletionService, PlayerStatsService); the plan names the exact lines. Nothing from before the flag was turned on is sent.

Free and Pro send the same kinds in this base. B adds the family kinds that need Pro and the server's check against Gatekeeper (Gatekeeper_Xp_Pro_Licenses::get and is_entitled).

7. Hero cards

interface HeroCard {
  heroId: string;
  nickname: string;
  look: HeroLook;
  jobClass: string;
  level: number;
  updatedAt: string;
}

The server keeps the newest hero.card per hero (by at) in xp_family_heroes.card, and the hero's nickname from it. The home device compares each hero's card with the last one it sent and queues a new one only when something changed. The age group is stored beside the card but is not part of it. Streaks and badges join the card when the app has them; it has neither today.

8. Rewards from the server

Only server code writes reward.granted, through Xophz_Compass_Xp_Family_Events::grant_reward( $family_id, $hero_id, $xp, $ap, $reason, $source_id ). A device that sends this kind is refused. A's rooms are the first caller (Quest Together bonus XP); in the rehearsal it is called with wp eval.

Rewards are XP and AP only, never gold. The home device, after each download:

  1. Takes each reward.granted whose ID is not on its paid list.
  2. Looks for its receipt, the quest log entry with questId party:{event id} and the new source "party" (QuestLogSource), so the hero sees "Party bonus". No receipt: it writes one, pays the XP and AP through applyQuestRewards, then marks the receipt rewardsApplied. A receipt not yet marked is paid and marked; a marked one is not paid again.
  3. Adds the ID to the paid list and saves the last-seen number.

A reward is paid once even if the download repeats, the last-seen number is lost, or the app stops between paying and saving. Paid IDs older than 90 days are dropped, because the server no longer holds those events.

9. App modules and state

New pure modules in src/lib/services/multiplayer/, each with a jest spec in tests/unit/:

Module Holds
familyServer.ts The xp-family/v1 calls through townHallFetch, with the parent's auth or the hero key; site error codes to outcomes
ageGroup.ts Age group from a profile (birthday, isAdult, the Game Master's pick)
heroCard.ts A card from a profile, using only the allowed fields; whether it changed since the last one sent
familyEvents.ts The kinds, who may see each, who may write each, the fields kept (the same lists as the server); building an event
eventOutbox.ts The queue states (queued, sending), batching, what each answer and error does, the reset on start
eventInbox.ts Downloading after the last number, paying rewards once, trimming the paid list
deviceLink.ts The QR link: building it, reading it (code, and site only in development); the claim; kid-mode state

State on the device, through StorageService. Neither database is added to BACKUP_STORES: a backup must never carry a hero key, and a restored queue would resend old events.

// FamilyEventsDb, storage name "__xp_family_events"
interface FamilyEventsState {
  outbox: Array<FamilyEvent & { status: "queued" | "sending"; tries: number; nextTryAt?: number }>;
  lastSeq: number;
  paidRewards: Array<{ id: string; at: string }>;
  lastCards: Record<string, string>;   // heroId -> the JSON of the last card sent
  home: "home" | "home-elsewhere" | "other-family" | "unknown";
}

// HeroDeviceDb, storage name "__xp_hero_device"
interface HeroDevice {
  siteUrl: string;
  familyId: string;
  heroId: string;
  deviceId: string;
  key: string;
  label: string;
  linkedAt: number;
  card: HeroCard;
}

A setup-style store, useFamilyStore (like pro.ts and the Town Hall's store), holds the binding answer, the heroes' cards, the phones list and kid mode, and runs the send and download moments.

Screens:

  • A family card in Settings → Family, Game Master only: each hero's nickname check and age group, and a Phones button that opens that hero's Link a phone modal (QR, code, countdown, the phone list with Remove). Both are their own <script setup> components, so neither Family Settings nor the profile form grows.
  • The first-run screen gains I'm a hero with a family code.
  • /link (reads the code, claims, enters kid mode), /hero-phone (kid mode's home), and the unlinked screen.
  • Every message is a clerk line in the game's dialog (XpRevealPrompt, hometownPrompts.ts), never a toast or a native alert.

10. When things go wrong

Situation What happens
Offline, timeout, 5xx Everything keeps working on the device; the queue waits and retries with growing pauses
401 on the parent's link The Town Hall's rule: the link is removed, the Game Master registers again; the queue is kept
401 on a hero key The unlinked screen with Start fresh
403 xp_family_not_member Offers Family registration
403 xp_family_taken Says the family is registered to another account
POST /family answers home-elsewhere or other-family; 409 xp_family_moved on a send The home question from section 4; sending stops until it is answered
404 xp_family_code_invalid "That code didn't work. Ask your Game Master for a new one."
429 Waits for Retry-After, then sends again
An event rejected Dropped from the queue and logged with debug; the rest of the batch carries on

11. Slices

  1. The server. Tables, routes, the three callers, binding, heroes, link codes, claims, events, the cleanup job, grant_reward. PHP tests through the plugin's runner. Runs on the rehearsal network from TH7.
  2. The family on the server. TownHallAuth and TownHallError additions, familyServer, ageGroup, heroCard, familyEvents, eventOutbox, the store, binding on link with the home question, registering heroes, cards and public events, the family card without linking.
  3. Kid phones. deviceLink, Link a phone, the QR, /link, the scanner on the phone apps, kid mode and /hero-phone, the phone list and Remove, the unlinked screen.
  4. Rewards. eventInbox, paying once, the "party" quest log source, then the full rehearsal run.

12. Testing

  • Jest for every module in section 9: age groups from each kind of profile; the card keeps only its fields and notices changes; event building and field lists match the server's; every queue transition, the reset on start, one send at a time, accepted, duplicate, rejected, 429 and offline; the inbox pays a reward once across repeated downloads and a lost last-seen number; the QR link in both history modes, and site refused in production; kid mode closes the Game Master guard; neither database is in BACKUP_STORES.
  • PHP, through tests/run-tests.mjs: each route refuses the wrong caller; a hero key cannot send for another hero, cannot write reward.granted, and cannot read another hero's family events; a duplicate ID is stored once; unknown fields are dropped and unknown kinds refused; codes expire, work once and are replaced; claim failures are rate-limited; the per-hero and per-family limits; move, home-elsewhere, other-family and xp_family_moved; removing a hero revokes its phones.
  • The rehearsal network (TH7): link the family, register heroes, make a code, claim it in a second browser, see kid mode, send events, download them, call grant_reward with wp eval, check the reward is paid once on the home device, remove the phone and see the unlinked screen.
  • Cypress at 390×844 with cy.intercept (never the live site): the family card, the QR and countdown, /link with a good and a bad code, /hero-phone, the unlinked screen.
  • chemx verify before every commit.

Out of scope

  • Everything in A and B listed in section 1.
  • Accounts for heroes; email for anyone but the Game Master.
  • Universal links that open the phone apps from the QR (the camera opens the web app; the phone apps use their scanner).
  • Notifications.
  • Moving a family between devices without a backup file (B).
⚠️ **GitHub.com Fallback** ⚠️