Make the UI transport-agnostic: one controller surface + one state snapshot for offline-AI, offline-human, and online play
To add online multiplayer to an app that already had a local game screen WITHOUT rewriting the screen: define ONE controller surface (subscribe/getState/play/pass/resign/chat/destroy/ready/coordOf) and ONE canonical state-snapshot shape, then provide TWO implementations behind it — the existing in-process controller (drives the local engine + AI) and a remote-controller backed by a WebSocket room. The server is authoritative and emits the SAME snapshot shape the offline controller already produced, so the screen renders identically and never branches on transport; it just picks the factory by mode (activeGame.gameId present => remote, else local). Key seams that made it work: (1) do NOT serialize engine objects over the wire — send the move list and rebuild the immutable Position client-side from the pure engine, so getState().position is real for the renderer. (2) Put per-client view bits on the client (e.g. chat carries byUserId on the wire; the client derives mine = byUserId===me), keep the wire snapshot identity-neutral. (3) Features that are local-only (undo vs an AI) become no-ops in the remote controller rather than protocol messages. (4) Freeze the snapshot keys in a shared contracts doc; every server layer and the client code to that, not to each other. This kept the diff to the existing screen tiny and let offline play keep working with the server down.