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.tshas 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 samecrt1subscribe token (15 minutes, inchannel.live.url). So the edge Worker has no channel code. Without the edge configured,liveis 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.
| Route | What |
|---|---|
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/:messageId | Its 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/read | Marks 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:
GET …/channel, showmessages, remember your own member id (members.find(isYou).id).- Open the socket. Apply the snapshot's events in order, then every event: a
messagewhose id is already shown is ignored (your own posts come back this way too), one whoseauthor.idis inblockedis dropped,removeddeletes. - When the socket drops, or closes with 1008 or an HTTP 401:
GET …/channelagain (freshlive.url, and the messages missed) and reconnect, with backoff (1, 2, 4, up to 15 s). POST …/readwhen 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.