Skip to main content

Game channels

Every game has a channel where the people who built it talk. Its members are the game's author and every player with an accepted change in it (the game's credits). Nobody else can read or write it. Players appear by their tag ("Player 1F7D", handleFor), never a name.

The author is the moderator: they can remove any message, mute a member (who then reads but can't write), and send announcements, which show as such in the channel and reach every other member as a notification.

How it works​

App ── POST /api/app/games/:id/channel/messages ──▶ Server: checks membership, mute, rate
insert game_channel_messages
POST /rt/runs/game:<id>/events ──▶ Edge RunChannel DO "game:<id>"
App ◀── wss /rt/runs/game:<id>?token=crt1 ────────────────────────────────────────────┘ (everyone connected)
  • Rows are the truth (db/migrations/0014_…sql: game_channel_messages, _mutes, _reads, _reports, user_blocks); src/lib/channel.ts has every rule.
  • The edge only fans out. A channel uses the same Durable Object class as a run's live progress (Realtime), under the id game:<gameId> (gameChannelId), with the same crt1 subscribe token (15 minutes, in channel.live.url). So the edge Worker has no channel code. Without the edge configured, live is null and an app refetches instead.
  • The socket is one-way. Apps send messages over HTTP, never the socket.

API​

All with the app's bearer session. :id is the game's id.

RouteWhat
GET /api/app/channels{channels: ChannelSummary[], unread}: the channels the player is in, most recently active first. A game of their own is listed once someone else has a change in it or something was said.
GET /api/app/games/:id/channel[?before=<createdAt>]The Channel (below). 403 {code: "not_a_member"} for anyone else.
POST /api/app/games/:id/channel/messages{text} or, from the author, {text, announcement: true} → 201 {message}. Errors carry {error, code}: forbidden 403, muted 403, empty 400, too_fast 429 (12 a minute), too_many_announcements 429 (3 a day).
DELETE /api/app/games/:id/channel/messages/:messageIdIts writer or the author removes it.
POST /api/app/games/:id/channel/messages/:messageId/report{reason?}. Any member.
POST /api/app/games/:id/channel/members/:memberId{muted: boolean} (the author) → {muted}; {blocked: boolean} (anyone) → {blocked: [memberId…]}. A blocked member's messages aren't shown to that player in any channel.
POST /api/app/games/:id/channel/readMarks the channel read.
type ChannelAuthor = { id: string /* memberId, not the account id */; handle: string; role: "author" | "member"; isYou: boolean };
type ChannelMessage = { id: string; kind: "message" | "announcement"; text: string; author: ChannelAuthor; createdAt: string };
type Channel = {
game: { id: string; title: string; coverUrl: string | null };
role: "author" | "member";
muted: boolean; // this player was muted
members: (ChannelAuthor & { muted: boolean; changes: number })[]; // the author first
messages: ChannelMessage[]; // oldest first, at most 60
more: boolean; // older ones exist: ?before=<messages[0].createdAt>
blocked: string[]; // memberIds this player blocked
live: { url: string } | null; // the WebSocket
};
type ChannelSummary = {
game: { id: string; title: string; coverUrl: string | null };
role: "author" | "member"; members: number; unread: number;
last: { text: string; handle: string; kind: "message" | "announcement"; createdAt: string } | null;
};

Messages are plain text: at most 1000 characters (an announcement 280), control characters removed.

The live socket​

Open channel.live.url. The first frame is {"type":"snapshot","events":[…]} (the last 50 events at most), then one event per frame. Send exactly {"type":"ping"} every 20 to 30 s; the answer is {"type":"pong"}.

{ type: "message", at, message: ChannelMessage } // author has no isYou: compare author.id with your own member id
{ type: "removed", at, id } // drop that message
{ type: "muted", at, member, muted } // member is a memberId; when it is you, flip `muted`

What an app does:

  1. GET …/channel, show messages, remember your own member id (members.find(isYou).id).
  2. Open the socket. Apply the snapshot's events in order, then every event: a message whose id is already shown is ignored (your own posts come back this way too), one whose author.id is in blocked is dropped, removed deletes.
  3. When the socket drops, or closes with 1008 or an HTTP 401: GET …/channel again (fresh live.url, and the messages missed) and reconnect, with backoff (1, 2, 4, up to 15 s).
  4. POST …/read when the channel is opened and when a message arrives while it is on screen.

Limits​

  • Members only, so a player whose change is cancelled stays a member only while another accepted change of theirs remains.
  • Reports are stored (game_channel_reports).
  • No typing or "who's here" yet: the Durable Object would need to know who is connected to say so.