The realtime protocol uses JSON messages over WebSocket.
Default endpoint after login or account creation:
ws://localhost:3000/wsBrowser clients authenticate the WebSocket handshake with the HttpOnly session cookie set by the
HTTP auth routes. Non-browser clients should send an Authorization bearer header on the
upgrade request. Query-string tokens are intentionally unsupported because URLs are commonly
captured in logs and browser history.
Request the room directory available to the authenticated user with live occupancy.
{
"type": "room.list.request"
}Join a room as the authenticated WebSocket user.
{
"type": "room.join",
"roomId": "lobby"
}Request movement to a target tile. The server decides whether the target is valid and which path is accepted.
{
"type": "avatar.move.request",
"target": {
"x": 5,
"y": 3
}
}Send a plain room chat message.
{
"type": "chat.say",
"text": "Hi"
}Send transient typing state for the current room.
{
"type": "chat.typing",
"isTyping": true
}Send a friend-gated direct message to another user.
{
"type": "dm.send",
"toUserId": "user_...",
"text": "Hi"
}Send transient typing state for a direct-message conversation. The server only forwards the update when the users are allowed to message each other.
{
"type": "dm.typing",
"toUserId": "user_...",
"isTyping": true
}Mark unread messages from a friend as read in the direct-message conversation.
{
"type": "dm.read",
"friendId": "user_..."
}Edit a direct message sent by the authenticated user.
{
"type": "dm.edit",
"messageId": "dm_...",
"text": "Updated message"
}Delete a direct message sent by the authenticated user for both participants.
{
"type": "dm.delete",
"messageId": "dm_..."
}Broadcast the authenticated user's saved character appearance to the current room after the profile update succeeds.
{
"type": "avatar.appearance.update",
"appearance": {
"hair": "side-part",
"hairColor": "#8b4a24",
"skinTone": "#f2c097",
"shirt": "hoodie",
"shirtColor": "#2f5f7f",
"pants": "straight",
"pantsColor": "#d2c294",
"shoes": "boots",
"shoesColor": "#5b4218"
}
}Place a furniture definition in the current room. Only the room owner can edit furniture.
{
"type": "room.item.place.request",
"itemType": "crate_table",
"position": {
"x": 2,
"y": 1
},
"rotation": 0
}Move or rotate a placed room item.
{
"type": "room.item.move.request",
"itemId": "item_...",
"position": {
"x": 3,
"y": 1
},
"rotation": 1
}Remove a placed room item.
{
"type": "room.item.pickup.request",
"itemId": "item_..."
}Use a supported item action such as toggling a lamp.
{
"type": "room.item.interact.request",
"itemId": "item_...",
"action": "toggle"
}Simple ping/pong support.
{
"type": "ping",
"sentAt": "2026-05-10T12:00:00.000Z"
}Sent immediately after a WebSocket connection opens. When the authenticated user has a valid
persisted last room, the server may immediately follow this with room.snapshot, user.joined,
and room.list messages for the resumed room.
{
"type": "connected",
"userId": "user_...",
"dollars": 500
}Sent after a successful join or automatic room resume.
{
"type": "room.snapshot",
"roomId": "lobby",
"users": [],
"tiles": [],
"items": [
{
"id": "item_...",
"itemType": "crate_table",
"x": 2,
"y": 1,
"z": 0,
"rotation": 0,
"state": {}
}
],
"canEditItems": true
}Each room user includes id, username, position, and appearance.
Sent after a room list request, a successful join, and an automatic room resume.
{
"type": "room.list",
"rooms": [
{
"id": "lobby",
"name": "Lobby",
"userCount": 4,
"joined": true
}
]
}Broadcast when a user joins a room.
{
"type": "user.joined",
"user": {
"id": "user_...",
"username": "Tom",
"position": {
"x": 2,
"y": 2
},
"appearance": {
"hair": "short",
"hairColor": "#7a4424",
"skinTone": "#f2c097",
"shirt": "crew",
"shirtColor": "#2f5f7f",
"pants": "straight",
"pantsColor": "#d2c294",
"shoes": "boots",
"shoesColor": "#5b4218"
}
}
}Broadcast when a user disconnects or changes rooms.
{
"type": "user.left",
"userId": "user_..."
}Broadcast after the server accepts a movement request.
{
"type": "avatar.moved",
"userId": "user_...",
"path": [
{
"x": 2,
"y": 2
},
{
"x": 3,
"y": 2
}
]
}Broadcast after a user changes their character while inside a room.
{
"type": "avatar.appearance.updated",
"userId": "user_...",
"appearance": {
"hair": "bob",
"hairColor": "#3b2418",
"skinTone": "#f2c097",
"shirt": "hoodie",
"shirtColor": "#7f3b44",
"pants": "wide",
"pantsColor": "#77684b",
"shoes": "sneakers",
"shoesColor": "#2f3b40"
}
}Broadcast after the server accepts a chat message.
{
"type": "chat.message",
"userId": "user_...",
"username": "Tom",
"text": "Hi",
"sentAt": "2026-05-10T12:00:00.000Z"
}Broadcast after the server accepts a typing state update.
{
"type": "chat.typing",
"userId": "user_...",
"username": "Tom",
"isTyping": true
}Broadcast after the server accepts and persists a placed room item.
{
"type": "room.item.placed",
"item": {
"id": "item_...",
"itemType": "crate_table",
"x": 2,
"y": 1,
"z": 0,
"rotation": 0,
"state": {}
}
}Broadcast after the server accepts and persists an item move or rotation.
{
"type": "room.item.moved",
"item": {
"id": "item_...",
"itemType": "crate_table",
"x": 3,
"y": 1,
"z": 0,
"rotation": 1,
"state": {}
}
}Broadcast after the server accepts and persists item pickup.
{
"type": "room.item.picked_up",
"itemId": "item_..."
}Broadcast after an item interaction changes item state.
{
"type": "room.item.state_updated",
"item": {
"id": "item_...",
"itemType": "glass_lamp",
"x": 1,
"y": 1,
"z": 0,
"rotation": 0,
"state": {
"on": true
}
}
}Published to the sender and recipient user topics after the server accepts and persists a direct message.
{
"type": "dm.message",
"id": "dm_...",
"fromUserId": "user_...",
"toUserId": "user_...",
"text": "Hi",
"sentAt": "2026-05-10T12:00:00.000Z",
"readAt": "2026-05-10T12:01:00.000Z",
"editedAt": "2026-05-10T12:00:30.000Z"
}Published to the recipient user topic after the server accepts a direct-message typing state update.
{
"type": "dm.typing",
"fromUserId": "user_...",
"toUserId": "user_...",
"isTyping": true
}Published to both direct-message participants after the reader marks received messages as read.
{
"type": "dm.read",
"readerUserId": "user_...",
"otherUserId": "user_...",
"messageIds": ["dm_..."],
"readAt": "2026-05-10T12:01:00.000Z"
}Published to both direct-message participants after the sender edits one of their messages.
{
"type": "dm.edited",
"id": "dm_...",
"fromUserId": "user_...",
"toUserId": "user_...",
"text": "Updated message",
"editedAt": "2026-05-10T12:00:30.000Z"
}Published to both direct-message participants after the sender deletes one of their messages.
{
"type": "dm.deleted",
"id": "dm_...",
"fromUserId": "user_...",
"toUserId": "user_...",
"deletedAt": "2026-05-10T12:02:00.000Z"
}Response to ping.
{
"type": "pong",
"sentAt": "2026-05-10T12:00:00.000Z"
}Structured error response.
{
"type": "error",
"code": "INVALID_MESSAGE",
"message": "Invalid message"
}Inbound client messages are validated by packages/protocol.
Current limits:
- Raw WebSocket payload: 8 KB.
- Room ID: 1 to 64 trimmed characters.
- Chat text: 1 to 240 trimmed characters.
- Tile coordinates: integers only.
- Avatar styles and colors must be one of the supported values exported by
@tilezo/protocol.
Malformed JSON, unknown message types, invalid payloads, unauthenticated room joins, movement before joining, chat before joining, character updates before joining, and invalid tiles are rejected without crashing the server.