feat: M0 harness scaffold — TypeScript online multiplayer#1
feat: M0 harness scaffold — TypeScript online multiplayer#1yichenhuang2000 wants to merge 15 commits into
Conversation
Replaces the OpenClaw + Discord runtime direction with a transport- agnostic engine (Node/Fastify) speaking zod-typed JSON events over WebSocket to a React web room client. Covers protocol, turn loop, code/prompt split, rooms/identity/persistence, content migration, testing, and milestones. Design was adversarially reviewed by a multi-agent workflow; adjudicated decisions recorded in §9. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A multi-agent gap analysis inventoried every runtime service OpenClaw (and macOS launchd beneath it) provided the GM deployment, then cross-checked the spec for silently-borrowed plumbing. Headline: the GM was never actually played over Discord — all live testing ran through OpenClaw's gateway/control surface (sessions.reset used 13x in 3 days), which had no successor in the spec. Player-facing transport was already well covered. The blind spot was the operator/process boundary. Eight amendments, all M0-M1, none touching the adjudicated architecture: - §2: systemd supervision, journald log rotation, SIGTERM drain, /healthz - §3.3: spend gate on POST /api/rooms (invite code + per-IP cap); gm.stop reserved - §4.4: block-10 session meta defined to inject clock + idle-elapsed - §4.6: per-turn wall-clock timeout + max_tokens (wedged-stream safety net) - §5.2: WS ping/pong dead-socket detection (presence + eviction both key on it) - §5.5 (new): operator surface — admin inspect/evict routes, per-turn LLM debug capture, conversation-reset verb - §8: M0/M1 acceptance criteria updated - §10: rate-limit seam covers both ingress surfaces; away-player + gm.stop seams Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…st/snapshot/chat)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4aaeaa1bfa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ws.send("room.join", roomCode.trim(), { | ||
| roomCode: roomCode.trim(), | ||
| displayName: joinName.trim(), | ||
| }); |
There was a problem hiding this comment.
Persist join credentials before retrying room.join
After a reload or tab close, the only join path is this handler, but it never reads a stored token nor includes playerToken. The server treats a join without a valid token as a new seat and rejects the same durable display name with NAME_TAKEN, so a returning player, especially a host after refreshing, cannot resume their seat and may strand the room. Store the token returned by room.joined/create and include it here when the room code is reused.
Useful? React with 👍 / 👎.
| const p = env.payload as { players: Player[]; chat: ChatMessage[] }; | ||
| set({ players: p.players ?? [], chat: p.chat ?? [] }); |
There was a problem hiding this comment.
Hydrate chat from the snapshot transcript
The room snapshot schema/server payload uses transcript, not chat, so this path always resets store.chat to [] on join or reconnect. Any rehydrated room with existing messages will display an empty chat log until new messages arrive, which breaks the snapshot recovery path. Read p.transcript here instead of p.chat.
Useful? React with 👍 / 👎.
| room.unsubscribe(conn.playerId!, conn.socket); | ||
| room.broadcast("presence.update", { players: room.publicPlayers() }); |
There was a problem hiding this comment.
Clear the connection after room.leave
When a client sends room.leave, this only removes the socket from the room subscription set; conn.playerId and conn.roomCode remain populated. Subsequent chat.send or room.close messages on the same WebSocket still pass joinedRoom(), so a player who has left and appears offline can continue chatting or, if host, close the table without rejoining. Clear the connection state after broadcasting presence.
Useful? React with 👍 / 👎.
| conn.playerId = seat.playerId; | ||
| conn.roomCode = room.code; |
There was a problem hiding this comment.
Drop the prior subscription before rejoining a socket
If room.join is received on a socket that is already joined, these assignments overwrite the connection without unsubscribing it from the previous seat/room. The old room keeps the same socket in its subscription map, so the client can continue receiving broadcasts from the prior room and the prior seat can remain falsely online after close because handleClose only removes the latest conn values. Reject a second join or unsubscribe the previous membership before overwriting it.
Useful? React with 👍 / 👎.
Summary
Implements the M0 milestone of the self-built TypeScript harness, replacing the OpenClaw/Discord dependency with a direct HTTP + WebSocket server and web client.
Packages
@gm/protocolv/id/type/room/ts/payload), C2S/S2C event catalogue, error codes@gm/server@gm/webKey design decisions
data/tables/<code>.jsonvia atomicwriteAtomic(tmp + rename)phase: "ended"to block re-entryVerification
Commits (13)
Design spec reference
docs/superpowers/specs/2026-06-12-online-harness-design.md