2026 10 13 multiplayer shared base design - xopherdeep/do-it-for-the-xp GitHub Wiki
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.
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.
-
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 anxp/v1route 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.
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.
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 |
-
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 exceptPOST /family, they must also be the family'sowner_user_id. A visitor who is signed in but not a member gets 403xp_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 updateslast_seen_atat most once a minute. A revoked or unknown key gets 401xp_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.
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) areadult. - 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.
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.
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 (
deviceIdishome_device_id); any other device gets 409xp_family_movedfor 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.
-
atmay be in the past (offline devices catch up) but not more than a day ahead of the server. -
datamust 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}"), asGatekeeper_Xp_Pro_Dbdoes 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).
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.
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:
- Takes each
reward.grantedwhose ID is not on its paid list. - Looks for its receipt, the quest log entry with
questIdparty:{event id}and the new source"party"(QuestLogSource), so the hero sees "Party bonus". No receipt: it writes one, pays the XP and AP throughapplyQuestRewards, then marks the receiptrewardsApplied. A receipt not yet marked is paid and marked; a marked one is not paid again. - 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.
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.
| 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 |
-
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. -
The family on the server.
TownHallAuthandTownHallErroradditions,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. -
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. -
Rewards.
eventInbox, paying once, the"party"quest log source, then the full rehearsal run.
-
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, andsiterefused in production; kid mode closes the Game Master guard; neither database is inBACKUP_STORES. -
PHP, through
tests/run-tests.mjs: each route refuses the wrong caller; a hero key cannot send for another hero, cannot writereward.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-familyandxp_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_rewardwithwp 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,/linkwith a good and a bad code,/hero-phone, the unlinked screen. -
chemx verifybefore every commit.
- 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).