WebSocket protocol

JSON text frames, short field names (see decision 0004). The connection is authenticated by the session cookie. The server closes the socket with a code that tells the client what to do (close codes); 1009 means a frame was too large.

Every connection starts with a full hello; the same happens on every reconnect, so the client never needs to merge state across connections.

#Client to server

MessageFieldsMeaning
ins seq, x, y (-1, 0, 1), b runmovement intent; sent on change and every 400 ms while held. b: true runs at cfg.run times the walking speed
gox, y tile, or id of a playerrun there along a path the server finds (walls, locked and forbidden rooms respected, portals avoided); any movement key cancels it
stvset status: available, busy, away, invisible
consentbopt in or out of automatic conversations
chatsc (o office, g group, d direct), text, id (peer for d)send a message
locidask where a person is
syncnoneask for the current state of everything in view (sent when a hidden tab becomes visible)
toknonerequest a fresh media token for the current conversation
emon 1-8show a reaction above your avatar (700 ms server cooldown); 8 is a short dance (key Z)
handbraise or lower your hand (shown in the roster and over the avatar)
notetextset your status note, one line, at most 60 characters ("" clears it); kept in memory only
waveidwave at somebody anywhere in the office (2 s cooldown); busy and away people are not disturbed
usex, y tileinteract with the object at that tile (note, site, image, whiteboard); the server checks distance and area access
folid (0 stops)follow that player; any movement key stops it
leadidask that player to follow you (they decide)
lockblock or unlock the meeting room you are standing in
knockn area indexknock on a locked room or an assigned private office you stand close to
kansid, badmit or decline a knock (only from inside the room; in an assigned office only an owner or an admin)
wbdrawing opswhiteboard edits: draw (batched pen points or one text), del, undo, clear (admin), close; all bounded, see decision 0010
pingcround-trip probe

#Server to client

MessageFieldsMeaning
helloyou, role, status, office, cfg, map, roster, chatinitial state; cfg has speed, the run multiplier (run), tick, media availability, your position and the areas denied to you; chat is the persisted office chat
wk tick, m [[id, x, y, d]...], l [id...]state records in your area of interest and entities that left it
as, x, yacknowledgement with the authoritative position at the time of input s
pa people added/updated, d ids removedpresence roster changes; a person is {id, n, av, s, r} plus h: 1 (hand raised) and m (status note) when set
csc, f from, to, x text, tschat message
convop = join / m / leaveconversation lifecycle, including room name and token on join
mapmap, deny, optional x/ythe office map changed (admin edit); x/y present if you were moved
locid, ok, x, y, aanswer to a locate request
eid, v 1-7somebody's reaction; rendered above their avatar
objk note/embed/image, l label, d contentobject content, only after a nearby authorised use (never in the map)
folid, nyou are now following (or stopped, id 0)
wvfrom, nsomebody waved at you
wvrid, st ok / busy / away / offlinewhat happened to your wave
acc [count per area]visible people per map area, in map order; sent with hello and then at most every 2 s when a count changes
annn sender name, x textan administrator's announcement banner (one line, at most 280 characters, never stored)
gook, x, ya walk-to was accepted (the server now steers you, self carries the positions) or refused (ok: false: no path, nobody there); its end arrives as fol with id 0
selfx, y, dx, dy, d, tpyour authoritative position while following or walking to a place, once more where a guided walk stops, and after a portal (tp)
lreqfrom, nsomebody asks you to follow them
denyd denied areas, lk locked roomsyour blocked-area list (locks change it for everyone)
knkid, n, asomebody knocks on the room you are in (done dismisses)
knrst wait/ok/no/empty, athe answer to your knock
wbop state/draw/del/clear/closedwhiteboard state and live strokes; deletes carry owner+id
spotop on/offoffice broadcast: speaker, room and token (subscribe-only unless you are the speaker)
pongcreply to ping

#State records (w)

A record is not a position sample: it is the state to extrapolate from (decision 0006). d packs the facing direction and the movement:

d = facing | (dx + 1) << 2 | (dy + 1) << 4 | run << 6     facing: 0 down, 1 left, 2 right, 3 up; dx, dy in -1..1
idle = dx = dy = 0

Clients keep moving the entity with the same rules as the server (speed from cfg.speed, times cfg.run when the run bit is set, diagonals scaled by 1/sqrt(2), axis-separated collision against the static walls of the map, sub-steps of at most 4 px, and a blocked axis moves to the point of contact with the wall; see decision 0017). The server sends a new record when the direction or the run state changes, when reality diverges from that extrapolation, as a 1 s resync, and when you start seeing the entity. The run bit is only set while moving.

Avatars are {sk, hs, hc, sh, pa, pt} small integers; pt is the companion pet (0 none, 1-8). The server clamps unknown values to 0 and drops unknown fields.

#HTTP endpoints

EndpointAuthPurpose
GET /healthz, GET /readyznoneliveness, readiness (database)
GET /metricsbearer TILEWORK_METRICS_TOKEN; open in dev; disabled (404) in production without a tokenPrometheus text
POST /api/joininvite (production)create a member; {name, avatar, invite}; sets the session cookie
GET /api/mecookiecurrent session or {"authenticated": false}
PUT /api/profilecookiechange your name and avatar
POST /api/invitesadminmint an invite {role, maxUses, hours}; the secret is returned once
GET /api/admin/invitesadminlist invites (id, role, uses, expiry, creator, status); never the secret
DELETE /api/admin/invites/{id}adminrevoke an invite
GET /api/admin/membersadminlist members {members: [{id, name, role, joinedAt}], you}
PATCH /api/admin/members/{id}adminchange a role `{role: "admin"
DELETE /api/admin/members/{id}adminremove a member: sessions deleted, socket closed, call ended. Not yourself, not the last admin (409)
GET /api/admin/auditadminthe latest 100 admin actions, newest first
POST /api/admin/announceadmin{text} (1-280 characters): banner to everybody online; one per 15 s per admin (burst 2); audited as announce without the text
PUT /api/mapadminvalidate, apply and save a new map
GET /api/admin/mapadminthe saved map including hidden interactive-object content (the world map redacts it; editors must round-trip through this endpoint or they will erase object content)
GET /wscookiethe world WebSocket

#WebSocket close codes

CodeMeaningClient behaviour
4001replaced by a newer connection of the same user (another tab)stop, show "session opened in another tab"
4002the client could not keep up (reliable queue overflow)reconnect with backoff
4003an admin removed the member or changed their roleask /api/me: still a member -> reconnect at once; otherwise go to the join screen with a notice
1013server closing the connection for another reasonreconnect with backoff

The reason is recorded on the world goroutine right before the outbound channel closes, so the writer always sends the right code.

#Limits

LimitValue
Incoming frame size2 KB
Movement input rate40/s, burst 80
Control messages10/s, burst 30
Chat3/s, burst 8; 500 characters
Outbound queue per client128 frames
Reconnect grace before removal10 s
Join / map / profile requestsrate limited per IP or user; bodies capped (4 KB, 512 KB, 4 KB)
Admin API5 requests/s per admin, burst 20; bodies capped at 256 B (role change)

Sustained violations disconnect the client. Text is trimmed of control characters on the server.

Edit this page on GitHub