Skip to content

feat: M0 harness scaffold — TypeScript online multiplayer#1

Open
yichenhuang2000 wants to merge 15 commits into
mainfrom
feat/m0-harness-scaffold
Open

feat: M0 harness scaffold — TypeScript online multiplayer#1
yichenhuang2000 wants to merge 15 commits into
mainfrom
feat/m0-harness-scaffold

Conversation

@yichenhuang2000

Copy link
Copy Markdown
Owner

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

Package Description
@gm/protocol Zod v4 envelope schema (v/id/type/room/ts/payload), C2S/S2C event catalogue, error codes
@gm/server Fastify 5 HTTP + WebSocket gateway, atomic file store, Room actor + RoomRegistry, account-less identity (token mint/hash/verify), heartbeat dead-socket detection, idle eviction
@gm/web Vite 6 + React 19 + zustand store, WS/API clients, Lobby (create/join table) + RoomView (players list, chat, close)

Key design decisions

  • Envelope protocol: additive-evolution rule — unknown event types silently ignored, not errors
  • Persistence: room state in data/tables/<code>.json via atomic writeAtomic (tmp + rename)
  • Identity: host gets token on room creation; guests get token on first join; both can resume after disconnect
  • Heartbeat: server pings every 30s, terminates after 2 unanswered pongs
  • Idle eviction: rooms with no active sockets swept after 15min; ended rooms persisted with phase: "ended" to block re-entry

Verification

  • Typecheck: 3/3 packages pass
  • Tests: 60/60 pass (protocol 14 + server 46 incl. 4 real HTTP+WS e2e)
  • Web build: production bundle 272 KB (82 KB gzip)

Commits (13)

fde55c2 chore: scaffold pnpm/typescript monorepo for online harness
ee2c3a4 feat(protocol): envelope schema + makeEnvelope/parseEnvelope
b205103 feat(protocol): M0 event catalogue, payload schemas, error codes
a04cff9 feat(server): config, app skeleton, /healthz, entry with SIGTERM drain
9fc25d9 feat(server): atomic writes + table file store
d9f76d8 feat(server): account-less identity (token mint/hash/verify + roster helpers)
76d06bc feat(server): room code generator + in-memory Room actor
c29bfcf feat(server): RoomRegistry create/rehydrate/evict + idle sweep + peek
83a2089 feat(server): POST /api/rooms + healthz live-room count
5796e38 feat(server): WS envelope dispatch + heartbeat detector
51cc5c3 feat(server): Fastify WS gateway + M0 e2e acceptance
2d53e3e feat(web): Vite + React + zustand store + WS/API clients + Lobby/RoomView UI
4aaeaa1 chore: M0 gate — 60 tests green, typecheck pass, web build pass

Design spec reference

docs/superpowers/specs/2026-06-12-online-harness-design.md

yichenhuang21 and others added 15 commits June 12, 2026 15:09
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +45 to +48
ws.send("room.join", roomCode.trim(), {
roomCode: roomCode.trim(),
displayName: joinName.trim(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread packages/web/src/store.ts
Comment on lines +95 to +96
const p = env.payload as { players: Player[]; chat: ChatMessage[] };
set({ players: p.players ?? [], chat: p.chat ?? [] });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +129 to +130
room.unsubscribe(conn.playerId!, conn.socket);
room.broadcast("presence.update", { players: room.publicPlayers() });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +102 to +103
conn.playerId = seat.playerId;
conn.roomCode = room.code;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants