Mastering MindbricksRealtime Hub

Realtime Hub Pattern

A comprehensive guide to designing bidirectional real-time communication hubs in Mindbricks. Covers Socket.IO rooms, built-in message types, custom message types, standard events, auto-bridged DataObject events, Kafka bridging, message persistence, room authorization, guardrails, server-side logic hooks (message interception, state management, scheduled actions), and REST fallback endpoints.

Realtime Hub Pattern

Overview

A RealtimeHub is a bidirectional Socket.IO communication channel that brings real-time messaging, presence, and custom events to your Mindbricks service. Unlike the older Realtime Service (a standalone microservice that bridges Kafka events to clients via topics), RealtimeHub lives inside a business service and provides room-based, full-duplex communication with built-in message types, automatic persistence, and rate limiting.

CapabilityRealtime ServiceRealtimeHub
TransportSocket.IO (one-way push)Socket.IO (bidirectional)
ScopeStandalone microserviceInside any business service
ModelTopic + rights-token authorizationRoom + membership/ownership auth
Message flowKafka → Server → ClientClient ↔ Server ↔ Client
PersistenceNot built-inAlways-on auto-generated Message DataObject
Message typesN/ABuilt-in (text, image, video, ...) + custom types
Standard eventsNoYes (typing, read receipts, presence, etc.)
Auto-bridgeCore functionAuto-bridges CRUD events from known DataObjects
REST fallbackNoAuto-generated REST endpoints

Use RealtimeHub when: Users need to talk to each other in real time -- chat rooms, multiplayer games, collaborative editing, live dashboards, support tickets, auction bidding.

Use Realtime Service when: You only need one-way server-to-client push of backend events (e.g., order status notifications, live price feeds).


Architecture

Each RealtimeHub is mounted as a Socket.IO namespace (/hub/{hubName}) on the service's HTTP server. Rooms within the namespace correspond to records in an existing DataObject (e.g., a Chat, Match, or Dashboard). The framework handles:

  1. Authentication -- Token-based auth middleware validates every socket connection via the existing session layer (HexaAuth).

  2. Room authorization -- A configurable pipeline of role checks, script evaluations, and DataObject-based auth sources determines who can join each room and what hub role they receive.

  3. Message types -- Built-in types (text, image, video, audio, document, sticker, contact, location, system) with known schemas, plus designer-defined custom message types for app-specific structured data.

  4. Persistence -- Every hub auto-generates a Message DataObject. Messages are always persisted with system fields, a messageType discriminator, a content JSONB column, and denormalized sender identity (senderName, senderAvatar) for fast history rendering.

  5. Standard events -- Typing indicators, recording indicators, read receipts, delivery receipts, and presence are built-in toggles.

  6. Auto-bridge -- CRUD events from the room, membership, and message DataObjects are automatically bridged to connected clients via Kafka.

  7. Custom events -- Named signals beyond messages for app-specific needs (game actions, cursor tracking).

  8. External Kafka bridge -- Events from unrelated Kafka topics can be bridged to hub rooms.

  9. REST endpoints -- Auto-generated REST routes for message history, deletion, and REST-based message sending.

  10. Horizontal scaling -- Redis adapter for multi-instance deployments.


Pattern Structure

A RealtimeHub is a chapter in the Mindbricks ontology with 7 sections:

SectionPurpose
hubBasicsName and description
roomSettingsRoom DataObject, authorization flow, auth sources, auth scripts
hubRolesRole definitions with granular permissions (read, send, moderate, etc.)
messageSettingsBuilt-in message types, custom message types, cross-cutting features
eventSettingsStandard events, auto-bridge, custom events, external Kafka events
historySettingsMessage history on join
guardrailsRate limits, size limits, connection limits, moderation settings

Additionally, the service-level realtimeConfig section (inside ServiceSettings) controls the Socket.IO server itself (adapter, heartbeat, connection limits).


Service-Level Configuration: RealtimeConfig

Before designing individual hubs, configure the Socket.IO server at the service level:

{
  "serviceSettings": {
    "realtimeConfig": {
      "adapter": "redis",
      "heartbeatInterval": 25000,
      "heartbeatTimeout": 20000,
      "maxConnectionsPerUser": 10
    }
  }
}
PropertyTypeDefaultDescription
adapter"redis" | "memory""redis"Scaling adapter. Use redis for production (multi-instance), memory for development.
heartbeatIntervalInteger25000Ping interval in ms.
heartbeatTimeoutInteger20000Pong timeout in ms. Must be less than interval.
maxConnectionsPerUserInteger10Max concurrent sockets per authenticated user.

When adapter is redis, the generated package.json includes @socket.io/redis-adapter and the socket server initializer sets up the Redis pub/sub adapter automatically.


Hub Basics

{
  "hubBasics": {
    "name": "chatHub",
    "description": "Real-time messaging hub for one-on-one and group conversations"
  }
}
PropertyTypeRequiredDescription
nameStringYesUnique identifier (camelCase). Becomes the namespace /hub/chatHub and REST prefix /chat-hub.
descriptionTextNoHuman-readable purpose. Used in docs and AI context.

Room Settings

Every hub requires a room DataObject -- an existing DataObject in the same service that represents the "room" entity. Users join rooms by referencing the DataObject record ID. The room settings define the authorization pipeline that decides who can join a room and what role they receive.

{
  "roomSettings": {
    "roomDataObject": "catalogEvent",
    "roomEligibility": "catalogEvent.chatEnabled == true",
    "absoluteRoles": ["superAdmin"],
    "checkRoles": ["premiumUser", "subscriber"],
    "checkScript": "user.city == catalogEvent.city",
    "authSources": [
      { "name": "eventOwner", "sourceObject": "catalogEvent", "userField": "createdBy", "roomField": "id", "hubRole": "owner" },
      { "name": "eventHost", "sourceObject": "eventHost", "userField": "userId", "roomField": "eventId", "hubRole": "moderator" },
      { "name": "ticketHolder", "sourceObject": "issuedTicket", "userField": "ownerUserId", "roomField": "eventId", "condition": "issuedTicket.status == 'active'", "hubRole": "attendee" }
    ],
    "authScripts": [
      { "name": "vipAccess", "script": "lib.checkVIPAccess(user, room)", "hubRole": "viewer" }
    ]
  }
}

Properties

PropertyTypeDefaultDescription
roomDataObjectLocalDataObjectName--DataObject representing rooms in this hub. Selected from the service's DataObject list.
roomEligibilityMScriptnullCondition evaluated against the room record. The context variable name matches the roomDataObject name (e.g., catalogEvent.chatEnabled). The generic alias room is also available. If false, nobody can join (including absoluteRoles).
absoluteRolesString[][]List of role names that bypass all auth checks. Users with a matching role get the built-in system role with full permissions.
checkRolesString[][]List of role names REQUIRED to proceed. Users without any of these roles are denied immediately.
checkScriptMScriptnullCondition with user + roomDataObject name context (e.g., catalogEvent). Generic alias room is also available. Must return true to proceed to auth sources.
authSourcesArray[]Ordered DataObject-based auth sources. First match wins -- put highest-privilege sources first.
authScriptsArray[]Ordered MScript-based auth sources. Checked after authSources.

Auth Source Properties

Each entry in authSources queries an Elasticsearch-indexed DataObject to determine whether the user has a relationship with the room.

PropertyTypeDescription
nameStringUnique identifier for this auth source. Used in logs and documentation.
sourceObjectDataObjectNameDataObject to query in Elasticsearch. Selected from the project's DataObject list.
userFieldPropReferProperty on the sourceObject matching the user's ID. Selected from sourceObject's property list.
roomFieldPropReferProperty on the sourceObject matching the room's ID. Selected from sourceObject's property list.
conditionMScriptOptional condition evaluated on the found record. The context variable name matches the sourceObject name (e.g., issuedTicket). The generic alias record is also available. Example: issuedTicket.status == 'active'.
hubRoleStringOptional. Hub role assigned to the user if this source matches. If null, the user is authorized with a default member role (read + send, no moderation). If provided, must reference a role defined in hubRoles.

Auth Script Properties

Each entry in authScripts runs an MScript expression to determine authorization. Checked after all authSources have been tried.

PropertyTypeDescription
nameStringUnique identifier for this auth script. Used in logs and documentation.
scriptMScriptExpression with user + room context. Can call library functions via lib.*. Must return true for the source to match.
hubRoleStringOptional. Hub role assigned to the user if the script returns true. If null, the user is authorized with a default member role. If provided, must reference a role defined in hubRoles.

Authorization Flow

The framework evaluates authorization as a top-to-bottom pipeline. The first matching rule wins.

 1. Fetch room record from Elasticsearch
 2. Room not found → DENY
 3. roomEligibility → eval({ roomDataObject, room }) → false → DENY (blocks everyone)
 4. Check if user is BLOCKED (Redis/ES) → DENY
 5. absoluteRoles → user.roleId in list → ALLOW with "system" role
 6. checkRoles → not in list → DENY
 7. checkScript → false → DENY
 8. authSources top-to-bottom → first match → ALLOW with hubRole (or default "member") → BREAK
 9. authScripts top-to-bottom → first match → ALLOW with hubRole (or default "member") → BREAK
10. No match → DENY

Cross-Service Auth Sources

Auth source queries use Elasticsearch rather than direct database queries. This allows the DataObjects referenced in authSources to live in a different service -- as long as they are indexed in Elasticsearch (which is the default for all DataObjects), the hub can query them across service boundaries. The Elasticsearch index name is automatically derived from the project codename and the DataObject name.


Hub Roles

Hub roles define what each user can do within a room. Roles are assigned by authSources and authScripts during the authorization flow. A built-in system role (all permissions enabled) is automatically assigned to users who match absoluteRoles.

If hubRoles is empty and auth sources/scripts use null for hubRole, all authorized members receive a default member role with read + send permissions, no moderation. This is the simplest configuration for hubs that do not need differentiated permissions.

{
  "hubRoles": [
    { "name": "owner", "canRead": true, "canSend": true, "canModerate": true, "canDeleteAny": true, "canManageRoom": true },
    { "name": "moderator", "canRead": true, "canSend": true, "canModerate": true, "canDeleteAny": true },
    { "name": "attendee", "canRead": true, "canSend": true, "allowedMessageTypes": "text,image", "moderated": true },
    { "name": "viewer", "canRead": true, "canSend": false }
  ]
}

Permission Fields

PermissionDefaultDescription
name--Role identifier referenced by authSources / authScripts.
canReadtrueCan receive messages.
canSendtrueCan send messages.
allowedMessageTypesallComma-separated message types this role can send. If omitted, all enabled types are allowed.
moderatedfalseMessages from this role require moderator approval before broadcast.
canModeratefalseCan approve/reject pending messages, block/silence users.
canDeleteAnyfalseCan delete any message in the room.
canManageRoomfalseCan update room settings, kick users.

Block, Silence & Talk Permission

Runtime moderation actions stored in an auto-generated HubModeration DataObject and cached in Redis for fast lookups.

Block & Silence

ActionEffectPersistence
BlockUser cannot enter the room. If currently connected, they are force-removed.Elasticsearch + Redis cache
SilenceUser can read messages but cannot send.Elasticsearch + Redis cache

Moderation Events

EventDirectionWhoPayload
hub:blockClient → ServercanModerate{ roomId, userId, reason?, duration? }
hub:unblockClient → ServercanModerate{ roomId, userId }
hub:silenceClient → ServercanModerate{ roomId, userId, reason?, duration? }
hub:unsilenceClient → ServercanModerate{ roomId, userId }
hub:blockedServer → User--{ roomId, reason } (then force-leave)
hub:unblockedServer → User--{ roomId } (user may rejoin)
hub:silencedServer → User--{ roomId, reason }
hub:unsilencedServer → User--{ roomId }

Duration: 0 or null = permanent. A positive value = seconds until automatic expiry.

Default Silenced Mode

When guardrails.defaultSilenced is true, all users join rooms in a silenced state. Users with canModerate permission are exempt. Others must explicitly request speak permission from a moderator.

EventDirectionPayload
hub:requestSpeakClient → Server{ roomId }
hub:speakRequestedServer → Moderators{ roomId, userId }
hub:grantSpeakModerator → Server{ roomId, userId }
hub:revokeSpeakModerator → Server{ roomId, userId }
hub:speakGrantedServer → User{ roomId }
hub:speakRevokedServer → User{ roomId }

Message Moderation

When a role has moderated: true or guardrails.globalModeration is true, messages are saved with a status of "pending" instead of "approved". Only users with canModerate permission see pending messages. Moderators approve or reject pending messages:

EventDirectionPayload
hub:approveModerator → Server{ roomId, messageId }
hub:rejectModerator → Server{ roomId, messageId, reason? }
hub:messagePendingServer → Sender + Moderators{ roomId, messageId, sender?, message? }
hub:messageRejectedServer → Room{ roomId, messageId, reason? }

Approved messages are broadcast to the room as hub:messageArrived.


Message Settings

Built-in Message Types

Every hub selects which built-in message types to support. Each type has a known content schema managed by the framework -- no manual field definitions needed.

{
  "messageSettings": {
    "dataObjectName": "ChatHubMessage",
    "messageTypes": ["text", "image", "video", "audio", "document", "location"],
    "enableReplyTo": true,
    "enableReaction": true,
    "enableForwarded": true
  }
}
TypeContent SchemaDescription
text{ body }Plain text message
image{ mediaUrl, thumbnail, caption, width, height }Image with optional caption
video{ mediaUrl, thumbnail, caption, duration }Video with preview
audio{ mediaUrl, duration, waveform }Voice message or audio clip
document{ mediaUrl, fileName, fileSize, mimeType }File attachment
sticker{ stickerUrl, stickerPackId }Sticker image
contact{ contactName, contactPhone, contactUserId }Shared contact card
location{ lat, lng, address, label }Location pin
system{ systemAction, systemData }Auto-generated system message (member joined, room renamed, etc.)

Auto-Generated Message DataObject

Every hub generates a Message DataObject with these columns:

ColumnTypeDescription
idIDPrimary key
roomIdIDForeign key to the room DataObject
senderIdIDForeign key to the authenticated user
messageTypeEnumDiscriminator: all built-in + custom type names
contentJSONBType-specific payload (schema varies by messageType)
timestampDateTimeMessage creation time
replyToJSON(if enabled) Reply reference { id, preview }
reactionJSON(if enabled) Reactions [{ emoji, userId, timestamp }]
forwardedBoolean(if enabled) Whether message was forwarded

Cross-Cutting Features

These apply to all message types:

ToggleField AddedDescription
enableReplyToreplyTo (JSON)Any message can quote another
enableReactionreaction (JSON)Any message can have emoji reactions
enableForwardedforwarded (Boolean)Any message can be marked as forwarded

Sender Identity in Messages

Every message persisted by the hub includes denormalized sender identity fields -- senderName and senderAvatar -- populated automatically from the user's session at send time. This eliminates the need for frontend clients to make separate user profile lookups when rendering chat history or incoming messages.

Real-time events (hub:messageArrived, hub:messagePending) include a sender envelope with { id, fullname, avatar }.

Persisted messages (history and REST responses) include senderName and senderAvatar directly on the message record.

The hub:presence event also includes fullname and avatar on the user object, so the frontend can display who joined or left with their display name and profile picture.

Custom Message Types

For app-specific structured data that doesn't fit the built-in types, define custom message types. Each custom type has a name, description, and field schema. The name is added to the messageType enum alongside built-in types. The fields are validated at runtime and stored in the content JSONB column.

{
  "messageSettings": {
    "dataObjectName": "GameChatMessage",
    "messageTypes": ["text", "sticker"],
    "customMessageTypes": [
      {
        "name": "chessMove",
        "description": "A chess piece move with board coordinates",
        "fields": [
          { "name": "from", "fieldType": "String", "required": true, "description": "Source square (e.g. e2)" },
          { "name": "to", "fieldType": "String", "required": true, "description": "Target square (e.g. e4)" },
          { "name": "piece", "fieldType": "Enum", "required": true, "enumValues": "pawn,rook,knight,bishop,queen,king" },
          { "name": "captured", "fieldType": "Enum", "required": false, "enumValues": "pawn,rook,knight,bishop,queen" },
          { "name": "isCheck", "fieldType": "Boolean", "required": false, "defaultValue": "false" },
          { "name": "notation", "fieldType": "String", "required": false, "description": "Standard algebraic notation" }
        ]
      },
      {
        "name": "nudge",
        "description": "A visual buzz/shake signal",
        "fields": [
          { "name": "animation", "fieldType": "Enum", "required": false, "enumValues": "shake,bounce,flash", "defaultValue": "shake" }
        ]
      }
    ]
  }
}

The generated messageType enum becomes: text, sticker, chessMove, nudge.

Custom Field Types

TypeDescription
StringShort text (up to 255 chars)
TextLong-form text
IntegerWhole number
DecimalFloating point
BooleanTrue/false
IDUUID reference
DateTimeISO 8601 timestamp
EnumFixed set of values (define in enumValues)
JSONArbitrary JSON object or array

Event Settings

Standard Events

Standard events are built-in, toggle-able signal types that every messaging app needs. Enable them with a single boolean -- the framework generates the socket handlers, payload validation, and client protocol.

{
  "eventSettings": {
    "enableTypingIndicator": true,
    "enableRecordingIndicator": true,
    "enableReadReceipts": true,
    "enableDeliveryReceipts": true,
    "enablePresence": true,
    "autoBridgeDataEvents": true
  }
}
ToggleEvents GeneratedEphemeralDescription
enableTypingIndicatorhub:typing, hub:stopTypingYesUser is typing / stopped typing
enableRecordingIndicatorhub:recording, hub:stopRecordingYesUser is recording a voice message
enableReadReceiptshub:messageReadNoUser has read messages up to a timestamp (blue ticks)
enableDeliveryReceiptshub:messageDeliveredNoMessage was delivered to the recipient's device
enablePresencehub:online, hub:offline, hub:awayYesUser connection state changes

Auto-Bridged DataObject Events

When autoBridgeDataEvents is true (the default), the hub automatically subscribes to Kafka CRUD events from three DataObjects it already knows about:

From the Message DataObject:

CRUD EventHub EventDescription
{message}.updatedhub:messageEditedA message was edited via REST/API
{message}.deletedhub:messageDeletedA message was deleted via REST/API

From the Auth Source DataObjects (when using authSources):

CRUD EventHub EventDescription
{authSource}.createdhub:memberJoinedNew member added to the room
{authSource}.deletedhub:memberLeftMember removed from the room
{authSource}.updatedhub:memberUpdatedMember role or settings changed

From the Room DataObject:

CRUD EventHub EventDescription
{room}.updatedhub:roomUpdatedRoom name, icon, or settings changed
{room}.deletedhub:roomClosedRoom was deleted

If the system message type is enabled, auto-bridged events also generate system messages (e.g., "Alice added Bob", "Room name changed to General").

Zero configuration required. The framework derives topic names and filter expressions from the DataObject names.

Custom Events

For app-specific signals that don't fit standard events or message types:

{
  "eventSettings": {
    "customEvents": [
      {
        "name": "cursorMoved",
        "description": "User's cursor position in a collaborative editor",
        "ephemeral": true,
        "direction": "clientToRoom"
      }
    ]
  }
}

Event Directions

DirectionEmitterReceiverUse case
clientToRoomClient via socketAll room membersGame actions, cursor position
serverToClientServer logicSpecific clientPrivate notifications
serverToRoomServer logicAll room membersSystem alerts

External Kafka Events

For events from unrelated services or topics:

{
  "eventSettings": {
    "kafkaEvents": [
      {
        "name": "orderStatusChanged",
        "description": "Order status update from the commerce service",
        "topic": "order-events",
        "filterExpression": "data.type === 'status.changed'",
        "targetRoomExpression": "data.dashboardId"
      }
    ]
  }
}
PropertyTypeDescription
nameStringEvent name emitted to clients.
topicStringKafka topic to subscribe to.
filterExpressionMScriptFilter incoming messages. Receives data.
targetRoomExpressionMScriptExtract target room ID from the message.

History Settings

Controls whether message history is delivered to clients when they join a room.

{
  "historySettings": {
    "historyEnabled": true,
    "historyLimit": 50
  }
}
PropertyTypeDefaultDescription
historyEnabledBooleantrueSend recent messages on join.
historyLimitInteger50Number of most recent messages to send.

Guardrails

Safety limits to prevent abuse and resource exhaustion.

{
  "guardrails": {
    "maxUsersPerRoom": 1000,
    "maxRoomsPerUser": 50,
    "messageRateLimit": 60,
    "maxMessageSize": 65536,
    "connectionTimeout": 300000,
    "authCacheTTL": 300,
    "globalModeration": false,
    "defaultSilenced": false
  }
}
PropertyTypeDefaultDescription
maxUsersPerRoomInteger1000Max concurrent users per room.
maxRoomsPerUserInteger50Max rooms a user can be in simultaneously.
messageRateLimitInteger60Max messages per user per minute.
maxMessageSizeInteger65536Max message payload in bytes (64 KB).
connectionTimeoutInteger300000Idle timeout in ms (5 minutes).
authCacheTTLInteger300Redis auth cache TTL in seconds. 0 = caching disabled.
globalModerationBooleanfalseWhen true, all messages require moderator approval regardless of role.
defaultSilencedBooleanfalseWhen true, all users join rooms silenced and must request speak permission. canModerate users are exempt.

Hub Logic

HubLogic adds server-side processing hooks to a RealtimeHub. It allows intercepting custom message types with MScript before broadcast, delivering room state on join or on demand, and running scheduled actions on an interval. MScript hooks can call service library functions, Redis, DB, Business APIs, or external services -- the hub just provides the hook points and acts on the return values.

Pattern Structure

interface HubLogic = {
  stateScript : MScript;
  messageHandlers : HubMessageHandler[];
  scheduledActions : HubScheduledAction[];
}

interface HubMessageHandler = {
  messageType : String;
  script : MScript;
}

interface HubScheduledAction = {
  name : String;
  intervalMs : Integer;
  keepAlive : Boolean;
  script : MScript;
}

Message Handlers

When a client sends a custom message type that has a matching handler, the hub intercepts the message instead of broadcasting it immediately. The handler's MScript is called with:

Context VariableTypeDescription
sessionObjectSender's session (userId, roleId, fullname, avatar, tenantId)
messageObject{ type: "chessMove", content: { from: "e2", to: "e4" } }
roomIdStringThe room this message was sent in
roomObjectThe room DataObject record (fetched from Elasticsearch)
libObjectService library functions

The script must return an object:

{
  accept: true,                    // false to reject
  rejectReason: "Not your turn",   // sent to sender if rejected

  broadcast: true,                 // false to suppress the original message
  broadcastTo: "all",              // "all" | "others" | "sender" | ["userId-1", "userId-2"]
  modifiedContent: { ... },        // replace content before broadcast
  persist: true,                   // false to skip persistence

  serverMessages: [                // server-generated messages
    {
      type: "gameEvent",
      content: { event: "check" },
      to: "all",                   // "all" | "others" | "sender" | ["userId-1"]
      persist: true                // false to skip persistence
    }
  ]
}

Per-room sequential queue: Intercepted messages are queued per-room and processed one at a time. This prevents race conditions when two messages arrive simultaneously for the same room (critical for games, auctions, and collaborative editing).

Standard messages pass through: Only custom message types with a matching handler are intercepted. Built-in types (text, image, sticker, etc.) and custom types without a handler follow the standard flow -- immediate broadcast and persistence.

State Script

The stateScript is called when a user joins a room and when a client emits hub:requestState. It fetches the current room state from wherever the designer stores it (Redis, DB, external service) and returns it to the requesting client only.

Context VariableTypeDescription
sessionObjectRequesting user's session
roomIdStringRoom ID
roomObjectRoom DataObject record
libObjectService library functions

The return value can be any shape -- the hub wraps it in a hub:state event:

// Client receives:
socket.on("hub:state", ({ roomId, state }) => {
  renderChessBoard(state.board);
  setTurnIndicator(state.turn);
});

// Client can request state anytime (e.g., after reconnect):
socket.emit("hub:requestState", { roomId });

Scheduled Actions

Scheduled actions run on a fixed interval for each active room. Each action's MScript is called every intervalMs milliseconds and can return serverMessages to broadcast.

FieldTypeDefaultDescription
nameStringIdentifier for logging and lifecycle
intervalMsInteger1000Milliseconds between invocations
keepAliveBooleanfalseWhen true, keeps running even when the room has no connected users
scriptMScriptCalled each tick; returns { serverMessages: [...] }

Lifecycle:

  • Starts when the first user joins the room
  • Stops when the last user disconnects (unless keepAlive: true)
  • Each room has its own independent timer

Example: Chess Game

{
  "hubLogic": {
    "stateScript": "lib.chess.getState(roomId)",
    "messageHandlers": [
      {
        "messageType": "chessMove",
        "script": "lib.chess.processMove(roomId, session, message)"
      }
    ],
    "scheduledActions": []
  }
}

The service library function lib.chess.processMove handles validation, state updates, and generates server messages:

async function processMove(roomId, session, message) {
  const state = JSON.parse(await redis.get(`chess:${roomId}`));

  if (state.turn !== session.userId) {
    return { accept: false, rejectReason: "Not your turn" };
  }

  const result = validateAndApply(state.board, message.content);
  if (!result.valid) {
    return { accept: false, rejectReason: result.reason };
  }

  await redis.set(`chess:${roomId}`, JSON.stringify(result.newState));

  const serverMessages = [];
  if (result.isCheckmate) {
    serverMessages.push({
      type: "gameEvent",
      content: { event: "checkmate", winner: session.userId },
      to: "all"
    });
  } else if (result.isCheck) {
    serverMessages.push({
      type: "gameEvent",
      content: { event: "check" },
      to: "all"
    });
  }

  return { accept: true, broadcast: true, broadcastTo: "all", serverMessages };
}

Example: Multiplayer World with Ticks

{
  "hubLogic": {
    "stateScript": "lib.world.getVisibleState(roomId, session)",
    "messageHandlers": [
      { "messageType": "playerMove", "script": "lib.world.processMove(roomId, session, message)" },
      { "messageType": "playerAttack", "script": "lib.world.processAttack(roomId, session, message)" }
    ],
    "scheduledActions": [
      {
        "name": "worldTick",
        "intervalMs": 1000,
        "keepAlive": true,
        "script": "lib.world.tick(roomId)"
      }
    ]
  }
}

Socket.IO Protocol

Connection

import { io } from "socket.io-client";

const socket = io("https://your-service.example.com/hub/chatHub", {
  path: "/your-service-api/socket.io/",   // HTTP path for reverse proxy routing
  auth: { token: "Bearer <jwt-token>" },
  transports: ["websocket", "polling"]
});

socket.on("connect", () => console.log("Connected to chatHub"));
socket.on("connect_error", (err) => console.error("Auth failed:", err.message));

Multi-Tenant Connection

For multi-tenant projects, include the tenant codename in the auth payload:

const socket = io("https://your-service.example.com/hub/chatHub", {
  path: "/your-service-api/socket.io/",
  auth: {
    token: "Bearer <jwt-token>",
    tenantCodename: "my-tenant"       // required for multi-tenant projects
  },
  transports: ["websocket", "polling"]
});

Important: Do not rely on extraHeaders for the tenant codename. Browsers cannot send custom headers over the WebSocket transport -- only the HTTP polling transport receives extraHeaders. The auth payload is delivered over all transports (WebSocket and polling), so it is the only reliable way to pass the tenant context.

Joining a Room

socket.emit("hub:join", { roomId: "chat-uuid-123", meta: { displayName: "Alice" } });

socket.on("hub:joined", ({ roomId }) => console.log("Joined room", roomId));
socket.on("hub:history", ({ roomId, messages }) => console.log(`${messages.length} messages loaded`));
socket.on("hub:error", ({ error }) => console.error(error));

Sending Messages

// Text message
socket.emit("hub:send", {
  roomId: "chat-uuid-123",
  messageType: "text",
  content: { body: "Hello everyone!" }
});

// Image message
socket.emit("hub:send", {
  roomId: "chat-uuid-123",
  messageType: "image",
  content: { mediaUrl: "https://cdn.example.com/photo.jpg", caption: "Check this out" }
});

// Custom message type (chess move)
socket.emit("hub:send", {
  roomId: "match-uuid",
  messageType: "chessMove",
  content: { from: "e2", to: "e4", piece: "pawn", notation: "e4" }
});

// With reply
socket.emit("hub:send", {
  roomId: "chat-uuid-123",
  messageType: "text",
  content: { body: "I agree!" },
  replyTo: { id: "msg-uuid-456", preview: "Should we meet at 5?" }
});

Receiving Messages

socket.on("hub:messageArrived", ({ roomId, sender, message }) => {
  console.log(`[${message.messageType}] from ${sender.id}:`, message.content);
});

Standard Events

// Emit typing
socket.emit("hub:event", { roomId: "chat-uuid-123", event: "typing", data: {} });
socket.emit("hub:event", { roomId: "chat-uuid-123", event: "stopTyping", data: {} });

// Receive typing
socket.on("hub:typing", ({ roomId, userId }) => console.log(userId, "is typing..."));
socket.on("hub:stopTyping", ({ roomId, userId }) => console.log(userId, "stopped typing"));

// Read receipts
socket.emit("hub:event", {
  roomId: "chat-uuid-123",
  event: "messageRead",
  data: { lastReadTimestamp: "2025-01-15T10:30:00Z" }
});

socket.on("hub:messageRead", ({ roomId, userId, lastReadTimestamp }) => {
  console.log(userId, "read up to", lastReadTimestamp);
});

// Presence
socket.on("hub:online", ({ roomId, userId }) => console.log(userId, "is online"));
socket.on("hub:offline", ({ roomId, userId }) => console.log(userId, "went offline"));

Auto-Bridged Events

// These fire automatically when DataObject CRUD operations happen via REST/API
socket.on("hub:memberJoined", ({ roomId, ...data }) => console.log("New member:", data));
socket.on("hub:memberLeft", ({ roomId, ...data }) => console.log("Member left:", data));
socket.on("hub:messageEdited", ({ roomId, ...data }) => console.log("Message edited:", data));
socket.on("hub:messageDeleted", ({ roomId, messageId }) => console.log("Deleted:", messageId));
socket.on("hub:roomUpdated", ({ roomId, ...data }) => console.log("Room updated:", data));

Full Event Reference

EventDirectionPayloadDescription
hub:joinClient → Server{ roomId, meta? }Request to join a room
hub:joinedServer → Client{ roomId }Join confirmed
hub:leaveClient → Server{ roomId }Request to leave a room
hub:sendClient → Server{ roomId, messageType, content, replyTo?, forwarded? }Send a message
hub:messageArrivedServer → Room{ roomId, sender, message }New message broadcast (or approved moderated message)
hub:historyServer → Client{ roomId, messages[] }Historical messages on join
hub:presenceServer → Room{ event, roomId, user }Join/leave presence
hub:eventClient → Server{ roomId, event, data }Standard or custom event
hub:typingServer → Room{ roomId, userId }Typing indicator
hub:stopTypingServer → Room{ roomId, userId }Stopped typing
hub:recordingServer → Room{ roomId, userId }Recording voice
hub:stopRecordingServer → Room{ roomId, userId }Stopped recording
hub:messageReadServer → Room{ roomId, userId, lastReadTimestamp }Read receipt
hub:messageDeliveredServer → Room{ roomId, userId, messageId }Delivery receipt
hub:onlineServer → Room{ roomId, userId }User came online
hub:offlineServer → Room{ roomId, userId }User went offline
hub:blockClient → Server{ roomId, userId, reason?, duration? }Block a user (requires canModerate)
hub:unblockClient → Server{ roomId, userId }Unblock a user (requires canModerate)
hub:silenceClient → Server{ roomId, userId, reason?, duration? }Silence a user (requires canModerate)
hub:unsilenceClient → Server{ roomId, userId }Unsilence a user (requires canModerate)
hub:blockedServer → User{ roomId, reason }Notifies user they were blocked (then force-leave)
hub:unblockedServer → User{ roomId }Notifies user they were unblocked (may rejoin)
hub:silencedServer → User{ roomId, reason }Notifies user they were silenced
hub:unsilencedServer → User{ roomId }Notifies user they were unsilenced
hub:requestSpeakClient → Server{ roomId }Request speak permission (defaultSilenced mode)
hub:speakRequestedServer → Moderators{ roomId, userId }Notifies moderators of speak request
hub:grantSpeakModerator → Server{ roomId, userId }Grant speak permission
hub:revokeSpeakModerator → Server{ roomId, userId }Revoke speak permission
hub:speakGrantedServer → User{ roomId }Notifies user speak was granted
hub:speakRevokedServer → User{ roomId }Notifies user speak was revoked
hub:approveModerator → Server{ roomId, messageId }Approve a pending message
hub:rejectModerator → Server{ roomId, messageId, reason? }Reject a pending message
hub:messagePendingServer → Sender + Moderators{ roomId, messageId, sender?, message? }Message awaiting moderation
hub:messageRejectedServer → Room{ roomId, messageId, reason? }Moderated message was rejected
hub:requestStateClient → Server{ roomId }Request current room state (HubLogic)
hub:stateServer → Client{ roomId, state }Room state delivered (HubLogic)
hub:rejectedServer → Client{ roomId, messageType, reason }Intercepted message rejected by server logic
hub:messageEditedServer → Room{ roomId, ...record }Auto-bridged: message edited
hub:messageDeletedServer → Room{ roomId, messageId, deletedBy }Auto-bridged: message deleted
hub:memberJoinedServer → Room{ roomId, ...record }Auto-bridged: member added
hub:memberLeftServer → Room{ roomId, ...record }Auto-bridged: member removed
hub:memberUpdatedServer → Room{ roomId, ...record }Auto-bridged: member updated
hub:roomUpdatedServer → Room{ roomId, ...record }Auto-bridged: room updated
hub:roomClosedServer → Room{ roomId, ...record }Auto-bridged: room deleted
hub:errorServer → Client{ roomId?, error }Error notification

REST Endpoints

REST endpoints are auto-generated for server-side access and fallback scenarios.

MethodPathDescription
GET/{hub-name}/:roomId/messagesPaginated message history. Query params: limit, offset.
POST/{hub-name}/:roomId/messagesSend a message via REST (also broadcasts to connected clients).
DELETE/{hub-name}/:roomId/messages/:messageIdDelete a message (also broadcasts hub:messageDeleted).
GET/{hub-name}/:roomId/eligibleCheck if a room supports real-time features. Returns the roomEligibility evaluation result for the given room.

Complete Examples

Example 1: WhatsApp-Style Messenger

A messaging hub supporting 1-on-1 and group conversations with all media types, read receipts, typing indicators, role-based permissions, and auto-bridged membership events.

{
  "hubBasics": {
    "name": "messenger",
    "description": "WhatsApp-style messaging hub with 1-on-1 and group conversations"
  },
  "roomSettings": {
    "roomDataObject": "conversation",
    "authSources": [
      { "name": "conversationAdmin", "sourceObject": "conversationMember", "userField": "userId", "roomField": "conversationId", "condition": "conversationMember.role == 'admin'", "hubRole": "admin" },
      { "name": "conversationMember", "sourceObject": "conversationMember", "userField": "userId", "roomField": "conversationId", "hubRole": "member" }
    ]
  },
  "hubRoles": [
    { "name": "admin", "canRead": true, "canSend": true, "canModerate": true, "canDeleteAny": true, "canManageRoom": true },
    { "name": "member", "canRead": true, "canSend": true }
  ],
  "messageSettings": {
    "dataObjectName": "ChatMessage",
    "messageTypes": ["text", "image", "video", "audio", "document", "sticker", "contact", "location", "system"],
    "enableReplyTo": true,
    "enableReaction": true,
    "enableForwarded": true
  },
  "eventSettings": {
    "enableTypingIndicator": true,
    "enableRecordingIndicator": true,
    "enableReadReceipts": true,
    "enableDeliveryReceipts": true,
    "enablePresence": true,
    "autoBridgeDataEvents": true
  },
  "historySettings": {
    "historyEnabled": true,
    "historyLimit": 50
  },
  "guardrails": {
    "maxUsersPerRoom": 1024,
    "maxRoomsPerUser": 500,
    "messageRateLimit": 30,
    "maxMessageSize": 65536,
    "connectionTimeout": 600000
  }
}

