Skip to main content
Mixed runtime. The plugin definition (./ entry) is worker-safe. The React widget (./react entry) is browser-only. State lives at G.plugins.chat once composed via withPlugins(baseDef, [chatPlugin]). Every seated player can send messages regardless of whose turn it is.

Install

Compose into your game

The plugin contributes:
  • A G.plugins.chat: ChatSlice slice initialized to { messages: [] }.
  • A namespaced chat__send move dispatchable by every seated player.
  • Auto-merge of the chat slice into both views.public and views.player (when declared) so clients receive history without extra wiring.

Server-side surface

chatPlugin

The plugin definition. Pass it to withPlugins. Plugin id is "chat" (CHAT_PLUGIN_ID).

chat__send move

Validation, applied server-side:
  • Empty / whitespace-only text rejects with reason: "empty_message".
  • text is trimmed and capped to MAX_MESSAGE_LENGTH (500 chars).
  • displayName is trimmed and capped to MAX_DISPLAY_NAME_LENGTH (40 chars). Empty falls back to Player {playerID}.
  • History is capped to MAX_HISTORY (200 messages); older entries fall off the front.
Each accepted message lands on G.plugins.chat.messages as:

Constants

Types

  • ChatMessage{ authorPlayerID, authorDisplayName, text }.
  • ChatSlice{ messages: readonly ChatMessage[] }.
  • ChatSendArgs{ text: string; displayName: string }.
  • ChatPluginEvent"chat__send".

React widget — ./react

<ChatBubble /> renders a floating chat bubble in the bottom-right corner via a portal. It auto-hides until the match is live (when room.game is null, e.g. in lobby), so you can mount it once outside any lobby/game branch and let it persist across the transition.

Props

The component is structural over the room shape — it accepts any object that exposes userID, userName, and game.{ snapshot, dispatch, playerID }. It does not import @openturn/react directly, so the package stays free of UI plumbing dependencies.

Display name resolution

The bubble picks the first available, non-generic value:
  1. Explicit displayName prop.
  2. room.userName.
  3. Player {userID slice} from room.userID.
  4. Player {playerID} (or Spectator when no seat).
Generic placeholders like "anonymous" are skipped.

Behavior

  • Sending: Enter to submit (Shift+Enter is reserved for IME composition; the input is intentionally not a <form> to survive sandboxed iframes that omit allow-forms).
  • Spectators (playerID === null) can read but not send.
  • Unread badge: messages received while the panel is closed bump a counter; opening the panel marks them seen.
  • Auto-scroll on new messages; auto-focus the input on open.
  • Server rejections surface as inline error text under the composer (humanizeChatError maps empty_message, inactive_player, not_connected to user-friendly copy).

Styling

The widget uses inline styles only — no Tailwind, CSS modules, or stylesheet imports. This keeps the package drop-in for any host shell. The portal mounts on document.body by default with a high z-index (2147483000) so it floats above app chrome.

Example

examples/plugins/tic-tac-toe-with-chat — the canonical end-to-end example. Game is composed with withPlugins; the app mounts <ChatBubble /> outside the HostedRoom switch so it persists across the lobby → game transition.

See also