02. Architecture
How a game talks to the platform. All citations are MM/ = maneet-games/master-mind/,
working tree read 2026-10-19.
1. Layers
flowchart TB
subgraph HOST["HOST (one of: Hub · Offline shell · Game Studio · Standalone)"]
H["identity, roles, wallet + MM coins, passes/codes, quests,<br/>AI gate, db/room access + per-game scopes,<br/>catalogue, Code Editor overrides"]
end
subgraph SDK["SDK v2: games/_sdk/mm-sdk.js (window.MM) — services, no UI"]
S["ready · data · store · player · caps/hello · errors · rewards · quest"]
end
subgraph KIT["optional: games/_kit/kit.js (MMKit) + family layers (games/_ordie/core.js)"]
K["screens · lifecycle · pause · fit · input · sound · toast/modal"]
end
G["GAME: games/<id>/index.html — gameplay, art, tuning, own save shape"]
G -->|hooks| K --> S
G -->|plain JS calls| S
S <-->|"postMessage {__mm:1,id,cap,op,…}"| HRule: a layer calls only the layer below it, and only the host touches window.claude capabilities (db, room,
sample, user) (FINAL-PLATFORM-AND-IMPROVEMENTS.md §2.2). Inside the hub, the game iframe has no window.claude.
2. The hub ↔ game bridge
Envelope (MM/games/_sdk/mm-sdk.js header, "Wire protocol"):
game → hub {__mm:1, id, cap, op, …args}
hub → game {__mm:1, reply:id, val, err?:{code, message, retryable?}}
hub → game {__mm:1, push:sid, val} (subscriptions)
hub → game {__mm:1, event:"player", val:{player}} (a different player signed in)The handler is one message listener (MM/index.html:3653). In order, it:
1. Drops any message whose source is not #frame or the Code Editor preview #edPrev, or that lacks __mm/id.
2. Stamps a generation ("f"+launchSeq+"."+frameReloads); a reply to a game that has since closed or reloaded is
dropped (:3656-3658).
3. Rejects unknown capabilities and ops with unsupported (:3666-3675). The lists (:3398-3401):
| cap | ops | who may call |
|---|---|---|
sys | hello | only the open game in #frame |
mm | ready, save | open game in #frame (save: :3778) |
mm | quest, pass, rewards | open game (:3782-3784); pass/rewards are read-only |
mm | authz | open game; returns this tab's live {goat, hacker, name} (:3787) |
mm | board, telemetry, gallery | open game in #frame (:3767, :3773) |
mm | play, role, official, me, passes, buyPass, arc*, giveCoins … | Game Studio only (studioSrc, :3121) |
ai | isGoat, aiAllowed, abort, ordie, judge, sample, ordieImg, ordieScore | behind the AI gate (§5) |
has | db, user, room, sample | has.sample is false unless the AI gate passes (:3668) |
user | id, isOwner | |
db | dset, dget, dsub, qget, qsub | per-game scopes (§6) |
room | join, presence, emit, leave, peerssub, onsub | §7 |
Error codes (one list, MM.errors in the SDK): unsupported not_granted invalid_argument guest offline busy, plus the hub's
rate_limited timeout too_big player_changed unavailable bad_replyai_off (:3046).
Handshake. MM.caps() sends {cap:"sys",op:"hello",v:2,…}; a v2 host answers its capability map; an older
host's answer (not_granted, invalid_argument, nothing) makes the SDK derive the map from v1 mm.ready
(SDK header, "v2"). v1 ops are never removed: published games and Code Editor versions cannot be recompiled
(decision 6).
sequenceDiagram
participant G as Game (iframe #frame)
participant S as mm-sdk.js
participant H as Hub (index.html)
participant D as db (window.claude)
G->>S: MM.ready({game, legacy})
S->>H: {cap:"mm",op:"ready",v:1,game}
H-->>S: {player, version, game, quest, cloud}
S->>H: {cap:"mm",op:"save",action:"get",pid}
H->>D: read data/users/<uid>/sdk~<game>~<pid>
H-->>S: {d:{key:{v,t}}}
S-->>G: resolves (always) → game boots
G->>S: MM.data.setItem / store.save()
Note over S: debounce 2 s, or at once on hide/pagehide
S->>H: {op:"save",action:"put",pid,d}
H->>D: sdkSerial(path): merge newest-per-key, write
H-->>S: {d:merged}
H--)S: {event:"player"} (another player signed in)
S-->>G: MM.on("player") — MM.data already reloaded3. SDK v2 saves
From the mm-sdk.js header (version 2.0.0, CLOUD_MAX=100000, MAX_KEYS=500, DEBOUNCE=2000 at :99):
| Where | Rule | |
|---|---|---|
| Local copy | localStorage["mmsdk~<game>~<playerId|guest>"] (+ .bak) | always |
| Cloud copy | data/users/<uid>/sdk~<game>~<playerId> | signed-in hub players only; ≤ 100 KB per game per player, above that device-only |
| Merge | newest write per key wins; removals are tombstones | timestamps come from device clocks |
| Write timing | 2 s after the last change; at once on hide/close | MM.data.flush() forces it |
MM.store(key, defaults, migrate): one JSON save object under MM.data[key]. Load = key → key.bak
(recovered) → defaults; migrate runs on a copy; nothing is written before the real save loads; write-if-changed,
previous good copy to .bak; on a player switch the store refills with the new player's save before the game'sMM.on("player") runs, and queued writes of the old player are dropped. A throwing migrate sets store.blocked
and refuses writes (use it for "newer version" saves). store.legacy / store.restoreLegacy() offer an old pre-SDK
save that could not be migrated automatically; only an explicit player action restores it. opts.merge is not
supported: the operator chose newest-wins after five Codex rounds on device merge (decision 23).
Limits to design around: one big key means two devices playing at once keep only the latest copy; split saves into
several keys if that matters. A cached old page may run SDK v1: load mm-store-v1.js after the SDK.
4. Accounts and roles
flowchart LR
C["Claude account (uid)"] --> P["Master Mind player (name)<br/>mm-player, mm-session {sid, uid, pw}"]
P --> R{role}
R -->|"uid ∈ GOAT_UIDS + GOAT name + password verified on this uid + current sid"| GOAT["👑 GOAT: moderation, approvals, AI"]
R -->|"HACKER name + owner check + current sid"| HK["💻 hacker: AI, no coins/moderation"]
R -->|"pass bought from wallet"| VIP["👑 VIP / 💎 Premium: cosmetics"]
R --> N["player: no AI (friend judge)"]
C -.->|"not signed in"| GU["guest: device-only saves"]- GOAT:
GOAT_UIDShard-coded list (MM/index.html:1187),isGoat()(:1200). Player records (hub list, Fish It
list, cache) never grant GOAT (decision 34). Hacker: isHacker() (:1214).
- Sessions are per tab:
mm-sessioncarries a randomsidthis tab created (newSid,sessCurat:1196-1197);
another tab's login revokes this tab until it signs in again (storage listener, :1208).
- Games never decide roles. They ask
mm.authzfor this tab's live answer. The old sharedmm-adminmarker is
written only while isGoat() (adminMarkSync, :1202) and must not be trusted by new games
(06-security.md (06-security.md) L5).
- In-game accounts and passwords are retired; hub identity only, sub-profiles if needed (decision 4).
- Passes: the hub sets
localStorage["mm-pass"]={tier,until}and answersmm.pass; games read it through
MM.rewards (cosmetic only) (MM/docs/PHASE2-PROFILES-PASSES-CODES.md "Pass mechanics").
5. The AI gate
flowchart LR
Q["game asks ai.ordie / judge / ordieImg / ordieScore<br/>or has.sample"] --> A{"aiAllowed()<br/>= goatStrict() || hackerStrict()<br/>try/catch → false"}
A -->|false| OFF["{code:'ai_off'} / has:false → friend judge"]
A -->|true| L{"same player generation<br/>after every await?"}
L -->|no| PC["player_changed"]
L -->|yes| M["model call, reply cleaned;<br/>malformed → bad_reply (never 0 points)"]- Decision 27: all AI is for the GOAT and the hacker only; everyone else gets the no-AI version. Fail closed
(aiAllowed, MM/index.html:3045; roleStrict re-checks playerGen after awaits, :3035).
- A malformed AI reply never costs a life; the game falls back to the friend judge (decision 17;
hub-pl21-test.mjs). - Error messages to games come from a fixed table (
AI_ERR_MSG/aiErr,:3099-3102), never raw provider text. - Pictures for the picture judge are passed to the model and dropped: never stored, logged, cached or counted
(comment above ordieImg, :3682).
6. db access and db rules
- Games reach the database only through the bridge
dbcap.bridgeVerdict(game, op, path, uid, enforce)
(MM/index.html:3212):
- dset to a hub-owned collection (BRIDGE_HUB_COLS, :3190) or hub-owned user doc (BRIDGE_HUB_USER, :3195:
wallet~, quests~, sdk~, …) → not_granted.
- Any other dset must match the game's BRIDGE_WRITES globs (:3201); enforcement is on (BRIDGE_ENFORCE=true, :3189).
- Private docs (password hashes, locks, telemetry) cannot even be read (BRIDGE_PRIVATE, :3197).
- This is a guard rail, not a wall: first-party games are same-origin with the hub (decision 13). Real protection
for shared collections is the artifact's db rules.
- db rules are not in the repo. They are part of the published artifact's capability config. Proposed rules are
in MM/docs/PHASE2-PROFILES-PASSES-CODES.md §"New db rules"; the live rules are snapshotted read-only in
db-rules-snapshot.md (db-rules-snapshot.md) (pattern: read view, write owner; per-player
doc {self} write interact). Every db rule change is an explicit operator decision. Claims that "someone else
can overwrite X" are inference until the live rules are read (FINAL… §8).
- Writes that two pages can race go through
sdkSerial(path, fn)(:3153) within a page and a db lease
(leaseOn) across pages (wallet, boards, gallery; tested by ci/hub-f1-test.mjs).
7. Rooms (party play)
- Bridge cap
room(ROOM_OPS,:3401). The hub registers rooms per window + generation, so a late leave from a
closed game cannot end the new game's room (phase0-test.mjs C47-C49).
- Rules: sender identity comes from presence, never from the payload; preset phrases only, no free text
(decisions 2, 31); every party has a device-only fallback.
- Decision 18: a room latency spike test comes before building live rooms (overridden for The Smiling Dark only,
decision 31). Status: not run yet (open, operator 2026-10-19). Do not build live rooms for other games until it has run.
8. Code Editor overrides
A game's live code can come from the database instead of its file.
flowchart LR
P["Play game g"] --> W["await codeReady (≤ 4 s)"]
W --> M{"code/<fileKey>.cur ≥ 0?"}
M -->|no| F["#frame.src = games/<id>/index.html"]
M -->|yes| C["read codechunks/<fileKey>_v<v>_0..n-1 (field t)"]
C --> S["#frame.srcdoc = withBase(code) — <base href> injected after <head>"]- Meta doc
code/<fileKey>={versions:[{v,at,by,n}], cur}; **curis an index intoversions, not a version
number** (codeMeta, MM/index.html:1276; liveCode, :1287; readChunks, :1280; CHUNK=100000, :1275).
- The in-hub Code Editor publishes by writing chunks, appending a version and setting
curto the last index; Revert
decrements cur (:1325-1330).
- Today's override games: Cube Dash (
cubedash) and Storm Royale (storm) (MM/ci/overrides.json). A file-only
publish does not change what players get; ship a new db version. Decision 12: fold them back into files.
- When Claude writes a version through
ArtifactData, it pins the meta doc's db version (if_version) and reads
the chunks back; that pin belongs to the db write tool, not to the hub code (the hub's setPointer at :1324 is a
plain set).
9. Offline app
MM/offline/:offline-shell.html(a local host),offline-bridge.js(answers the same bridge ops from the
device; its OPS list is the contract), offline-sync.js (save-file export/import, newest per key), sw.js.
- Network-only ops answer
unsupported(decision 6), so games fall back (for AI: friend judge). - Build:
node offline/build-offline.mjs→offline/dist/. Test:node ci/offline-test.mjs(op coverage against
every game and hub op, merge rules, real offline play, sweep of every game).
- Hosting and app stores are operator-only (decision 19). The in-hub "Import saves" control was removed
(ci/hub-addons-test.mjs I1-I2).
10. What a new game must and must not do
| Must | Must not |
|---|---|
boot from MM.ready, save through MM.store/MM.data | write localStorage progress keys directly |
read the player from MM.player, react to MM.on("player") | read mm-player / mm-admin to decide anything |
ask mm.authz for live role answers | keep its own accounts or passwords |
use MM.rewards for passes (cosmetic) | sell MM coins or passes, or link out |
send MM.event for quests the hub knows | send raw {__mm:1} messages outside _sdk/_kit/_ordie |