Example 2: Chess Game Hub

A game hub with text chat, stickers, and custom message types for chess moves and nudges.

{
  "hubBasics": {
    "name": "chessLobby",
    "description": "Real-time chess game hub with move broadcasting and chat"
  },
  "roomSettings": {
    "roomDataObject": "chessMatch",
    "authSources": [
      { "name": "matchPlayer", "sourceObject": "matchPlayer", "userField": "playerId", "roomField": "matchId", "hubRole": "player" }
    ]
  },
  "hubRoles": [
    { "name": "player", "canRead": true, "canSend": true }
  ],
  "messageSettings": {
    "dataObjectName": "ChessMessage",
    "messageTypes": ["text", "sticker", "system"],
    "enableReplyTo": false,
    "enableReaction": true,
    "enableForwarded": false,
    "customMessageTypes": [
      {
        "name": "chessMove",
        "description": "A chess piece move with board coordinates",
        "fields": [
          { "name": "from", "fieldType": "String", "required": true, "description": "Source square" },
          { "name": "to", "fieldType": "String", "required": true, "description": "Target square" },
          { "name": "piece", "fieldType": "Enum", "required": true, "enumValues": "pawn,rook,knight,bishop,queen,king" },
          { "name": "captured", "fieldType": "Enum", "required": false, "enumValues": "pawn,rook,knight,bishop,queen" },
          { "name": "isCheck", "fieldType": "Boolean", "required": false, "defaultValue": "false" },
          { "name": "isCheckmate", "fieldType": "Boolean", "required": false, "defaultValue": "false" },
          { "name": "notation", "fieldType": "String", "required": false }
        ]
      },
      {
        "name": "nudge",
        "description": "A visual nudge to get attention",
        "fields": [
          { "name": "animation", "fieldType": "Enum", "required": false, "enumValues": "shake,bounce,flash", "defaultValue": "shake" }
        ]
      },
      {
        "name": "drawOffer",
        "description": "Player offers or responds to a draw",
        "fields": [
          { "name": "action", "fieldType": "Enum", "required": true, "enumValues": "offer,accept,decline" }
        ]
      }
    ]
  },
  "eventSettings": {
    "enableTypingIndicator": true,
    "enableRecordingIndicator": false,
    "enableReadReceipts": false,
    "enableDeliveryReceipts": false,
    "enablePresence": true,
    "autoBridgeDataEvents": true,
    "customEvents": [
      {
        "name": "clockUpdate",
        "description": "Timer sync from server",
        "ephemeral": true,
        "direction": "serverToRoom"
      }
    ]
  },
  "historySettings": {
    "historyEnabled": true,
    "historyLimit": 200
  },
  "guardrails": {
    "maxUsersPerRoom": 10,
    "maxRoomsPerUser": 5,
    "messageRateLimit": 120,
    "maxMessageSize": 4096,
    "connectionTimeout": 1800000
  }
}

Example 3: Live Dashboard with Kafka Bridge

A read-mostly hub where backend events are pushed to dashboard viewers. Uses the system message type for auto-generated event logs and external Kafka events for metrics and alerts.

{
  "hubBasics": {
    "name": "liveDashboard",
    "description": "Real-time dashboard streaming backend metrics and events"
  },
  "roomSettings": {
    "roomDataObject": "dashboard",
    "authSources": [
      { "name": "dashboardOwner", "sourceObject": "dashboard", "userField": "createdBy", "roomField": "id", "hubRole": "dashboardOwner" }
    ]
  },
  "hubRoles": [
    { "name": "dashboardOwner", "canRead": true, "canSend": false, "canManageRoom": true }
  ],
  "messageSettings": {
    "dataObjectName": "DashboardEvent",
    "messageTypes": ["system"],
    "enableReplyTo": false,
    "enableReaction": false,
    "enableForwarded": false
  },
  "eventSettings": {
    "enableTypingIndicator": false,
    "enableRecordingIndicator": false,
    "enableReadReceipts": false,
    "enableDeliveryReceipts": false,
    "enablePresence": false,
    "autoBridgeDataEvents": true,
    "kafkaEvents": [
      {
        "name": "metricUpdate",
        "description": "New metric data point",
        "topic": "analytics-events",
        "filterExpression": "data.type === 'metric'",
        "targetRoomExpression": "data.dashboardId"
      },
      {
        "name": "alertTriggered",
        "description": "Threshold alert",
        "topic": "analytics-events",
        "filterExpression": "data.type === 'alert'",
        "targetRoomExpression": "data.dashboardId"
      }
    ]
  },
  "historySettings": {
    "historyEnabled": false
  },
  "guardrails": {
    "maxUsersPerRoom": 200,
    "maxRoomsPerUser": 10,
    "messageRateLimit": 10,
    "maxMessageSize": 8192,
    "connectionTimeout": 900000
  }
}

Example 4: Customer Support Chat

A support hub with script-based auth, text and image messages, and a custom internalNote message type visible only to agents.

{
  "hubBasics": {
    "name": "supportChat",
    "description": "Customer support live chat tied to support tickets"
  },
  "roomSettings": {
    "roomDataObject": "supportTicket",
    "absoluteRoles": ["supportAgent", "supportLead"],
    "authSources": [
      { "name": "ticketCreator", "sourceObject": "supportTicket", "userField": "createdBy", "roomField": "id", "hubRole": "customer" }
    ]
  },
  "hubRoles": [
    { "name": "customer", "canRead": true, "canSend": true }
  ],
  "messageSettings": {
    "dataObjectName": "SupportMessage",
    "messageTypes": ["text", "image", "document", "system"],
    "enableReplyTo": false,
    "enableReaction": false,
    "enableForwarded": false,
    "customMessageTypes": [
      {
        "name": "internalNote",
        "description": "Internal note visible only to support agents",
        "fields": [
          { "name": "note", "fieldType": "Text", "required": true },
          { "name": "isResolution", "fieldType": "Boolean", "required": false, "defaultValue": "false" }
        ]
      }
    ]
  },
  "eventSettings": {
    "enableTypingIndicator": true,
    "enableRecordingIndicator": false,
    "enableReadReceipts": true,
    "enableDeliveryReceipts": false,
    "enablePresence": true,
    "autoBridgeDataEvents": true
  },
  "historySettings": {
    "historyEnabled": true,
    "historyLimit": 100
  },
  "guardrails": {
    "maxUsersPerRoom": 10,
    "maxRoomsPerUser": 20,
    "messageRateLimit": 30,
    "maxMessageSize": 65536,
    "connectionTimeout": 600000
  }
}

Example 5: Auction Bidding Room

A hub for live auctions with a custom bid message type, Kafka-bridged timer events, and message moderation for bids.

{
  "hubBasics": {
    "name": "auctionRoom",
    "description": "Live auction bidding with real-time bid updates and timer"
  },
  "roomSettings": {
    "roomDataObject": "auctionListing",
    "roomEligibility": "auctionListing.status == 'active'",
    "checkRoles": ["verifiedBidder"],
    "authScripts": [
      { "name": "allVerifiedBidders", "script": "true", "hubRole": "bidder" }
    ]
  },
  "hubRoles": [
    { "name": "bidder", "canRead": true, "canSend": true, "allowedMessageTypes": "text,bid" }
  ],
  "messageSettings": {
    "dataObjectName": "AuctionActivity",
    "messageTypes": ["text", "system"],
    "enableReplyTo": false,
    "enableReaction": false,
    "enableForwarded": false,
    "customMessageTypes": [
      {
        "name": "bid",
        "description": "A bid placed on the auction listing",
        "fields": [
          { "name": "amount", "fieldType": "Decimal", "required": true, "description": "Bid amount" },
          { "name": "currency", "fieldType": "String", "required": true, "defaultValue": "USD" },
          { "name": "isAutoBid", "fieldType": "Boolean", "required": false, "defaultValue": "false" }
        ]
      }
    ]
  },
  "eventSettings": {
    "enableTypingIndicator": false,
    "enableRecordingIndicator": false,
    "enableReadReceipts": false,
    "enableDeliveryReceipts": false,
    "enablePresence": true,
    "autoBridgeDataEvents": true,
    "kafkaEvents": [
      {
        "name": "auctionEnding",
        "topic": "auction-events",
        "filterExpression": "data.type === 'auction.ending'",
        "targetRoomExpression": "data.listingId"
      },
      {
        "name": "auctionClosed",
        "topic": "auction-events",
        "filterExpression": "data.type === 'auction.closed'",
        "targetRoomExpression": "data.listingId"
      }
    ]
  },
  "historySettings": {
    "historyEnabled": true,
    "historyLimit": 200
  },
  "guardrails": {
    "maxUsersPerRoom": 10000,
    "maxRoomsPerUser": 30,
    "messageRateLimit": 10,
    "maxMessageSize": 2048,
    "connectionTimeout": 1800000
  }
}

Example 6: Online Chess Platform with HubLogic

