@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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/docs/costs.md +49 -0
- package/package.json +64 -0
- package/pithy.manifest.json +56 -0
- package/src/capability.ts +96 -0
- package/src/cloudflare-test.d.ts +17 -0
- package/src/code/room.ts +136 -0
- package/src/config/config.ts +160 -0
- package/src/data/friend.ts +31 -0
- package/src/data/invite.ts +34 -0
- package/src/data/tables.ts +38 -0
- package/src/error/errors.ts +165 -0
- package/src/friends/store.ts +146 -0
- package/src/http/guard.ts +24 -0
- package/src/http/routes.ts +301 -0
- package/src/http/schemas.ts +72 -0
- package/src/index.ts +45 -0
- package/src/invite/resolve.ts +71 -0
- package/src/invite/store.ts +105 -0
- package/src/kv/rooms.ts +42 -0
- package/src/migrations/0001_matchmaking.ts +64 -0
- package/src/presence/durableObject.ts +114 -0
- package/src/presence/protocol.ts +17 -0
- package/src/queue/durableObject.ts +209 -0
- package/src/queue/matching.ts +124 -0
- package/src/queue/skill.ts +23 -0
- package/src/rpc.ts +42 -0
- package/src/seeds/example.ts +51 -0
- package/src/session/minter.ts +79 -0
- package/src/testWorker.ts +14 -0
- package/src/version.generated.ts +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pithy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @pithy-sh/matchmaking
|
|
2
|
+
|
|
3
|
+
Find competitors. Room codes, direct invites, a friend graph, and a skill-bucketed queue — every path lands two or more players in the same authoritative [`@pithy-sh/multiplayer`](../multiplayer) session.
|
|
4
|
+
|
|
5
|
+
Multiplayer gives you a session once players are in it. This is how they get there. Four ways, one output: a session id.
|
|
6
|
+
|
|
7
|
+
**Documentation: [pithy.sh/docs/capabilities/matchmaking](https://pithy.sh/docs/capabilities/matchmaking).** Overview, adding it, provisioning, using it, and the reference: the four ways in, the queue and its sweep.
|
|
8
|
+
|
|
9
|
+
_Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
|
|
10
|
+
|
|
11
|
+
## Add it
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pithy add matchmaking
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That installs the package and writes four bindings into every environment of your Worker's `wrangler.jsonc` — `DB`, `MATCHMAKING`, and the `QUEUE` and `PRESENCE` Durable Object namespaces — together with the `new_sqlite_classes` class migration tags the two Durable Objects need. **That wiring is the reason this is a capability rather than a snippet.** A DO binding is one entry; a DO class migration tag is another, in a different block, repeated per environment, and getting it wrong deploys a Worker whose class does not exist.
|
|
18
|
+
|
|
19
|
+
One binding it writes without an id: a `kv_namespaces` entry has no name field, so `pithy add` prints the name to give the `MATCHMAKING` namespace in each environment. Create it in your account under that name and paste the id in.
|
|
20
|
+
|
|
21
|
+
`pithy add` also writes both classes into your worker entry, so wrangler's `class_name` resolves against it:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
export { MatchmakingQueue } from "@pithy-sh/matchmaking/src/queue/durableObject";
|
|
25
|
+
export { MatchmakingPresence } from "@pithy-sh/matchmaking/src/presence/durableObject";
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Each from its own module, never from the package entry point. That entry point is what `pithy.config.ts` imports, and that file is loaded by every Node-side `pithy` command — a Durable Object on that path imports `cloudflare:workers` and takes `pithy upgrade` down with it.
|
|
29
|
+
|
|
30
|
+
Finally:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pithy migrate
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**This section stays here.** `src/capability.test.ts` holds those two export lines against the Durable Object classes the capability actually binds, and against the module each must come from. An instruction naming the package entry point would name a module that does not export them — a Worker that fails at bundle time, on the line the docs told somebody to write.
|
|
37
|
+
|
|
38
|
+
## License
|
|
39
|
+
|
|
40
|
+
MIT — adopter-side app value. The root `LICENSE` covers it.
|
package/docs/costs.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Matchmaking costs
|
|
2
|
+
|
|
3
|
+
_The reader's version of this page is [pithy.sh/docs/build/games/what-a-queue-costs](https://pithy.sh/docs/build/games/what-a-queue-costs). This copy ships in the package because `packages/cli/src/capabilities/catalog.ts` sends an adopter to it by name._
|
|
4
|
+
|
|
5
|
+
Two Durable Objects, one KV prefix, two D1 tables. Cloudflare's [Durable Objects pricing](https://developers.cloudflare.com/durable-objects/platform/pricing/) is the authority for the first, and when the two disagree Cloudflare is right. This page does not restate rates — it says how a pairing layer maps onto them, and names the one setting that changes the answer.
|
|
6
|
+
|
|
7
|
+
[`@pithy-sh/multiplayer`'s costs page](../../multiplayer/docs/costs.md) already records the two platform facts both capabilities inherit, and they are not repeated here: **WebSocket messages bill at a 20:1 ratio**, and **duration bills the full 128 MB, only while an object is awake**. Read that page first. What follows is what matchmaking adds.
|
|
8
|
+
|
|
9
|
+
## The presence object is a hibernating socket, and that is the whole cost model
|
|
10
|
+
|
|
11
|
+
Presence is one Durable Object holding every online player's WebSocket through the Hibernation API. A hibernating object bills no duration, so a thousand players sitting in a menu with the app open cost nothing to hold — you pay for the events that actually flow, discounted 20:1.
|
|
12
|
+
|
|
13
|
+
That is only true because of what the object refuses to do. It keeps no in-memory connection registry (the live set is read back from `getWebSockets()`), stashes the authenticated identity with `serializeAttachment` rather than in a field, and never calls `ws.accept()` or sets a timer. A single `setInterval` anywhere in it would pin it in memory and bill duration continuously, for every player online, forever.
|
|
14
|
+
|
|
15
|
+
It is one shared object, so its ceiling is a single object's: a soft **~1,000 requests/second**. Notifications do not approach that, but it is the number to plan against — it is a throughput ceiling, not a bill.
|
|
16
|
+
|
|
17
|
+
## The queue object bills for waiting, and `sweepSeconds` is the dial
|
|
18
|
+
|
|
19
|
+
The open queue is one Durable Object per game, and it is the only thing here that wakes up on its own. While anyone is waiting, a single alarm is armed at `now + sweepSeconds`; the alarm wakes the object, re-attempts pairing, widens every waiting player's skill band, and re-arms. Each wake is a request, plus the duration of a short handler.
|
|
20
|
+
|
|
21
|
+
So the cost of an idle queue is zero and the cost of a *waiting* queue is `sweepSeconds`:
|
|
22
|
+
|
|
23
|
+
| `sweepSeconds` | Wakes per waiting minute, per game | What it buys |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| 1 | 60 | Bands widen almost continuously. Pairing latency is dominated by who arrives, not by the sweep. |
|
|
26
|
+
| 5 (default) | 12 | A player waits at most 5 seconds past the moment their band grew wide enough. |
|
|
27
|
+
| 15 | 4 | A fifth of the default's wakes. Noticeable on a thin queue where the band is what unblocks the match. |
|
|
28
|
+
|
|
29
|
+
**An empty queue clears its alarm.** The handler deletes the alarm the moment the waiting list drains, and the last player to leave deletes it too — so a game nobody is queueing for wakes zero times and bills nothing, whatever `sweepSeconds` says. That is why the number is a per-waiting-minute rate rather than a standing charge.
|
|
30
|
+
|
|
31
|
+
Two things are not in the table because they do not change with it. A match forms on `enqueue` when an opponent is already waiting, without waiting for a sweep — the sweep exists to widen bands, not to pair. And storage is read fresh and Zod-parsed per handler by design: an eviction costs a read, and holding state in memory to avoid it would cost the hibernation that makes the whole object cheap.
|
|
32
|
+
|
|
33
|
+
**One object per game, addressed by game key.** Ten games are ten coordinators, each with its own alarm and its own waiting list, and each idle one bills nothing. That is also the sharding: a game's pairing throughput is one object's, so a game expecting more than a single object can carry wants splitting into region- or bracket-keyed games rather than a bigger `sweepSeconds`.
|
|
34
|
+
|
|
35
|
+
## Room codes are KV, and priced like it
|
|
36
|
+
|
|
37
|
+
A room code is one KV write when the room opens, one read per join attempt, and one write per redemption to decrement the counter. It expires on its TTL at no cost — nothing sweeps it, and there is no row left behind.
|
|
38
|
+
|
|
39
|
+
`ttlSeconds` does not change the bill; `maxUses` bounds it, since it caps the redeem writes one shared code can cause. KV was chosen here for exactly this shape: a short-lived pointer with a TTL and a counter is what the store is for, and it is the only thing in this capability that looks like that.
|
|
40
|
+
|
|
41
|
+
## D1 is two small tables
|
|
42
|
+
|
|
43
|
+
Invites and the friend graph, billed as rows read and written like every other D1 table. An invite is a row per invitation, resolved by an index on the invitee; a friendship is one row per pair, indexed from both sides so a lookup is one read rather than a scan. Neither grows with play — they grow with the social graph, which is a much slower number.
|
|
44
|
+
|
|
45
|
+
## What to watch
|
|
46
|
+
|
|
47
|
+
- **`sweepSeconds`, per game.** The only setting on this page that bills while nothing is happening. Raise it for a thin queue that pairs on arrival anyway; lower it only where band-widening is genuinely what pairs people.
|
|
48
|
+
- **A presence connection that never closes.** Hibernation makes an idle socket free, not a leaked one — a client that reconnects without closing leaves sockets in the set for the object to iterate on every push.
|
|
49
|
+
- **Chatty presence traffic.** Three event types ship, all server-pushed and small. Adding a client-driven heartbeat over this socket would put its messages through the 20:1 ratio and the object's request ceiling both; use the platform's own ping instead.
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pithy-sh/matchmaking",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/pithy-sh/pithy.git",
|
|
8
|
+
"directory": "packages/matchmaking"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"pithy.manifest.json",
|
|
13
|
+
"docs",
|
|
14
|
+
"!src/**/*.test.*"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
"./src/*": "./src/*.ts"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.json --noEmit false --outDir dist",
|
|
25
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:node": "vitest run --project=node",
|
|
28
|
+
"test:workers": "vitest run --project=workers",
|
|
29
|
+
"clean": "rm -rf dist .turbo",
|
|
30
|
+
"reset": "bun run clean && rm -rf node_modules"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@cloudflare/workers-types": "^5.20260729.1",
|
|
34
|
+
"@hono/zod-validator": "^0.9.0",
|
|
35
|
+
"@pithy-sh/core": "workspace:*",
|
|
36
|
+
"hono": "^4.13.2",
|
|
37
|
+
"kysely": "^0.29.0",
|
|
38
|
+
"zod": "^4.0.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@pithy-sh/auth": "workspace:*",
|
|
42
|
+
"@pithy-sh/rating": "workspace:*"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@pithy-sh/auth": {
|
|
46
|
+
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"@pithy-sh/rating": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@cloudflare/vitest-plugin": "^1.0.0",
|
|
54
|
+
"@pithy-sh/auth": "workspace:*",
|
|
55
|
+
"@pithy-sh/rating": "workspace:*",
|
|
56
|
+
"@pithy-sh/tsconfig": "workspace:*",
|
|
57
|
+
"@types/node": "^22.15.0",
|
|
58
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
59
|
+
"kysely-d1": "^0.4.0",
|
|
60
|
+
"typescript": "^7.0.2",
|
|
61
|
+
"vitest": "^4.1.0",
|
|
62
|
+
"wrangler": "^4.115.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "matchmaking",
|
|
3
|
+
"package": "@pithy-sh/matchmaking",
|
|
4
|
+
"requiredBindings": [
|
|
5
|
+
{ "type": "d1", "name": "DB" },
|
|
6
|
+
{ "type": "kv", "name": "MATCHMAKING" },
|
|
7
|
+
{
|
|
8
|
+
"type": "durable_object",
|
|
9
|
+
"name": "QUEUE",
|
|
10
|
+
"className": "MatchmakingQueue",
|
|
11
|
+
"classModule": "@pithy-sh/matchmaking/src/queue/durableObject"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"type": "durable_object",
|
|
15
|
+
"name": "PRESENCE",
|
|
16
|
+
"className": "MatchmakingPresence",
|
|
17
|
+
"classModule": "@pithy-sh/matchmaking/src/presence/durableObject"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"peerCapabilities": [],
|
|
21
|
+
"optionalCapabilities": ["auth", "rating", "multiplayer"],
|
|
22
|
+
"migrationNamespace": "matchmaking",
|
|
23
|
+
"whenToEnable": "How players find each other. @pithy-sh/multiplayer gives you an authoritative session once players are in it; this is the four ways they get there, and every one of them ends at a session id. A room code — short, shareable, short-lived, limited-use — for playing with someone who is already beside you. A direct invite by email or screen name, pending until the invitee accepts. A symmetric friend graph formed by mutual accept. And an open queue: one Durable Object per game pairs waiting players, bucketed by Cloudflare's own edge geolocation and by skill read from @pithy-sh/rating, widening each player's skill band the longer they wait until any opponent qualifies. A second Durable Object holds every online player's WebSocket and pushes match-found, invite-received, and friend-request in real time, over the Hibernation API so a connection waiting on nothing bills no duration. Two Durable Objects means two bindings and two class migration tags across every environment, and the CLI writes all of it — that wiring is the reason this is a capability rather than a snippet. Nothing here is a hard dependency: without auth every route is denied, without rating the queue buckets by region alone, without multiplayer session minting is off. Add auth at minimum, and read packages/matchmaking/docs/costs.md before production — a presence socket is cheap and a busy queue's alarm cadence is the dial that decides how cheap.",
|
|
24
|
+
"scaffold": [
|
|
25
|
+
"Add a `matchmaking({ games: [...] })` block to pithy.config.ts and declare at least one game.",
|
|
26
|
+
"Give each game a `key`, a `players` count, an optional `skillPool` naming a @pithy-sh/rating pool, and a `snapshot` — the `kind` and `rules` of the @pithy-sh/multiplayer game a formed match is minted into. Tune `roomCodes` and `queue` per game.",
|
|
27
|
+
"The CLI has already written both Durable Object exports into your worker entry: `export { MatchmakingQueue } from \"@pithy-sh/matchmaking/src/queue/durableObject\";` and `export { MatchmakingPresence } from \"@pithy-sh/matchmaking/src/presence/durableObject\";` — wrangler's `class_name` resolves against your worker's `main`. Each comes from its own module, never from the package entry point, which pithy.config.ts loads in Node. No hand-editing.",
|
|
28
|
+
"The CLI has already added the QUEUE and PRESENCE durable_object bindings and their class migration tags (`new_sqlite_classes`) to wrangler.jsonc for every environment. No hand-editing.",
|
|
29
|
+
"Bind a D1 database named DB in wrangler.jsonc — the same app database your other capabilities use.",
|
|
30
|
+
"Create the MATCHMAKING KV namespace in your Cloudflare account under the name `pithy add` printed for each environment, and paste its id into the entry add already wrote. A kv_namespaces entry has no name field, so the title can only live in the account. Room codes live there under a TTL; nothing else does.",
|
|
31
|
+
"Run `pithy migrate` to create pithy_matchmaking_invites and pithy_matchmaking_friends.",
|
|
32
|
+
"Add `@pithy-sh/auth` if it is not already installed. Every route binds to an authenticated player; without auth all of them are denied.",
|
|
33
|
+
"Add `@pithy-sh/multiplayer` and bind its SESSIONS Durable Object namespace. Without it every pairing path still runs and none of them can mint the session it exists to produce.",
|
|
34
|
+
"Add `@pithy-sh/rating` and name one of its pools as a game's `skillPool` to bucket the open queue on skill. Without it the queue buckets by region only.",
|
|
35
|
+
"Read packages/matchmaking/docs/costs.md before production. The queue's `sweepSeconds` is the alarm cadence, and it is the one setting that decides whether a waiting queue costs anything."
|
|
36
|
+
],
|
|
37
|
+
"configOptions": [
|
|
38
|
+
{
|
|
39
|
+
"key": "games",
|
|
40
|
+
"default": [
|
|
41
|
+
{ "key": "duel", "snapshot": { "kind": "connect-n", "rules": { "rows": 3, "cols": 3, "connect": 3 } } }
|
|
42
|
+
],
|
|
43
|
+
"describe": "Every game players are matched into. Replace this example — a two-player duel that mints a 3x3 connect-3 multiplayer session, which is the smallest game that actually plays. `key` is a URL path segment; `snapshot` is the multiplayer game the match becomes; add `skillPool` to bucket the queue on a rating pool. At least one game is required."
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"key": "friends",
|
|
47
|
+
"default": true,
|
|
48
|
+
"describe": "Whether the friend graph — request, accept, decline, remove — is mounted. Turn it off for a game where players only ever meet through a code or the queue, and its routes and tables go unused."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"key": "basePath",
|
|
52
|
+
"default": "/matchmaking",
|
|
53
|
+
"describe": "Where the matchmaking routes mount. Rooms, invites, friends, and the queue live under it, and the presence WebSocket is at `<basePath>/presence`."
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { BindingSpecInput } from "@pithy-sh/core/src/capability/bindings";
|
|
5
|
+
import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import type { Migration } from "kysely/migration";
|
|
7
|
+
import { MatchmakingConfig, type MatchmakingConfigInput } from "./config/config";
|
|
8
|
+
import { matchmakingTables } from "./data/tables";
|
|
9
|
+
import { registerMatchmakingRoutes } from "./http/routes";
|
|
10
|
+
import { ROOM_PREFIX, Room, RoomKey } from "./kv/rooms";
|
|
11
|
+
import { matchmaking_0001_matchmaking } from "./migrations/0001_matchmaking";
|
|
12
|
+
import { matchmakingExampleSeed } from "./seeds/example";
|
|
13
|
+
import { PACKAGE_VERSION } from "./version.generated";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where matchmaking's migrations sort in the app database. Unique per database; the registry composes keys
|
|
17
|
+
* like `0700_matchmaking_0001_matchmaking`. Sits after rating (600).
|
|
18
|
+
*/
|
|
19
|
+
export const MATCHMAKING_MIGRATION_ORDER = 700;
|
|
20
|
+
|
|
21
|
+
export type MatchmakingOptions = MatchmakingConfigInput & {
|
|
22
|
+
/** Mount the routes somewhere other than `/matchmaking`. */
|
|
23
|
+
basePath?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface MatchmakingCapability extends Capability {
|
|
27
|
+
matchmakingConfig: MatchmakingConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The matchmaking capability: find competitors and land in an authoritative multiplayer session. Room
|
|
32
|
+
* codes, direct invites, a symmetric friend graph, and an open queue bucketed by region and skill —
|
|
33
|
+
* every path outputs a `@pithy-sh/multiplayer` session id.
|
|
34
|
+
*
|
|
35
|
+
* Optional peers, all reached as seams (never hard `dependsOn`): `@pithy-sh/auth` (identity — reads
|
|
36
|
+
* `c.var.auth`, resolves invite targets), `@pithy-sh/rating` (skill for queue bucketing), and
|
|
37
|
+
* `@pithy-sh/multiplayer` (the `SESSIONS` binding, read at runtime to mint sessions). Absent any of them,
|
|
38
|
+
* matchmaking degrades: denied without auth, region-only queue without rating, session-minting disabled
|
|
39
|
+
* without multiplayer.
|
|
40
|
+
*/
|
|
41
|
+
export function matchmaking(options: MatchmakingOptions = { games: [] }): MatchmakingCapability {
|
|
42
|
+
const { basePath, ...configInput } = options;
|
|
43
|
+
const resolved = MatchmakingConfig.parse(configInput);
|
|
44
|
+
|
|
45
|
+
const migrations: Record<string, Migration> = { "0001_matchmaking": matchmaking_0001_matchmaking };
|
|
46
|
+
|
|
47
|
+
const requiredBindings: BindingSpecInput[] = [
|
|
48
|
+
{ type: "d1", name: "DB" },
|
|
49
|
+
{ type: "kv", name: "MATCHMAKING" },
|
|
50
|
+
{
|
|
51
|
+
type: "durable_object",
|
|
52
|
+
name: "QUEUE",
|
|
53
|
+
className: "MatchmakingQueue",
|
|
54
|
+
classModule: "@pithy-sh/matchmaking/src/queue/durableObject",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: "durable_object",
|
|
58
|
+
name: "PRESENCE",
|
|
59
|
+
className: "MatchmakingPresence",
|
|
60
|
+
classModule: "@pithy-sh/matchmaking/src/presence/durableObject",
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const capability = defineCapability({
|
|
65
|
+
name: "matchmaking",
|
|
66
|
+
// The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
|
|
67
|
+
// cannot read its own package.json. Reported per capability by the control-plane manifest.
|
|
68
|
+
version: PACKAGE_VERSION,
|
|
69
|
+
requiredBindings,
|
|
70
|
+
config: MatchmakingConfig,
|
|
71
|
+
databases: {
|
|
72
|
+
app: {
|
|
73
|
+
binding: "DB",
|
|
74
|
+
tables: matchmakingTables(),
|
|
75
|
+
migrationOrder: MATCHMAKING_MIGRATION_ORDER,
|
|
76
|
+
migrations,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
kvNamespaces: {
|
|
80
|
+
matchmaking: {
|
|
81
|
+
binding: "MATCHMAKING",
|
|
82
|
+
stores: {
|
|
83
|
+
rooms: { prefix: ROOM_PREFIX, key: RoomKey, value: Room },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
routes: registerMatchmakingRoutes({ config: resolved, basePath }),
|
|
88
|
+
seeds: [matchmakingExampleSeed],
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return Object.assign(capability, { matchmakingConfig: resolved });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isMatchmakingCapability(c: Capability): c is MatchmakingCapability {
|
|
95
|
+
return c.name === "matchmaking" && "matchmakingConfig" in c;
|
|
96
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/// <reference types="@cloudflare/vitest-plugin/types" />
|
|
5
|
+
|
|
6
|
+
// Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare
|
|
7
|
+
// config in `vitest.workers.config.ts`. `cloudflare:test` types its `env` as `Cloudflare.Env`, so test
|
|
8
|
+
// bindings are declared by augmenting that interface.
|
|
9
|
+
declare namespace Cloudflare {
|
|
10
|
+
interface Env {
|
|
11
|
+
DB: D1Database;
|
|
12
|
+
MATCHMAKING: KVNamespace;
|
|
13
|
+
QUEUE: DurableObjectNamespace<import("./queue/durableObject").MatchmakingQueue>;
|
|
14
|
+
PRESENCE: DurableObjectNamespace<import("./presence/durableObject").MatchmakingPresence>;
|
|
15
|
+
SESSIONS?: DurableObjectNamespace;
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/code/room.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { KVNamespace } from "@cloudflare/workers-types";
|
|
5
|
+
import type { MatchmakingGame } from "../config/config";
|
|
6
|
+
import { MatchmakingInvalidCodeError, MatchmakingRoomFullError, MatchmakingRoomNotFoundError } from "../error/errors";
|
|
7
|
+
import { roomStore } from "../kv/rooms";
|
|
8
|
+
import type { SessionMinter } from "../session/minter";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Room codes — the zero-discovery, play-with-a-friend path. A host opens a room: a multiplayer session is
|
|
12
|
+
* minted (host as creator) and a short, shareable code (`WXYZ-1234`) is stored in KV pointing at it, with
|
|
13
|
+
* a TTL and a limited-use counter. Others redeem the code to join the same session. See the workflow
|
|
14
|
+
* implementation for the code alphabet (ambiguous characters excluded) and the redeem/decrement logic.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The result of opening a room. */
|
|
18
|
+
export interface RoomCreation {
|
|
19
|
+
/** The shareable join code. */
|
|
20
|
+
code: string;
|
|
21
|
+
/** The multiplayer session the room joins players into. */
|
|
22
|
+
sessionId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The result of redeeming a room code. */
|
|
26
|
+
export interface RoomJoin {
|
|
27
|
+
/** The multiplayer session the joiner was seated into. */
|
|
28
|
+
sessionId: string;
|
|
29
|
+
/** Redemptions the code has left after this join. */
|
|
30
|
+
usesRemaining: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Unambiguous letters for the code prefix — I and O are excluded (they read as 1 and 0). */
|
|
34
|
+
const CODE_LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
35
|
+
/** Unambiguous digits for the code suffix — 0 and 1 are excluded (they read as O and I/L). */
|
|
36
|
+
const CODE_DIGITS = "23456789";
|
|
37
|
+
/** The canonical shape a normalized code takes: four letters, a dash, four digits. */
|
|
38
|
+
const CANONICAL = /^[A-Z]{4}-[0-9]{4}$/;
|
|
39
|
+
/** The same shape without its dash — tolerated on input and repaired to canonical. */
|
|
40
|
+
const DASHLESS = /^[A-Z]{4}[0-9]{4}$/;
|
|
41
|
+
|
|
42
|
+
/** Normalize and validate a raw room code (uppercase, canonical form). Throws `matchmaking/invalid_code`. */
|
|
43
|
+
export function normalizeCode(raw: string): string {
|
|
44
|
+
const cleaned = raw.toUpperCase().replace(/\s+/g, "");
|
|
45
|
+
if (CANONICAL.test(cleaned)) return cleaned;
|
|
46
|
+
if (DASHLESS.test(cleaned)) return `${cleaned.slice(0, 4)}-${cleaned.slice(4)}`;
|
|
47
|
+
throw new MatchmakingInvalidCodeError({ detail: `Malformed room code: "${raw}".` });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Draw `count` characters from `alphabet` using the CSPRNG. */
|
|
51
|
+
function pick(alphabet: string, count: number): string {
|
|
52
|
+
const bytes = new Uint8Array(count);
|
|
53
|
+
crypto.getRandomValues(bytes);
|
|
54
|
+
let out = "";
|
|
55
|
+
for (const byte of bytes) {
|
|
56
|
+
out += alphabet[byte % alphabet.length];
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Generate a fresh canonical room code (`WXYZ-1234`) from the unambiguous alphabets. */
|
|
62
|
+
function generateCode(): string {
|
|
63
|
+
return `${pick(CODE_LETTERS, 4)}-${pick(CODE_DIGITS, 4)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Open a room: mint a session with `hostId` as creator, store a fresh code in KV, return both. */
|
|
67
|
+
export async function createRoom(
|
|
68
|
+
namespace: KVNamespace,
|
|
69
|
+
game: MatchmakingGame,
|
|
70
|
+
minter: SessionMinter,
|
|
71
|
+
hostId: string,
|
|
72
|
+
now: Date,
|
|
73
|
+
): Promise<RoomCreation> {
|
|
74
|
+
const sessionId = await minter.mint(game.snapshot, game.players, [hostId]);
|
|
75
|
+
const ttlSeconds = game.roomCodes.ttlSeconds;
|
|
76
|
+
const store = roomStore(namespace, ttlSeconds);
|
|
77
|
+
|
|
78
|
+
let code = generateCode();
|
|
79
|
+
while ((await store.get({ code })) !== null) {
|
|
80
|
+
code = generateCode();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await store.put(
|
|
84
|
+
{ code },
|
|
85
|
+
{
|
|
86
|
+
code,
|
|
87
|
+
gameKey: game.key,
|
|
88
|
+
hostId,
|
|
89
|
+
sessionId,
|
|
90
|
+
usesRemaining: game.roomCodes.maxUses,
|
|
91
|
+
createdAt: now,
|
|
92
|
+
expiresAt: new Date(now.getTime() + ttlSeconds * 1000),
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return { code, sessionId };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Redeem a code: seat `userId` into the room's session, decrement uses, re-persist with a TTL recomputed
|
|
101
|
+
* from the room's `expiresAt` (so redeeming does not extend the window). The room value carries everything
|
|
102
|
+
* needed — session id, uses, expiry — so no game config is required here. Throws room_not_found/room_full.
|
|
103
|
+
*
|
|
104
|
+
* Concurrency note: this is a read-modify-write over KV, which has no compare-and-swap, so two simultaneous
|
|
105
|
+
* redemptions of the same code can both read the same `usesRemaining` and both decrement it — a code can be
|
|
106
|
+
* redeemed slightly past `maxUses`. This is intentional and harmless: the multiplayer session's own roster
|
|
107
|
+
* cap is the real guard (an extra joiner past the roster is rejected with `multiplayer/session_full`), so an
|
|
108
|
+
* over-redeemed code cannot over-seat a session; the only effect is that a raced code may outlive its use
|
|
109
|
+
* count by a redemption or two. Making it strictly atomic would mean moving room state into D1 (an atomic
|
|
110
|
+
* `UPDATE … WHERE uses > 0`) or a per-code Durable Object; not worth it for this guarantee.
|
|
111
|
+
*/
|
|
112
|
+
export async function joinRoom(
|
|
113
|
+
namespace: KVNamespace,
|
|
114
|
+
minter: SessionMinter,
|
|
115
|
+
code: string,
|
|
116
|
+
userId: string,
|
|
117
|
+
now: Date,
|
|
118
|
+
): Promise<RoomJoin> {
|
|
119
|
+
// A placeholder TTL just to read — the write below rebuilds the store with the correct remaining window.
|
|
120
|
+
const room = await roomStore(namespace, 60).get({ code });
|
|
121
|
+
if (room === null) {
|
|
122
|
+
throw new MatchmakingRoomNotFoundError({ detail: `No room for code "${code}".` });
|
|
123
|
+
}
|
|
124
|
+
if (room.usesRemaining <= 0) {
|
|
125
|
+
throw new MatchmakingRoomFullError({ detail: `Room "${code}" is spent.` });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await minter.join(room.sessionId, userId);
|
|
129
|
+
|
|
130
|
+
// Recompute the TTL from the room's own expiry so redeeming never extends the window. KV's floor is 60.
|
|
131
|
+
const remaining = Math.max(60, Math.ceil((room.expiresAt.getTime() - now.getTime()) / 1000));
|
|
132
|
+
const usesRemaining = room.usesRemaining - 1;
|
|
133
|
+
await roomStore(namespace, remaining).put({ code }, { ...room, usesRemaining });
|
|
134
|
+
|
|
135
|
+
return { sessionId: room.sessionId, usesRemaining };
|
|
136
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The matchmaking capability's config — the thin, user-owned surface in `pithy.config.ts`. Every field is
|
|
8
|
+
* `.describe()`d (CLAUDE.md §Config). A game declares how many players form a match, an optional
|
|
9
|
+
* `@pithy-sh/rating` pool to bucket the open queue on, and the `snapshot` used to mint the
|
|
10
|
+
* `@pithy-sh/multiplayer` session a match resolves into. Room codes, the queue, and abuse protection are
|
|
11
|
+
* configured per game and per capability.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** A game key is a URL path segment, so it is kebab-case and lowercase. */
|
|
15
|
+
const KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
16
|
+
|
|
17
|
+
/** The minimal multiplayer `GameSnapshot` matchmaking needs to mint a session when a match forms. */
|
|
18
|
+
export const MatchmakingSnapshot = z
|
|
19
|
+
.object({
|
|
20
|
+
kind: z
|
|
21
|
+
.string()
|
|
22
|
+
.min(1)
|
|
23
|
+
.describe("The multiplayer game-model kind the session runs — a `kind` registered in @pithy-sh/multiplayer."),
|
|
24
|
+
mode: z
|
|
25
|
+
.enum(["match", "table"])
|
|
26
|
+
.default("match")
|
|
27
|
+
.describe("The multiplayer session lifecycle — a one-off `match` or a long-lived `table`."),
|
|
28
|
+
turnTimeoutMs: z
|
|
29
|
+
.number()
|
|
30
|
+
.int()
|
|
31
|
+
.min(1000)
|
|
32
|
+
.nullable()
|
|
33
|
+
.default(null)
|
|
34
|
+
.describe("The session's per-turn deadline in ms, or null for no deadline. Passed through to the session."),
|
|
35
|
+
rules: z
|
|
36
|
+
.unknown()
|
|
37
|
+
.describe("The multiplayer game-model's `rules` block, passed through to the minted session unchanged."),
|
|
38
|
+
})
|
|
39
|
+
.describe("How to mint the multiplayer session a formed match resolves into.");
|
|
40
|
+
export type MatchmakingSnapshot = z.output<typeof MatchmakingSnapshot>;
|
|
41
|
+
|
|
42
|
+
/** Room-code settings — the zero-discovery, play-with-a-friend path. */
|
|
43
|
+
export const MatchmakingRoomCodes = z
|
|
44
|
+
.object({
|
|
45
|
+
enabled: z.boolean().default(true).describe("Whether a host may open a room and share a join code."),
|
|
46
|
+
ttlSeconds: z
|
|
47
|
+
.number()
|
|
48
|
+
.int()
|
|
49
|
+
.min(60)
|
|
50
|
+
.default(900)
|
|
51
|
+
.describe("How long a room code stays valid, in seconds (KV TTL; floor 60). Default 15 minutes."),
|
|
52
|
+
maxUses: z
|
|
53
|
+
.number()
|
|
54
|
+
.int()
|
|
55
|
+
.positive()
|
|
56
|
+
.default(9)
|
|
57
|
+
.describe("How many times a code may be redeemed before it is spent — bounds abuse of a shared code."),
|
|
58
|
+
})
|
|
59
|
+
.describe("Room-code settings: a short, shareable, short-lived, limited-use join code.");
|
|
60
|
+
export type MatchmakingRoomCodes = z.output<typeof MatchmakingRoomCodes>;
|
|
61
|
+
|
|
62
|
+
/** Open-queue settings — the skill/region-bucketed pairing coordinator. */
|
|
63
|
+
export const MatchmakingQueueSettings = z
|
|
64
|
+
.object({
|
|
65
|
+
enabled: z.boolean().default(true).describe("Whether players may enqueue for open matchmaking in this game."),
|
|
66
|
+
initialBand: z
|
|
67
|
+
.number()
|
|
68
|
+
.nonnegative()
|
|
69
|
+
.default(100)
|
|
70
|
+
.describe(
|
|
71
|
+
"The initial skill band half-width — a waiting player first matches only others within ±band of their skill.",
|
|
72
|
+
),
|
|
73
|
+
widenPerSecond: z
|
|
74
|
+
.number()
|
|
75
|
+
.nonnegative()
|
|
76
|
+
.default(50)
|
|
77
|
+
.describe(
|
|
78
|
+
"How much the skill band widens for every second a player waits — relaxes the match the longer they wait.",
|
|
79
|
+
),
|
|
80
|
+
maxWaitSeconds: z
|
|
81
|
+
.number()
|
|
82
|
+
.int()
|
|
83
|
+
.positive()
|
|
84
|
+
.default(120)
|
|
85
|
+
.describe(
|
|
86
|
+
"After this wait the band is unbounded — the player matches any available opponent in their region bucket.",
|
|
87
|
+
),
|
|
88
|
+
sweepSeconds: z
|
|
89
|
+
.number()
|
|
90
|
+
.int()
|
|
91
|
+
.positive()
|
|
92
|
+
.default(5)
|
|
93
|
+
.describe("How often, in seconds, the coordinator re-attempts pairing and widens bands (its alarm cadence)."),
|
|
94
|
+
})
|
|
95
|
+
.describe("Open-queue pairing settings: how the skill band starts and widens over time.");
|
|
96
|
+
export type MatchmakingQueueSettings = z.output<typeof MatchmakingQueueSettings>;
|
|
97
|
+
|
|
98
|
+
export const MatchmakingGame = z
|
|
99
|
+
.object({
|
|
100
|
+
key: z
|
|
101
|
+
.string()
|
|
102
|
+
.regex(KEY_PATTERN, "A game key is lowercase, digits, and dashes — it is a URL path segment.")
|
|
103
|
+
.describe("The game's stable id, unique across the app. A URL path segment."),
|
|
104
|
+
players: z.number().int().min(2).default(2).describe("How many players form a match in this game (default 2)."),
|
|
105
|
+
skillPool: z
|
|
106
|
+
.string()
|
|
107
|
+
.optional()
|
|
108
|
+
.describe(
|
|
109
|
+
"The @pithy-sh/rating pool to bucket the open queue on. Omit to bucket by region only (no skill matching).",
|
|
110
|
+
),
|
|
111
|
+
snapshot: MatchmakingSnapshot.describe("How the multiplayer session is minted when a match forms."),
|
|
112
|
+
roomCodes: MatchmakingRoomCodes.prefault({}).describe("Room-code settings for this game."),
|
|
113
|
+
queue: MatchmakingQueueSettings.prefault({}).describe("Open-queue settings for this game."),
|
|
114
|
+
})
|
|
115
|
+
.describe("One matchmaking game: its roster size, skill pool, session snapshot, and pairing settings.");
|
|
116
|
+
export type MatchmakingGame = z.output<typeof MatchmakingGame>;
|
|
117
|
+
|
|
118
|
+
/** Room-code join abuse protection — opt-in, off by default. */
|
|
119
|
+
export const MatchmakingAbuse = z
|
|
120
|
+
.object({
|
|
121
|
+
turnstile: z
|
|
122
|
+
.boolean()
|
|
123
|
+
.default(false)
|
|
124
|
+
.describe("Gate room-code join with a Turnstile humanity check (needs @pithy-sh/turnstile). Off by default."),
|
|
125
|
+
rateLimit: z.boolean().default(false).describe("Rate-limit room-code join attempts per user. Off by default."),
|
|
126
|
+
})
|
|
127
|
+
.describe("Opt-in abuse protection for room-code join.");
|
|
128
|
+
export type MatchmakingAbuse = z.output<typeof MatchmakingAbuse>;
|
|
129
|
+
|
|
130
|
+
export const MatchmakingConfig = z
|
|
131
|
+
.object({
|
|
132
|
+
games: z
|
|
133
|
+
.array(MatchmakingGame)
|
|
134
|
+
.min(1, "A matchmaking capability needs at least one game — configure at least one.")
|
|
135
|
+
.describe("The matchmaking games. Each declares its roster, skill pool, and session snapshot."),
|
|
136
|
+
friends: z.boolean().default(true).describe("Whether the friend graph (requests, accepts, removal) is enabled."),
|
|
137
|
+
abuse: MatchmakingAbuse.prefault({}).describe("Room-code join abuse protection (off by default)."),
|
|
138
|
+
})
|
|
139
|
+
.describe("The matchmaking capability's configuration.")
|
|
140
|
+
.check((ctx) => {
|
|
141
|
+
const seen = new Set<string>();
|
|
142
|
+
for (const game of ctx.value.games) {
|
|
143
|
+
if (seen.has(game.key)) {
|
|
144
|
+
ctx.issues.push({
|
|
145
|
+
code: "custom",
|
|
146
|
+
input: game.key,
|
|
147
|
+
path: ["games"],
|
|
148
|
+
message: `Duplicate game key "${game.key}" — each game key must be unique.`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
seen.add(game.key);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
export type MatchmakingConfig = z.output<typeof MatchmakingConfig>;
|
|
155
|
+
export type MatchmakingConfigInput = z.input<typeof MatchmakingConfig>;
|
|
156
|
+
|
|
157
|
+
/** The configured game for a key, or undefined. */
|
|
158
|
+
export function resolveGame(config: MatchmakingConfig, key: string): MatchmakingGame | undefined {
|
|
159
|
+
return config.games.find((g) => g.key === key);
|
|
160
|
+
}
|