@pithy-sh/matchmaking 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * Creates the matchmaking D1 tables: `pithy_matchmaking_invites` (direct invites) and
9
+ * `pithy_matchmaking_friends` (the symmetric friend graph). Room codes live in KV, so they are not here.
10
+ * All identifiers are camelCase; the runner's `CamelCasePlugin` snake-cases them to `pithy_matchmaking_*`.
11
+ */
12
+ export const matchmaking_0001_matchmaking: Migration = {
13
+ up: async (db: Kysely<unknown>) => {
14
+ await db.schema
15
+ .createTable("pithyMatchmakingInvites")
16
+ .addColumn("id", "text", (c) => c.primaryKey())
17
+ .addColumn("gameKey", "text", (c) => c.notNull())
18
+ .addColumn("inviterId", "text", (c) => c.notNull())
19
+ .addColumn("inviteeId", "text", (c) => c.notNull())
20
+ .addColumn("status", "text", (c) => c.notNull())
21
+ .addColumn("sessionId", "text")
22
+ .addColumn("createdAt", "integer", (c) => c.notNull())
23
+ .addColumn("respondedAt", "integer")
24
+ .execute();
25
+
26
+ await db.schema
27
+ .createIndex("pithyMatchmakingInvitesInviteeIdx")
28
+ .on("pithyMatchmakingInvites")
29
+ .columns(["inviteeId", "status"])
30
+ .execute();
31
+
32
+ await db.schema
33
+ .createTable("pithyMatchmakingFriends")
34
+ .addColumn("id", "integer", (c) => c.primaryKey().autoIncrement())
35
+ .addColumn("userA", "text", (c) => c.notNull())
36
+ .addColumn("userB", "text", (c) => c.notNull())
37
+ .addColumn("status", "text", (c) => c.notNull())
38
+ .addColumn("requestedBy", "text", (c) => c.notNull())
39
+ .addColumn("createdAt", "integer", (c) => c.notNull())
40
+ .addColumn("updatedAt", "integer", (c) => c.notNull())
41
+ .execute();
42
+
43
+ await db.schema
44
+ .createIndex("pithyMatchmakingFriendsPairIdx")
45
+ .on("pithyMatchmakingFriends")
46
+ .columns(["userA", "userB"])
47
+ .unique()
48
+ .execute();
49
+
50
+ await db.schema
51
+ .createIndex("pithyMatchmakingFriendsByUserB")
52
+ .on("pithyMatchmakingFriends")
53
+ .columns(["userB"])
54
+ .execute();
55
+ },
56
+
57
+ down: async (db: Kysely<unknown>) => {
58
+ await db.schema.dropIndex("pithyMatchmakingFriendsByUserB").execute();
59
+ await db.schema.dropIndex("pithyMatchmakingFriendsPairIdx").execute();
60
+ await db.schema.dropTable("pithyMatchmakingFriends").execute();
61
+ await db.schema.dropIndex("pithyMatchmakingInvitesInviteeIdx").execute();
62
+ await db.schema.dropTable("pithyMatchmakingInvites").execute();
63
+ },
64
+ };
@@ -0,0 +1,114 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { DurableObject } from "cloudflare:workers";
5
+ import type { D1Database } from "@cloudflare/workers-types";
6
+ import { matchmakingDatabase } from "../data/tables";
7
+ import { friendStore } from "../friends/store";
8
+ import { inviteStore } from "../invite/store";
9
+ import { PRESENCE_USER_HEADER, type PresenceEvent } from "./protocol";
10
+
11
+ // The header and the event shapes are **not** re-exported from here. They live in `./protocol` and are
12
+ // imported from there by everyone, DO included. Re-exporting them made this module a legal source for two
13
+ // pure values, and a value import out of a `cloudflare:workers` module is exactly how #172 reached
14
+ // multiplayer's config path — the routes only needed two constants, and they took the whole DO with them.
15
+ // One source per value, and it is the pure one.
16
+
17
+ /**
18
+ * Presence — a single Durable Object (addressed by a fixed name) holding every online player's WebSocket
19
+ * via the Hibernation API. On connect it delivers a player's pending invites and which of their friends
20
+ * are currently online; thereafter `notify()` pushes real-time events (a match found, an invite received,
21
+ * a friend request). "Friends online" is the intersection of a user's friend graph with the connected
22
+ * set. A single shared object has a soft ~1,000 req/s ceiling — adequate for notifications; noted as a
23
+ * scaling consideration.
24
+ *
25
+ * Platform discipline (mirrors the multiplayer session DO): the WebSocket Hibernation API only (never
26
+ * `ws.accept()`), the authenticated identity stashed with `serializeAttachment` so it survives eviction,
27
+ * and no in-memory connection registry — the live set is always read back from `getWebSockets()`.
28
+ */
29
+ export interface MatchmakingPresenceEnv {
30
+ DB: D1Database;
31
+ }
32
+
33
+ export class MatchmakingPresence extends DurableObject<MatchmakingPresenceEnv> {
34
+ /**
35
+ * WebSocket upgrade — accepts the connection (Hibernation API) and sends the initial presence payload. The
36
+ * upgrade is forwarded by the authenticated Hono handler, which sets {@link PRESENCE_USER_HEADER} to the
37
+ * AuthContext user id; the DO trusts that server-set header and never a client-supplied id.
38
+ */
39
+ override async fetch(request: Request): Promise<Response> {
40
+ if (request.headers.get("upgrade") !== "websocket") {
41
+ return new Response("Expected a WebSocket upgrade.", { status: 426 });
42
+ }
43
+ const userId = request.headers.get(PRESENCE_USER_HEADER);
44
+ if (!userId) return new Response("Missing authenticated user.", { status: 401 });
45
+
46
+ const pair = new WebSocketPair();
47
+ const [client, server] = [pair[0], pair[1]];
48
+ // Hibernation API — never `server.accept()`, which would pin the object in memory. The `userId` tag lets
49
+ // the runtime hand this user's sockets back after eviction.
50
+ this.ctx.acceptWebSocket(server, [userId]);
51
+ // A tiny attachment (well under the 16,384-byte ceiling): the identity that survives hibernation.
52
+ server.serializeAttachment({ userId });
53
+
54
+ // The connect payload: this player's pending invites, plus which of their accepted friends are online now.
55
+ const db = matchmakingDatabase(this.env.DB);
56
+ const pendingInvites = await inviteStore(db).pendingFor(userId);
57
+ const friends = await friendStore(db).list(userId);
58
+ const accepted = new Set(friends.filter((edge) => edge.status === "accepted").map((edge) => edge.userId));
59
+ const onlineFriends = this.connectedUserIds().filter((id) => accepted.has(id));
60
+ server.send(JSON.stringify({ type: "init", pendingInvites, onlineFriends }));
61
+
62
+ return new Response(null, { status: 101, webSocket: client });
63
+ }
64
+
65
+ /** Push an event to a connected user's sockets. A no-op if the user is offline. */
66
+ async notify(userId: string, event: PresenceEvent): Promise<void> {
67
+ const frame = JSON.stringify(event);
68
+ for (const ws of this.ctx.getWebSockets()) {
69
+ if (this.socketUserId(ws) !== userId) continue;
70
+ try {
71
+ ws.send(frame);
72
+ } catch {
73
+ // A dead socket — skip it; the player resyncs on reconnect. One bad socket never blocks the rest.
74
+ }
75
+ }
76
+ }
77
+
78
+ /** The user ids currently connected — distinct, read back from each socket's attachment (hibernation-safe). */
79
+ connectedUserIds(): string[] {
80
+ const ids = new Set<string>();
81
+ for (const ws of this.ctx.getWebSockets()) {
82
+ const id = this.socketUserId(ws);
83
+ if (id) ids.add(id);
84
+ }
85
+ return [...ids];
86
+ }
87
+
88
+ /** Keepalive only — presence carries no client-authoritative state; a `ping` gets a `pong`, else no-op. */
89
+ override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
90
+ const text = typeof message === "string" ? message : new TextDecoder().decode(message);
91
+ if (text === "ping") {
92
+ try {
93
+ ws.send("pong");
94
+ } catch {
95
+ // Socket is closing — nothing to answer.
96
+ }
97
+ }
98
+ }
99
+
100
+ override async webSocketClose(ws: WebSocket): Promise<void> {
101
+ // Nothing authoritative lives on the socket; a reconnecting player re-upgrades and gets a fresh payload.
102
+ try {
103
+ ws.close();
104
+ } catch {
105
+ // Already closing — ignore.
106
+ }
107
+ }
108
+
109
+ /** The authenticated user id stashed on a socket, or undefined for an unidentified one. */
110
+ private socketUserId(ws: WebSocket): string | undefined {
111
+ const attachment = ws.deserializeAttachment() as { userId?: string } | null;
112
+ return attachment?.userId;
113
+ }
114
+ }
@@ -0,0 +1,17 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The presence protocol — the header and event shapes shared by the presence Durable Object and the HTTP
6
+ * routes. Kept free of any `cloudflare:workers` import so the routes (and their node-side tests) can depend
7
+ * on it without pulling the DO runtime into a plain Node environment.
8
+ */
9
+
10
+ /** The server-set header carrying the authenticated user id into the presence DO. The DO trusts only this. */
11
+ export const PRESENCE_USER_HEADER = "x-pithy-user-id";
12
+
13
+ /** An event pushed to a connected player over the presence socket. */
14
+ export type PresenceEvent =
15
+ | { type: "match_found"; sessionId: string; gameKey: string }
16
+ | { type: "invite"; inviteId: string; gameKey: string; from: string }
17
+ | { type: "friend_request"; from: string };
@@ -0,0 +1,209 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { DurableObject } from "cloudflare:workers";
5
+ import type { D1Database, DurableObjectNamespace } from "@cloudflare/workers-types";
6
+ import type { MatchmakingQueueSettings, MatchmakingSnapshot } from "../config/config";
7
+ import { MatchmakingNotQueuedError } from "../error/errors";
8
+ import { guardRpc } from "../rpc";
9
+ import { type SessionNamespace, sessionMinter } from "../session/minter";
10
+ import { formMatches, StoredMatch, WaitingList, type WaitingTicket } from "./matching";
11
+
12
+ /**
13
+ * The open-queue coordinator — one Durable Object per game (addressed `env.QUEUE.idFromName(gameKey)`).
14
+ * Players enqueue with their skill and region bucket; the DO pairs them, minting a multiplayer session
15
+ * when a full roster forms. An alarm sweeps on a cadence, widening each waiting player's skill band the
16
+ * longer they wait, so a long wait relaxes into any-opponent. Matches are recorded per user for a polling
17
+ * `status()` read, and pushed best-effort to the presence DO.
18
+ *
19
+ * Platform discipline (mirrors the multiplayer session DO): storage is read fresh and Zod-parsed in every
20
+ * handler (no in-memory cache to survive eviction); the sweep is a single persisted alarm at an absolute
21
+ * ms-epoch time (never a timer); and the alarm handler is idempotent (an empty queue clears the alarm and
22
+ * no-ops). `Date.now()` is used inside handlers — permitted in the Workers runtime.
23
+ */
24
+ export interface MatchmakingQueueEnv {
25
+ DB: D1Database;
26
+ SESSIONS?: DurableObjectNamespace;
27
+ PRESENCE?: DurableObjectNamespace;
28
+ }
29
+
30
+ /** A player's entry into the queue — everything the coordinator needs to bucket and later mint. */
31
+ export interface QueueTicket {
32
+ userId: string;
33
+ skill: number | null;
34
+ region: string;
35
+ gameKey: string;
36
+ players: number;
37
+ settings: MatchmakingQueueSettings;
38
+ snapshot: MatchmakingSnapshot;
39
+ }
40
+
41
+ /** A player's standing in the queue. */
42
+ export interface QueueStatus {
43
+ state: "queued" | "matched";
44
+ sessionId: string | null;
45
+ waitingMs: number;
46
+ }
47
+
48
+ /** The minimal presence RPC the coordinator pushes to — declared locally so the queue never imports it. */
49
+ interface PresenceStub {
50
+ notify(userId: string, event: { type: "match_found"; sessionId: string; gameKey: string }): Promise<void>;
51
+ }
52
+
53
+ const WAITING_KEY = "waiting";
54
+ const GAME_KEY = "gameKey";
55
+ const PRESENCE_NAME = "presence";
56
+
57
+ const matchKey = (userId: string): string => `match:${userId}`;
58
+
59
+ export class MatchmakingQueue extends DurableObject<MatchmakingQueueEnv> {
60
+ /** Add a player to the queue and attempt an immediate match. */
61
+ async enqueue(ticket: QueueTicket): Promise<QueueStatus> {
62
+ const now = Date.now();
63
+
64
+ // A match already waiting for this player wins — return it (status delivers-once, so don't clear here).
65
+ const existing = await this.readMatch(ticket.userId);
66
+ if (existing) return { state: "matched", sessionId: existing.sessionId, waitingMs: 0 };
67
+
68
+ // Remember this coordinator's game for the alarm's later match records and presence pushes.
69
+ await this.ctx.storage.put(GAME_KEY, ticket.gameKey);
70
+
71
+ // Upsert the waiting entry with a fresh wait clock.
72
+ const waiting = (await this.readWaiting()).filter((t) => t.userId !== ticket.userId);
73
+ waiting.push({
74
+ userId: ticket.userId,
75
+ skill: ticket.skill,
76
+ region: ticket.region,
77
+ players: ticket.players,
78
+ snapshot: ticket.snapshot,
79
+ settings: ticket.settings,
80
+ enqueuedAt: now,
81
+ });
82
+
83
+ const { matched, remaining } = await this.applyMatches(waiting, now);
84
+ await this.reconcileAlarm(remaining, now);
85
+
86
+ const mine = matched.get(ticket.userId);
87
+ // The enqueue response reports the match as a convenience; the stored copy stays so a client that polls
88
+ // status() instead still receives it (status is the deliver-once channel). A join is idempotent, so a
89
+ // client that reads both channels simply joins the same session twice with no ill effect.
90
+ if (mine) return { state: "matched", sessionId: mine.sessionId, waitingMs: 0 };
91
+ return { state: "queued", sessionId: null, waitingMs: 0 };
92
+ }
93
+
94
+ /** Remove a player from the queue. */
95
+ async leave(userId: string): Promise<void> {
96
+ const waiting = await this.readWaiting();
97
+ const remaining = waiting.filter((t) => t.userId !== userId);
98
+ if (remaining.length === waiting.length) return;
99
+ await this.ctx.storage.put(WAITING_KEY, remaining);
100
+ if (remaining.length === 0) await this.ctx.storage.deleteAlarm();
101
+ }
102
+
103
+ /** A player's current standing — returns and clears a match once found. */
104
+ async status(userId: string): Promise<QueueStatus> {
105
+ // guardRpc encodes the not-queued PithyError so its 404 survives the DO RPC boundary (else it 500s).
106
+ return guardRpc(async () => {
107
+ const match = await this.readMatch(userId);
108
+ if (match) {
109
+ // Deliver-once: hand back the match and drop it so a re-poll reflects the (now empty) standing.
110
+ await this.ctx.storage.delete(matchKey(userId));
111
+ return { state: "matched", sessionId: match.sessionId, waitingMs: 0 };
112
+ }
113
+ const mine = (await this.readWaiting()).find((t) => t.userId === userId);
114
+ if (mine) return { state: "queued", sessionId: null, waitingMs: Date.now() - mine.enqueuedAt };
115
+ throw new MatchmakingNotQueuedError({ detail: `User ${userId} is not queued for this game.` });
116
+ });
117
+ }
118
+
119
+ /** The sweep: re-attempt pairing with bands widened by elapsed wait, mint and record any formed rosters. */
120
+ override async alarm(): Promise<void> {
121
+ const now = Date.now();
122
+ const waiting = await this.readWaiting();
123
+ if (waiting.length === 0) {
124
+ await this.ctx.storage.deleteAlarm();
125
+ return;
126
+ }
127
+ const { remaining } = await this.applyMatches(waiting, now);
128
+ await this.reconcileAlarm(remaining, now);
129
+ }
130
+
131
+ // --- internals ---
132
+
133
+ /** The waiting queue, read fresh and validated. Empty when nothing is stored. */
134
+ private async readWaiting(): Promise<WaitingTicket[]> {
135
+ const raw = await this.ctx.storage.get(WAITING_KEY);
136
+ return raw === undefined ? [] : WaitingList.parse(raw);
137
+ }
138
+
139
+ /** This player's recorded match, read fresh and validated, or undefined. */
140
+ private async readMatch(userId: string): Promise<StoredMatch | undefined> {
141
+ const raw = await this.ctx.storage.get(matchKey(userId));
142
+ return raw === undefined ? undefined : StoredMatch.parse(raw);
143
+ }
144
+
145
+ private async readGameKey(): Promise<string> {
146
+ const raw = await this.ctx.storage.get(GAME_KEY);
147
+ return typeof raw === "string" ? raw : "";
148
+ }
149
+
150
+ /**
151
+ * Run the pairing rule over `waiting`, mint a session per formed roster (or null when `SESSIONS` is not
152
+ * bound), record each matched player's result, and persist the players left waiting. Presence is always
153
+ * pushed best-effort for a match that minted a session — whether it formed on enqueue or on the sweep —
154
+ * so a waiting player is notified in real time in the common two-player case, not only by polling.
155
+ */
156
+ private async applyMatches(
157
+ waiting: WaitingTicket[],
158
+ now: number,
159
+ ): Promise<{ matched: Map<string, StoredMatch>; remaining: WaitingTicket[] }> {
160
+ const gameKey = await this.readGameKey();
161
+ const matched = new Map<string, StoredMatch>();
162
+
163
+ for (const { roster } of formMatches(waiting, now)) {
164
+ const anchor = waiting.find((t) => t.userId === roster[0]);
165
+ if (!anchor) continue;
166
+ const sessionId = this.env.SESSIONS
167
+ ? await sessionMinter(this.env.SESSIONS as unknown as SessionNamespace).mint(
168
+ anchor.snapshot,
169
+ anchor.players,
170
+ roster,
171
+ )
172
+ : null;
173
+ const record: StoredMatch = { sessionId, roster: [...roster], gameKey, at: now };
174
+ for (const uid of roster) matched.set(uid, record);
175
+ }
176
+
177
+ const remaining = waiting.filter((t) => !matched.has(t.userId));
178
+ for (const [uid, record] of matched) await this.ctx.storage.put(matchKey(uid), record);
179
+ await this.ctx.storage.put(WAITING_KEY, remaining);
180
+ await this.pushPresence(matched);
181
+
182
+ return { matched, remaining };
183
+ }
184
+
185
+ /** Push each newly matched player a `match_found` event — best-effort, never fatal, only if presence is bound. */
186
+ private async pushPresence(matched: Map<string, StoredMatch>): Promise<void> {
187
+ const presence = this.env.PRESENCE;
188
+ if (!presence) return;
189
+ const stub = presence.get(presence.idFromName(PRESENCE_NAME)) as unknown as PresenceStub;
190
+ for (const [uid, record] of matched) {
191
+ if (!record.sessionId) continue;
192
+ try {
193
+ await stub.notify(uid, { type: "match_found", sessionId: record.sessionId, gameKey: record.gameKey });
194
+ } catch {
195
+ // A notification never fails the match that formed it — the result is already recorded for polling.
196
+ }
197
+ }
198
+ }
199
+
200
+ /** Keep a single sweep alarm armed while anyone is waiting; clear it once the queue drains. */
201
+ private async reconcileAlarm(remaining: WaitingTicket[], now: number): Promise<void> {
202
+ const first = remaining[0];
203
+ if (first) {
204
+ await this.ctx.storage.setAlarm(now + first.settings.sweepSeconds * 1000);
205
+ } else {
206
+ await this.ctx.storage.deleteAlarm();
207
+ }
208
+ }
209
+ }
@@ -0,0 +1,124 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { MatchmakingQueueSettings, MatchmakingSnapshot } from "../config/config";
6
+
7
+ /**
8
+ * The open-queue matching core — the pure pairing rule and the shapes the {@link MatchmakingQueue} Durable
9
+ * Object persists. Kept here, free of any Workers runtime import, so the algorithm is unit-testable in a
10
+ * plain Node context and the DO shell simply drives it.
11
+ *
12
+ * A waiting player carries their skill and region bucket plus the settings that govern how their skill band
13
+ * widens with time. {@link formMatches} is deterministic in `(waiting, now)`: the longer a player waits, the
14
+ * wider their acceptable opponent skill window, until — past `maxWaitSeconds` — it opens to anyone in their
15
+ * region. Players in different regions never pair; an unrated player (`skill` null) pairs with anyone.
16
+ */
17
+
18
+ /** One player waiting in a game's open queue — a single entry in the DO's persisted `waiting` array. */
19
+ export const WaitingTicket = z
20
+ .object({
21
+ userId: z.string().describe("The waiting player's authenticated user id — the roster and match key."),
22
+ skill: z
23
+ .number()
24
+ .nullable()
25
+ .describe("The player's skill number in the game's rating pool, or null when unrated (pairs with anyone)."),
26
+ region: z.string().describe("The region bucket the player is pinned to — pairing never crosses regions."),
27
+ players: z.number().int().describe("How many players form a match in this game — the roster size to fill."),
28
+ snapshot: MatchmakingSnapshot.describe("How to mint the multiplayer session once this ticket's roster fills."),
29
+ settings: MatchmakingQueueSettings.describe("The band settings that govern how this ticket's skill window widens."),
30
+ enqueuedAt: z
31
+ .number()
32
+ .int()
33
+ .describe("When the player entered the queue, ms epoch — the age its band widens from."),
34
+ })
35
+ .describe("A player waiting in a game's open queue.");
36
+ export type WaitingTicket = z.output<typeof WaitingTicket>;
37
+
38
+ /** The DO's persisted queue — every player currently waiting in this game. */
39
+ export const WaitingList = z.array(WaitingTicket).describe("Every player currently waiting in a game's open queue.");
40
+ export type WaitingList = z.output<typeof WaitingList>;
41
+
42
+ /** A formed match recorded per player, read once by a polling `status()` and pushed best-effort to presence. */
43
+ export const StoredMatch = z
44
+ .object({
45
+ sessionId: z
46
+ .string()
47
+ .nullable()
48
+ .describe("The minted multiplayer session id, or null when @pithy-sh/multiplayer is not installed."),
49
+ roster: z.array(z.string()).describe("The user ids paired into this match, creator first."),
50
+ gameKey: z.string().describe("The game this match belongs to — carried for the presence `match_found` push."),
51
+ at: z.number().int().describe("When the match formed, ms epoch."),
52
+ })
53
+ .describe("A formed match recorded for one player, delivered once on the next status read.");
54
+ export type StoredMatch = z.output<typeof StoredMatch>;
55
+
56
+ /** A single formed roster — the user ids that pair into one match. */
57
+ export interface MatchGroup {
58
+ roster: string[];
59
+ }
60
+
61
+ /**
62
+ * A ticket's current skill band half-width at `now`. An unrated player has no band (they pair with anyone).
63
+ * Otherwise the band starts at `initialBand` and grows by `widenPerSecond` for every second waited, until it
64
+ * opens without bound once the wait passes `maxWaitSeconds`.
65
+ */
66
+ function band(ticket: WaitingTicket, now: number): number {
67
+ if (ticket.skill === null) return Number.POSITIVE_INFINITY;
68
+ const elapsedMs = now - ticket.enqueuedAt;
69
+ const { initialBand, widenPerSecond, maxWaitSeconds } = ticket.settings;
70
+ if (elapsedMs >= maxWaitSeconds * 1000) return Number.POSITIVE_INFINITY;
71
+ return initialBand + widenPerSecond * (elapsedMs / 1000);
72
+ }
73
+
74
+ /**
75
+ * Whether two waiting players may pair right now. Either being unrated pairs them; otherwise their skill gap
76
+ * must fit inside the wider of the two bands — so the longer-waiting player's relaxed window carries the pair.
77
+ */
78
+ function compatible(a: WaitingTicket, b: WaitingTicket, now: number): boolean {
79
+ if (a.skill === null || b.skill === null) return true;
80
+ return Math.abs(a.skill - b.skill) <= Math.max(band(a, now), band(b, now));
81
+ }
82
+
83
+ /**
84
+ * Form as many full rosters as the waiting set allows at `now`. Pure and deterministic. Players are bucketed
85
+ * by region (a pair never crosses regions); within a bucket the oldest waiter anchors a roster — its band is
86
+ * widest — and compatible others (in wait order) fill it to `players`. A roster that cannot fill leaves its
87
+ * anchor waiting; the sweep alarm retries later with a wider band.
88
+ */
89
+ export function formMatches(waiting: WaitingTicket[], now: number): MatchGroup[] {
90
+ const results: MatchGroup[] = [];
91
+
92
+ const byRegion = new Map<string, WaitingTicket[]>();
93
+ for (const ticket of waiting) {
94
+ const bucket = byRegion.get(ticket.region);
95
+ if (bucket) bucket.push(ticket);
96
+ else byRegion.set(ticket.region, [ticket]);
97
+ }
98
+
99
+ for (const bucket of byRegion.values()) {
100
+ let pool = [...bucket].sort((a, b) => a.enqueuedAt - b.enqueuedAt);
101
+ while (pool.length > 0) {
102
+ const anchor = pool[0];
103
+ if (!anchor) break;
104
+ const need = anchor.players;
105
+ const roster: WaitingTicket[] = [anchor];
106
+ for (let i = 1; i < pool.length && roster.length < need; i++) {
107
+ const candidate = pool[i];
108
+ // Compatible with EVERY current roster member, not just the anchor — so an N-player roster never
109
+ // seats two members whose skills exceed each other's band.
110
+ if (candidate && roster.every((member) => compatible(member, candidate, now))) roster.push(candidate);
111
+ }
112
+ if (roster.length === need) {
113
+ results.push({ roster: roster.map((ticket) => ticket.userId) });
114
+ const chosen = new Set(roster);
115
+ pool = pool.filter((ticket) => !chosen.has(ticket));
116
+ } else {
117
+ // The oldest waiter cannot fill a roster yet — leave it and try the next.
118
+ pool = pool.slice(1);
119
+ }
120
+ }
121
+ }
122
+
123
+ return results;
124
+ }
@@ -0,0 +1,23 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+
6
+ /**
7
+ * Read a player's skill number in a rating pool via the optional `@pithy-sh/rating` seam
8
+ * (dynamic-imported). Returns `null` when rating is not installed, the pool is empty, or the player is
9
+ * unrated — the queue then buckets that player by region only. Never throws for an absent dependency; a
10
+ * missing skill is a `null`, not an error.
11
+ */
12
+ export async function readSkill(db: D1Database, pool: string, userId: string): Promise<number | null> {
13
+ try {
14
+ const { ratingStore } = await import("@pithy-sh/rating/src/data/store");
15
+ const { ratingDatabase } = await import("@pithy-sh/rating/src/data/tables");
16
+ const record = await ratingStore(ratingDatabase(db)).get(pool, userId);
17
+ return record?.skill ?? null;
18
+ } catch {
19
+ // Rating is an optional peer: a missing package, an unmigrated pool, or an unrated player is a `null`,
20
+ // never a failure — the queue falls back to region-only bucketing.
21
+ return null;
22
+ }
23
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,42 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ErrorPayload } from "@pithy-sh/core/src/error/payload";
5
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
6
+
7
+ /**
8
+ * The Durable Object RPC error codec — the same shape multiplayer uses. A `PithyError` thrown inside a DO
9
+ * is flattened to a bare `Error` across the RPC boundary (only its `message` survives), which would turn a
10
+ * deliberate 404 into a 500. The DO encodes the payload into the message; the route decodes it back into a
11
+ * real `PithyError` so its status and code are preserved.
12
+ */
13
+ export const RPC_ERROR_PREFIX = "pithy-error:";
14
+
15
+ /** DO side: run a body, re-encoding any thrown `PithyError` into a message the boundary preserves. */
16
+ export async function guardRpc<T>(run: () => Promise<T>): Promise<T> {
17
+ try {
18
+ return await run();
19
+ } catch (error) {
20
+ if (error instanceof PithyError) throw new Error(RPC_ERROR_PREFIX + JSON.stringify(error.payload));
21
+ throw error;
22
+ }
23
+ }
24
+
25
+ /** Route side: run a DO call, decoding an encoded `PithyError` message back into a real `PithyError`. */
26
+ export async function callRpc<T>(run: () => Promise<T>): Promise<T> {
27
+ try {
28
+ return await run();
29
+ } catch (error) {
30
+ if (error instanceof PithyError) throw error;
31
+ const message = (error as { message?: unknown } | null)?.message;
32
+ if (typeof message === "string" && message.startsWith(RPC_ERROR_PREFIX)) {
33
+ try {
34
+ const parsed = ErrorPayload.safeParse(JSON.parse(message.slice(RPC_ERROR_PREFIX.length)));
35
+ if (parsed.success) throw new PithyError(parsed.data, { cause: error });
36
+ } catch (reviveError) {
37
+ if (reviveError instanceof PithyError) throw reviveError;
38
+ }
39
+ }
40
+ throw error;
41
+ }
42
+ }
@@ -0,0 +1,51 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { EXAMPLE_ADA, EXAMPLE_ALAN, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
5
+ import { d1SeedGroup, defineSeed, type SeedSet } from "@pithy-sh/core/src/seed/seed";
6
+ import { Friendship } from "../data/friend";
7
+ import { Invite } from "../data/invite";
8
+ import { MATCHMAKING_FRIENDS_TABLE, MATCHMAKING_INVITES_TABLE } from "../data/tables";
9
+
10
+ /**
11
+ * A small social graph among the example cast: Ada and Grace are friends, and Alan has a pending invite
12
+ * out to Ada. Enough to read a `/matchmaking/friends` and `/matchmaking/invites` response against right
13
+ * after `pithy seed`. Never runs in production.
14
+ */
15
+ const MATCHMAKING_EXAMPLE_SEED_ORDER = 220;
16
+ const now = () => new Date();
17
+
18
+ // Friend pairs are stored canonically (userA < userB); sort the two ids so the seed matches the store.
19
+ const [friendA, friendB] = [EXAMPLE_ADA.id, EXAMPLE_GRACE.id].sort();
20
+
21
+ export const matchmakingExampleSeed: SeedSet = defineSeed({
22
+ name: "example",
23
+ order: MATCHMAKING_EXAMPLE_SEED_ORDER,
24
+ environments: ["dev", "staging"],
25
+ example: true,
26
+ d1: [
27
+ d1SeedGroup("app", MATCHMAKING_FRIENDS_TABLE, Friendship, [
28
+ {
29
+ id: 1,
30
+ userA: friendA ?? EXAMPLE_ADA.id,
31
+ userB: friendB ?? EXAMPLE_GRACE.id,
32
+ status: "accepted",
33
+ requestedBy: EXAMPLE_ADA.id,
34
+ createdAt: now(),
35
+ updatedAt: now(),
36
+ },
37
+ ]),
38
+ d1SeedGroup("app", MATCHMAKING_INVITES_TABLE, Invite, [
39
+ {
40
+ id: "11111111-1111-4111-8111-111111111111",
41
+ gameKey: "duel",
42
+ inviterId: EXAMPLE_ALAN.id,
43
+ inviteeId: EXAMPLE_ADA.id,
44
+ status: "pending",
45
+ sessionId: null,
46
+ createdAt: now(),
47
+ respondedAt: null,
48
+ },
49
+ ]),
50
+ ],
51
+ });