A complete online chess platform where two players play chess with a side chat panel. The game room is a ChessGame DataObject, moves are validated server-side via a chess engine in the service library, and the board state is stored in Redis for fast access with history backed up to a ChessMove DataObject. Chat messages (text, stickers) flow through standard hub mechanics, while chessMove messages are intercepted by HubLogic for validation before broadcast.

DataObjects

DataObjectPurpose
ChessGameRoom record — players, status (waiting/active/completed), result, winner, timeControl
ChessGameMessageAuto-generated message DataObject — chat messages AND moves (via messageType enum)
ChessMoveDedicated move history — from, to, piece, promotion, notation, moveNumber, fen (separate from messages for analysis/replay)
ChessGameMembershipPlayer membership — userId, color (white/black), joined timestamp

Pattern Configuration

{
  "hubBasics": {
    "name": "chessGame",
    "description": "Online chess game with real-time moves, chat, and spectator mode"
  },
  "roomSettings": {
    "roomDataObject": "ChessGame",
    "roomEligibility": "chessGame.status !== 'completed'",
    "absoluteRoles": ["superAdmin"],
    "authSources": [
      {
        "name": "player",
        "sourceObject": "ChessGameMembership",
        "userField": "userId",
        "roomField": "chessGameId",
        "hubRole": "player"
      }
    ],
    "authScripts": [
      {
        "name": "spectatorAccess",
        "script": "room.allowSpectators === true",
        "hubRole": "spectator"
      }
    ]
  },
  "hubRoles": [
    {
      "name": "player",
      "canRead": true,
      "canSend": true,
      "canModerate": false,
      "allowedMessageTypes": "text,sticker,chessMove,drawOffer,resign"
    },
    {
      "name": "spectator",
      "canRead": true,
      "canSend": true,
      "canModerate": false,
      "allowedMessageTypes": "text,sticker"
    }
  ],
  "messageSettings": {
    "dataObjectName": "ChessGameMessage",
    "messageTypes": ["text", "sticker", "system"],
    "enableReplyTo": false,
    "enableReaction": true,
    "enableForwarded": false,
    "customMessageTypes": [
      {
        "name": "chessMove",
        "description": "A chess move on the board",
        "fields": [
          { "name": "from", "fieldType": "String", "required": true },
          { "name": "to", "fieldType": "String", "required": true },
          { "name": "promotion", "fieldType": "String", "required": false }
        ]
      },
      {
        "name": "drawOffer",
        "description": "Offer or respond to a draw",
        "fields": [
          { "name": "action", "fieldType": "String", "required": true }
        ]
      },
      {
        "name": "resign",
        "description": "Player resigns the game",
        "fields": []
      },
      {
        "name": "gameEvent",
        "description": "Server-generated game events (check, checkmate, draw, time)",
        "fields": [
          { "name": "event", "fieldType": "String", "required": true },
          { "name": "winner", "fieldType": "String", "required": false },
          { "name": "reason", "fieldType": "String", "required": false }
        ]
      }
    ]
  },
  "eventSettings": {
    "enableTypingIndicator": true,
    "enableRecordingIndicator": false,
    "enableReadReceipts": false,
    "enableDeliveryReceipts": false,
    "enablePresence": true,
    "autoBridgeDataEvents": true
  },
  "historySettings": {
    "historyEnabled": true,
    "historyLimit": 100
  },
  "guardrails": {
    "maxUsersPerRoom": 50,
    "maxRoomsPerUser": 5,
    "messageRateLimit": 30,
    "maxMessageSize": 4096,
    "connectionTimeout": 1800000,
    "authCacheTTL": 600
  },
  "hubLogic": {
    "stateScript": "lib.chessEngine.getState(roomId, session)",
    "messageHandlers": [
      {
        "messageType": "chessMove",
        "script": "lib.chessEngine.processMove(roomId, session, message, lib)"
      },
      {
        "messageType": "drawOffer",
        "script": "lib.chessEngine.handleDrawOffer(roomId, session, message, lib)"
      },
      {
        "messageType": "resign",
        "script": "lib.chessEngine.handleResign(roomId, session, message, lib)"
      }
    ],
    "scheduledActions": [
      {
        "name": "clockTick",
        "intervalMs": 1000,
        "keepAlive": false,
        "script": "lib.chessEngine.clockTick(roomId, lib)"
      }
    ]
  }
}

Service Library: chessEngine

These functions live in the service's library folder. The MScript hooks call them with the full context.

lib.chessEngine.getState(roomId, session) — Called on join and on hub:requestState:

const { getRedisData } = require("common");

async function getState(roomId, session) {
  const raw = await getRedisData(`chess:state:${roomId}`);
  if (!raw) return null;
  const state = JSON.parse(raw);

  return {
    board: state.board,
    turn: state.turn,
    moveCount: state.moveCount,
    whitePlayer: state.whitePlayer,
    blackPlayer: state.blackPlayer,
    whiteClock: state.whiteClock,
    blackClock: state.blackClock,
    status: state.status,
    lastMove: state.lastMove,
    capturedPieces: state.capturedPieces,
    isCheck: state.isCheck,
    legalMoves: state.turn === session.userId ? state.legalMoves : undefined
  };
}

The legalMoves field is only sent to the player whose turn it is, preventing the opponent from seeing available moves.

lib.chessEngine.processMove(roomId, session, message, lib) — The core move handler:

const { getRedisData, setRedisData } = require("common");
const { createChessMove, updateChessGameById } = require("dbLayer");

async function processMove(roomId, session, message, lib) {
  const raw = await getRedisData(`chess:state:${roomId}`);
  if (!raw) return { accept: false, rejectReason: "Game not initialized" };

  const state = JSON.parse(raw);

  if (state.status !== "active") {
    return { accept: false, rejectReason: "Game is not active" };
  }

  if (state.turn !== session.userId) {
    return { accept: false, rejectReason: "Not your turn" };
  }

  const { from, to, promotion } = message.content;

  const moveResult = validateAndApplyMove(state, from, to, promotion);
  if (!moveResult.valid) {
    return { accept: false, rejectReason: moveResult.reason };
  }

  const newState = moveResult.newState;

  // Persist move to ChessMove DataObject for replay/analysis
  await createChessMove({
    chessGameId: roomId,
    moveNumber: newState.moveCount,
    playerId: session.userId,
    from,
    to,
    piece: moveResult.piece,
    captured: moveResult.captured || null,
    promotion: promotion || null,
    notation: moveResult.notation,
    fen: newState.fen,
    timestamp: new Date().toISOString()
  });

  // Save updated state to Redis
  await setRedisData(`chess:state:${roomId}`, JSON.stringify(newState));

  // Build the response
  const serverMessages = [];

  if (newState.isCheckmate) {
    newState.status = "completed";
    serverMessages.push({
      type: "gameEvent",
      content: { event: "checkmate", winner: session.userId, reason: "checkmate" },
      to: "all"
    });
    await updateChessGameById(roomId, {
      status: "completed",
      result: "checkmate",
      winnerId: session.userId
    });
  } else if (newState.isStalemate) {
    newState.status = "completed";
    serverMessages.push({
      type: "gameEvent",
      content: { event: "draw", reason: "stalemate" },
      to: "all"
    });
    await updateChessGameById(roomId, {
      status: "completed",
      result: "draw",
      reason: "stalemate"
    });
  } else if (newState.isCheck) {
    serverMessages.push({
      type: "gameEvent",
      content: { event: "check" },
      to: "all",
      persist: false
    });
  }

  // Update state in Redis with final status
  await setRedisData(`chess:state:${roomId}`, JSON.stringify(newState));

  return {
    accept: true,
    broadcast: true,
    broadcastTo: "all",
    modifiedContent: {
      from,
      to,
      promotion: promotion || null,
      piece: moveResult.piece,
      captured: moveResult.captured || null,
      notation: moveResult.notation,
      fen: newState.fen,
      isCheck: newState.isCheck
    },
    serverMessages
  };
}

function validateAndApplyMove(state, from, to, promotion) {
  // This is where the actual chess rules engine lives.
  // Uses a chess library (or custom implementation) to:
  // 1. Validate the move is legal for the current board position
  // 2. Apply the move and compute the new board state
  // 3. Detect check, checkmate, stalemate, draw conditions
  // 4. Compute legal moves for the next player
  // Returns: { valid, reason?, newState, piece, captured, notation }
}

lib.chessEngine.handleDrawOffer(roomId, session, message, lib):

async function handleDrawOffer(roomId, session, message, lib) {
  const raw = await getRedisData(`chess:state:${roomId}`);
  const state = JSON.parse(raw);
  const { action } = message.content;

  if (action === "offer") {
    state.drawOffer = { from: session.userId, timestamp: Date.now() };
    await setRedisData(`chess:state:${roomId}`, JSON.stringify(state));
    return {
      accept: true,
      broadcast: true,
      broadcastTo: "all",
      serverMessages: [{
        type: "gameEvent",
        content: { event: "drawOffered", by: session.userId },
        to: "all",
        persist: false
      }]
    };
  }

  if (action === "accept" && state.drawOffer && state.drawOffer.from !== session.userId) {
    state.status = "completed";
    await setRedisData(`chess:state:${roomId}`, JSON.stringify(state));
    await updateChessGameById(roomId, { status: "completed", result: "draw", reason: "agreement" });
    return {
      accept: true,
      broadcast: false,
      serverMessages: [{
        type: "gameEvent",
        content: { event: "draw", reason: "agreement" },
        to: "all"
      }]
    };
  }

  if (action === "decline") {
    state.drawOffer = null;
    await setRedisData(`chess:state:${roomId}`, JSON.stringify(state));
    return {
      accept: true,
      broadcast: false,
      serverMessages: [{
        type: "gameEvent",
        content: { event: "drawDeclined" },
        to: "all",
        persist: false
      }]
    };
  }

  return { accept: false, rejectReason: "Invalid draw action" };
}

lib.chessEngine.handleResign(roomId, session, message, lib):

async function handleResign(roomId, session, message, lib) {
  const raw = await getRedisData(`chess:state:${roomId}`);
  const state = JSON.parse(raw);

  const winnerId = state.whitePlayer === session.userId
    ? state.blackPlayer
    : state.whitePlayer;

  state.status = "completed";
  await setRedisData(`chess:state:${roomId}`, JSON.stringify(state));
  await updateChessGameById(roomId, {
    status: "completed",
    result: "resignation",
    winnerId
  });

  return {
    accept: true,
    broadcast: false,
    serverMessages: [{
      type: "gameEvent",
      content: { event: "resignation", winner: winnerId, resigned: session.userId },
      to: "all"
    }]
  };
}

lib.chessEngine.clockTick(roomId, lib) — Scheduled action running every second:

async function clockTick(roomId, lib) {
  const raw = await getRedisData(`chess:state:${roomId}`);
  if (!raw) return { serverMessages: [] };
  const state = JSON.parse(raw);

  if (state.status !== "active") return { serverMessages: [] };

  const activeColor = state.turn === state.whitePlayer ? "white" : "black";
  if (activeColor === "white") {
    state.whiteClock = Math.max(0, state.whiteClock - 1);
  } else {
    state.blackClock = Math.max(0, state.blackClock - 1);
  }

  await setRedisData(`chess:state:${roomId}`, JSON.stringify(state));

  // Time expired
  if (state.whiteClock <= 0 || state.blackClock <= 0) {
    const loser = state.whiteClock <= 0 ? state.whitePlayer : state.blackPlayer;
    const winner = loser === state.whitePlayer ? state.blackPlayer : state.whitePlayer;
    state.status = "completed";
    await setRedisData(`chess:state:${roomId}`, JSON.stringify(state));
    await updateChessGameById(roomId, {
      status: "completed",
      result: "timeout",
      winnerId: winner
    });

    return {
      serverMessages: [
        {
          type: "gameEvent",
          content: { event: "timeout", winner, loser },
          to: "all"
        }
      ]
    };
  }

  // Broadcast clock update every second (ephemeral, not persisted)
  return {
    serverMessages: [
      {
        type: "gameEvent",
        content: {
          event: "clockUpdate",
          whiteClock: state.whiteClock,
          blackClock: state.blackClock
        },
        to: "all",
        persist: false
      }
    ]
  };
}

Frontend Integration

import { io } from "socket.io-client";

const socket = io(`${baseUrl}/chess-api/hub/chessGame`, {
  auth: { token: `Bearer ${accessToken}` },
  transports: ["websocket"],
});

// ── Join a game room ──────────────────────────────────────────────────

socket.emit("hub:join", { roomId: gameId });

socket.on("hub:joined", ({ roomId, hubRole }) => {
  isPlayer = hubRole === "player";
  isSpectator = hubRole === "spectator";
});

// ── Receive board state (on join and on demand) ──────────────────────

socket.on("hub:state", ({ roomId, state }) => {
  renderBoard(state.board);
  setClocks(state.whiteClock, state.blackClock);
  setTurnIndicator(state.turn);
  if (state.legalMoves) highlightLegalMoves(state.legalMoves);
  setPlayerInfo(state.whitePlayer, state.blackPlayer);
  setCapturedPieces(state.capturedPieces);
});

// ── Receive all messages (chat + moves + game events) ────────────────

socket.on("hub:messageArrived", ({ sender, message }) => {
  switch (message.messageType) {
    case "chessMove":
      animateMove(message.content.from, message.content.to);
      updateBoardFromFen(message.content.fen);
      addToMoveList(message.content.notation);
      if (message.content.isCheck) flashCheck();
      break;

    case "gameEvent":
      handleGameEvent(message.content);
      break;

    case "text":
    case "sticker":
      appendChatMessage(sender, message);
      break;
  }
});

// ── Handle rejected moves ────────────────────────────────────────────

socket.on("hub:rejected", ({ messageType, reason }) => {
  if (messageType === "chessMove") {
    shakeBoard();
    showToast(`Invalid move: ${reason}`);
  }
});

// ── Make a move ──────────────────────────────────────────────────────

function onPieceDrop(from, to, promotion) {
  socket.emit("hub:send", {
    roomId: gameId,
    messageType: "chessMove",
    content: { from, to, promotion }
  });
}

// ── Chat messages (standard hub flow, no interception) ───────────────

function sendChatMessage(text) {
  socket.emit("hub:send", {
    roomId: gameId,
    messageType: "text",
    content: { body: text }
  });
}

// ── Game actions ─────────────────────────────────────────────────────

function offerDraw() {
  socket.emit("hub:send", {
    roomId: gameId,
    messageType: "drawOffer",
    content: { action: "offer" }
  });
}

function acceptDraw() {
  socket.emit("hub:send", {
    roomId: gameId,
    messageType: "drawOffer",
    content: { action: "accept" }
  });
}

function resign() {
  socket.emit("hub:send", {
    roomId: gameId,
    messageType: "resign",
    content: {}
  });
}

// ── Handle game events ───────────────────────────────────────────────

function handleGameEvent(content) {
  switch (content.event) {
    case "check":
      flashCheck();
      break;
    case "checkmate":
      showGameOver(`Checkmate! ${getPlayerName(content.winner)} wins.`);
      break;
    case "draw":
      showGameOver(`Draw by ${content.reason}.`);
      break;
    case "resignation":
      showGameOver(`${getPlayerName(content.resigned)} resigned. ${getPlayerName(content.winner)} wins.`);
      break;
    case "timeout":
      showGameOver(`Time's up! ${getPlayerName(content.winner)} wins on time.`);
      break;
    case "clockUpdate":
      setClocks(content.whiteClock, content.blackClock);
      break;
    case "drawOffered":
      if (content.by !== myUserId) showDrawOfferDialog();
      break;
    case "drawDeclined":
      showToast("Draw offer declined.");
      break;
  }
}

// ── Resync after reconnect ───────────────────────────────────────────

socket.on("connect", () => {
  socket.emit("hub:join", { roomId: gameId });
});

socket.on("hub:requestState", { roomId: gameId });

// ── Chat history on join ─────────────────────────────────────────────

socket.on("hub:history", ({ messages }) => {
  for (const msg of messages.reverse()) {
    if (msg.messageType === "text" || msg.messageType === "sticker") {
      appendChatMessage(msg.sender, msg);
    }
  }
});

Architecture Summary

Frontend (Chess Board + Chat Panel)

    │  Single Socket.IO connection


RealtimeHub: chessGame (Socket.IO namespace)

    ├── text, sticker messages ──────► Standard flow (persist + broadcast)

    ├── chessMove ──────────────────► HubLogic interception
    │   │                                │
    │   │   Per-room queue (sequential)  │
    │   │                                ▼
    │   │                         lib.chessEngine.processMove()
    │   │                                │
    │   │                    ┌───────────┼───────────┐
    │   │                    │           │           │
    │   │                Read state   Validate   Write state
    │   │                from Redis    move      to Redis
    │   │                                │
    │   │                    ┌───────────┼───────────┐
    │   │                    │           │           │
    │   │             Save ChessMove  Return     Generate
    │   │             to DataObject   result    serverMessages
    │   │                                │
    │   ◄────────────────────────────────┘
    │   │
    │   ├── accept: true ──► broadcast chessMove to all players
    │   ├── accept: false ─► hub:rejected to sender only
    │   └── serverMessages ► gameEvent (check, checkmate) to all

    ├── drawOffer, resign ──────────► HubLogic interception (same pattern)

    └── clockTick (every 1s) ───────► Scheduled action

        └── lib.chessEngine.clockTick()

            ├── Decrement active player's clock in Redis
            ├── If time expired → gameEvent: timeout
            └── clockUpdate → all (persist: false)

Using External Libraries: chess.js

Instead of writing a chess engine from scratch, you can use the chess.js npm library for move validation, legal move generation, and game-over detection. This example shows how to integrate an external npm package into the service and call it from the HubLogic MScript hooks.

Step 1: Add chess.js to the service's npm packages

In Service Settings, add the package to nodejsPackages:

{
  "serviceSettings": {
    "serviceBasics": {
      "name": "chess",
      "nodejsPackages": [
        {
          "packageName": "chess.js",
          "version": "^1.0.0",
          "defaultImportConst": "chessjs"
        }
      ]
    }
  }
}

This adds "chess.js": "^1.0.0" to the service's package.json and makes it available for require("chess.js") in library functions.

Step 2: Rewrite the service library to use chess.js

The entire custom validateAndApplyMove function from the previous example is replaced by chess.js calls:

lib.chessEngine.getState(roomId, session):

const { Chess } = require("chess.js");
const { getRedisData } = require("common");

async function getState(roomId, session) {
  const fen = await getRedisData(`chess:fen:${roomId}`);
  const meta = JSON.parse(await getRedisData(`chess:meta:${roomId}`) || "{}");
  if (!fen) return null;

  const game = new Chess(fen);

  const turnColor = game.turn(); // 'w' or 'b'
  const turnUserId = turnColor === "w" ? meta.whitePlayer : meta.blackPlayer;

  return {
    fen,
    board: game.board(),
    turn: turnUserId,
    turnColor,
    moveCount: game.moveNumber(),
    isCheck: game.isCheck(),
    isGameOver: game.isGameOver(),
    whitePlayer: meta.whitePlayer,
    blackPlayer: meta.blackPlayer,
    whiteClock: meta.whiteClock,
    blackClock: meta.blackClock,
    status: meta.status,
    capturedPieces: meta.capturedPieces || { w: [], b: [] },
    legalMoves: turnUserId === session.userId
      ? game.moves({ verbose: true }).map(m => ({ from: m.from, to: m.to, promotion: m.promotion }))
      : undefined
  };
}

lib.chessEngine.processMove(roomId, session, message, lib):

const { Chess } = require("chess.js");
const { getRedisData, setRedisData } = require("common");
const { createChessMove, updateChessGameById } = require("dbLayer");

async function processMove(roomId, session, message, lib) {
  const fen = await getRedisData(`chess:fen:${roomId}`);
  const meta = JSON.parse(await getRedisData(`chess:meta:${roomId}`) || "{}");
  if (!fen) return { accept: false, rejectReason: "Game not initialized" };

  if (meta.status !== "active") {
    return { accept: false, rejectReason: "Game is not active" };
  }

  const game = new Chess(fen);

  // Verify it's the sender's turn
  const expectedPlayer = game.turn() === "w" ? meta.whitePlayer : meta.blackPlayer;
  if (expectedPlayer !== session.userId) {
    return { accept: false, rejectReason: "Not your turn" };
  }

  // Attempt the move using chess.js — it validates everything:
  // legal squares, pins, checks, en passant, castling, promotion
  const { from, to, promotion } = message.content;
  let moveResult;
  try {
    moveResult = game.move({ from, to, promotion });
  } catch (err) {
    return { accept: false, rejectReason: "Illegal move" };
  }

  if (!moveResult) {
    return { accept: false, rejectReason: "Illegal move" };
  }

  const newFen = game.fen();

  // Track captured pieces
  if (moveResult.captured) {
    const capturedBy = moveResult.color; // 'w' or 'b'
    meta.capturedPieces = meta.capturedPieces || { w: [], b: [] };
    meta.capturedPieces[capturedBy].push(moveResult.captured);
  }

  // Persist the move to ChessMove DataObject
  await createChessMove({
    chessGameId: roomId,
    moveNumber: game.moveNumber(),
    playerId: session.userId,
    from: moveResult.from,
    to: moveResult.to,
    piece: moveResult.piece,
    captured: moveResult.captured || null,
    promotion: moveResult.promotion || null,
    notation: moveResult.san,
    fen: newFen,
    timestamp: new Date().toISOString()
  });

  // Save new FEN and meta to Redis
  await setRedisData(`chess:fen:${roomId}`, newFen);

  // Build server messages based on game state
  const serverMessages = [];

  if (game.isCheckmate()) {
    meta.status = "completed";
    serverMessages.push({
      type: "gameEvent",
      content: { event: "checkmate", winner: session.userId, reason: "checkmate" },
      to: "all"
    });
    await updateChessGameById(roomId, {
      status: "completed", result: "checkmate", winnerId: session.userId
    });
  } else if (game.isStalemate()) {
    meta.status = "completed";
    serverMessages.push({
      type: "gameEvent",
      content: { event: "draw", reason: "stalemate" },
      to: "all"
    });
    await updateChessGameById(roomId, {
      status: "completed", result: "draw", reason: "stalemate"
    });
  } else if (game.isDraw()) {
    meta.status = "completed";
    const reason = game.isInsufficientMaterial()
      ? "insufficient material"
      : game.isThreefoldRepetition()
        ? "threefold repetition"
        : "50-move rule";
    serverMessages.push({
      type: "gameEvent",
      content: { event: "draw", reason },
      to: "all"
    });
    await updateChessGameById(roomId, {
      status: "completed", result: "draw", reason
    });
  } else if (game.isCheck()) {
    serverMessages.push({
      type: "gameEvent",
      content: { event: "check" },
      to: "all",
      persist: false
    });
  }

  await setRedisData(`chess:meta:${roomId}`, JSON.stringify(meta));

  return {
    accept: true,
    broadcast: true,
    broadcastTo: "all",
    modifiedContent: {
      from: moveResult.from,
      to: moveResult.to,
      piece: moveResult.piece,
      captured: moveResult.captured || null,
      promotion: moveResult.promotion || null,
      notation: moveResult.san,
      fen: newFen,
      isCheck: game.isCheck(),
      flags: moveResult.flags
    },
    serverMessages
  };
}

What chess.js handles automatically:

  • All legal move generation (pins, discovered checks, en passant, castling rights)
  • Move validation — game.move() throws if the move is illegal
  • Check, checkmate, stalemate, draw detection (50-move rule, insufficient material, threefold repetition)
  • FEN generation after each move
  • SAN notation (e4, Nxf3+, O-O, e8=Q)
  • Turn management (game.turn() returns 'w' or 'b')

What you still handle in your lib functions:

  • State storage (Redis for live FEN, DB for move history)
  • Player identity mapping (userId to color)
  • Clock management (chess.js doesn't handle time controls)
  • Game lifecycle (creating games, matching players, handling resignations/draw offers)
  • Persisting moves to the ChessMove DataObject for replay

Step 3: Initialize game state when a game is created

When a new ChessGame DataObject is created (via Business API), a post-create action seeds the Redis state:

const { Chess } = require("chess.js");
const { setRedisData } = require("common");

async function initializeChessGame(gameId, whitePlayerId, blackPlayerId, timeControlSeconds) {
  const game = new Chess(); // standard starting position

  await setRedisData(`chess:fen:${gameId}`, game.fen());
  await setRedisData(`chess:meta:${gameId}`, JSON.stringify({
    whitePlayer: whitePlayerId,
    blackPlayer: blackPlayerId,
    whiteClock: timeControlSeconds,
    blackClock: timeControlSeconds,
    status: "active",
    capturedPieces: { w: [], b: [] }
  }));
}

This pattern — npm package in service settings, require in lib function, call from MScript — works for any external library. The same approach applies to game engines, physics simulations, math libraries, AI/ML clients, or domain-specific validators.


Best Practices

  1. Select only the message types you need. Don't enable all 9 built-in types if your app only sends text and images. Fewer types = smaller API surface and simpler client code.

  2. Use custom message types for structured app data. Chess moves, bids, poll votes, form submissions -- anything with a defined schema. The framework validates fields before broadcast and persistence.

  3. Keep custom message types light. Store large payloads (media, files) as URLs pointing to the Bucket Service. The content JSONB column should stay under a few KB.

  4. Enable**system**** message type for auditable hubs.** When combined with autoBridgeDataEvents, the hub auto-generates system messages for membership changes and room updates -- giving users a visible activity log.

  5. Use standard event toggles instead of custom events. Typing indicators, read receipts, and presence are so common they deserve the built-in treatment. Only use custom events for truly app-specific signals.

  6. Leave**autoBridgeDataEvents**** on.** It's free and keeps connected clients in sync with REST/API changes. Only disable it if you have a specific reason (e.g., the hub's DataObjects don't use Kafka).

  7. Use Redis adapter in production. The memory adapter only works with a single service instance. Two or more instances behind a load balancer require Redis.

  8. Set conservative guardrails. Start tight: messageRateLimit: 10-30 for chat, 60-120 for games. Set maxUsersPerRoom based on actual expected sizes, not theoretical maximums.

  9. Order authSources by privilege. Place the highest-privilege source first (e.g., owner before moderator before member). The pipeline stops at the first match, so ordering determines which role a user receives when they match multiple sources.

  10. Use roomEligibility to gate the UI. If a room can toggle chat on/off, use roomEligibility so the frontend can check the same field to show/hide the chat widget -- and the backend enforces the same rule at join time.

  11. Keep HubLogic scripts thin. The MScript in messageHandlers and scheduledActions should be a single function call like lib.chess.processMove(roomId, session, message, lib). Put all business logic in service library functions where you have full JavaScript, imports, and testability.

  12. Store fast state in Redis, history in DB. For games and stateful hubs, use Redis for the live state (board position, clocks, scores) and persist significant events to a DataObject (moves, bids, transactions) for replay and analysis. The stateScript reads from Redis; the message handler writes to both.

  13. Use persist: false for ephemeral server messages. Clock ticks, typing-like indicators, and transient notifications don't need to be stored. Set persist: false on serverMessages that are only relevant in real time.