@thegeem/protocol 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/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @thegeem/protocol
2
+
3
+ The **wire contract** for [Geem](https://geem.tv) — jackbox.tv-style multiplayer trivia.
4
+
5
+ Shared **zod schemas + TypeScript types** for talking to the Geem game server over a
6
+ single Socket.IO connection: `ClientMsg` in (validated), `ServerMsg` out (authoritative).
7
+ This package is the **source of truth** every client (web + native) implements against.
8
+
9
+ The server is the referee (server-authoritative): clients send *inputs* and *render* the
10
+ authoritative state the server pushes. There is no REST gameplay API — this protocol is the
11
+ only gate.
12
+
13
+ ```ts
14
+ import { ClientMsg, ServerMsg, PROTOCOL_VERSION } from '@thegeem/protocol';
15
+
16
+ // validate anything you send
17
+ const msg = ClientMsg.parse({ t: 'joinRoom', code: 'ABCD', name: 'Sara', pv: PROTOCOL_VERSION });
18
+ ```
19
+
20
+ ## Versioning
21
+
22
+ `PROTOCOL_VERSION` is a breaking-change counter. Clients send `pv` on entry; the server
23
+ rejects `pv < PROTOCOL_VERSION` with `client_outdated`. The npm package version is separate
24
+ (normal semver for the package itself).
25
+
26
+ UNLICENSED — published for Geem's own clients; not for third-party use.
package/dist/index.cjs ADDED
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ ClientMsg: () => ClientMsg,
24
+ GAME_IDS: () => GAME_IDS,
25
+ LIMITS: () => LIMITS,
26
+ PROTOCOL_VERSION: () => PROTOCOL_VERSION,
27
+ ROOM_ALPHABET: () => ROOM_ALPHABET
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+ var import_zod = require("zod");
31
+ var LIMITS = {
32
+ MAX_NAME_LEN: 20,
33
+ ROOM_CODE_LEN: 4,
34
+ MAX_PLAYERS: 12,
35
+ MAX_MSG_PER_SEC: 8
36
+ };
37
+ var PROTOCOL_VERSION = 1;
38
+ var GAME_IDS = ["trivia"];
39
+ var ROOM_ALPHABET = "ACDEFGHJKMNPQRTUVWXY2346789";
40
+ var UNSAFE_CHARS = /[\p{Cc}<>]/gu;
41
+ var DisplayName = import_zod.z.string().trim().min(1).max(LIMITS.MAX_NAME_LEN).transform((s) => s.replace(UNSAFE_CHARS, "").trim()).refine((s) => s.length >= 1, "name is empty after sanitizing");
42
+ var RoomCode = import_zod.z.string().trim().toUpperCase().regex(new RegExp(`^[${ROOM_ALPHABET}]{${LIMITS.ROOM_CODE_LEN}}$`), "bad room code");
43
+ var PlayerId = import_zod.z.string().max(40);
44
+ var HelpType = import_zod.z.enum([
45
+ "removeTwoAnswers",
46
+ "changeQuestion",
47
+ "extraTime",
48
+ "doublePoints",
49
+ "stealPoints",
50
+ "restPlayer",
51
+ "tripLevel"
52
+ ]);
53
+ var Pv = import_zod.z.number().int().min(1).max(1e6).optional();
54
+ var IdToken = import_zod.z.string().max(4096).optional();
55
+ var ClientMsg = import_zod.z.discriminatedUnion("t", [
56
+ // room / lobby
57
+ import_zod.z.object({ t: import_zod.z.literal("createRoom"), name: DisplayName.optional(), pv: Pv, idToken: IdToken }),
58
+ import_zod.z.object({ t: import_zod.z.literal("joinRoom"), code: RoomCode, name: DisplayName, pv: Pv, idToken: IdToken }),
59
+ import_zod.z.object({ t: import_zod.z.literal("resume"), code: RoomCode, token: import_zod.z.string().max(120), pv: Pv, idToken: IdToken }),
60
+ import_zod.z.object({ t: import_zod.z.literal("leaveRoom") }),
61
+ import_zod.z.object({ t: import_zod.z.literal("approve"), playerId: PlayerId }),
62
+ import_zod.z.object({ t: import_zod.z.literal("reject"), playerId: PlayerId }),
63
+ import_zod.z.object({ t: import_zod.z.literal("kick"), playerId: PlayerId }),
64
+ // host game actions
65
+ import_zod.z.object({
66
+ t: import_zod.z.literal("startGame"),
67
+ game: import_zod.z.enum(GAME_IDS).optional(),
68
+ // which game to play; defaults to 'trivia'
69
+ totalRounds: import_zod.z.number().int().min(1).max(50),
70
+ categoryIds: import_zod.z.array(import_zod.z.string().max(60)).min(1).max(12)
71
+ }),
72
+ import_zod.z.object({ t: import_zod.z.literal("pickLevel"), categoryId: import_zod.z.string().max(60), level: import_zod.z.number().int().min(1).max(6) }),
73
+ import_zod.z.object({ t: import_zod.z.literal("nextTurn") }),
74
+ import_zod.z.object({ t: import_zod.z.literal("playAgain") }),
75
+ // host: after a game ends, return the SAME players to the lobby
76
+ import_zod.z.object({ t: import_zod.z.literal("markOpen"), correct: import_zod.z.boolean() }),
77
+ import_zod.z.object({ t: import_zod.z.literal("revealNoHost") }),
78
+ import_zod.z.object({ t: import_zod.z.literal("awardNoHost"), teamIndex: import_zod.z.number().int().min(0).max(LIMITS.MAX_PLAYERS) }),
79
+ import_zod.z.object({ t: import_zod.z.literal("noHostNobody") }),
80
+ // player actions
81
+ import_zod.z.object({ t: import_zod.z.literal("buzz") }),
82
+ import_zod.z.object({ t: import_zod.z.literal("answer"), index: import_zod.z.number().int().min(0).max(10) }),
83
+ import_zod.z.object({ t: import_zod.z.literal("useHelp"), helpType: HelpType }),
84
+ // sabotage a rival (active team only): spend a sabotage help to debuff targetTeam's next question
85
+ import_zod.z.object({ t: import_zod.z.literal("sabotage"), helpType: HelpType, targetTeam: import_zod.z.number().int().min(0).max(LIMITS.MAX_PLAYERS) })
86
+ ]);
87
+ // Annotate the CommonJS export names for ESM import in node:
88
+ 0 && (module.exports = {
89
+ ClientMsg,
90
+ GAME_IDS,
91
+ LIMITS,
92
+ PROTOCOL_VERSION,
93
+ ROOM_ALPHABET
94
+ });
@@ -0,0 +1,396 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Geem Online wire format — server-authoritative (Shape B).
5
+ * Clients send INPUTS (`ClientMsg`, always validated server-side); the server
6
+ * runs @thegeem/engine and pushes authoritative STATE (`ServerMsg`).
7
+ *
8
+ * SELF-CONTAINED: this package owns the wire contract and depends only on `zod`
9
+ * (no @thegeem/engine import) so it can be published publicly + generated into
10
+ * Swift/Kotlin. The engine re-exports these wire types and PRODUCES them.
11
+ */
12
+ declare const LIMITS: {
13
+ readonly MAX_NAME_LEN: 20;
14
+ readonly ROOM_CODE_LEN: 4;
15
+ readonly MAX_PLAYERS: 12;
16
+ readonly MAX_MSG_PER_SEC: 8;
17
+ };
18
+ /**
19
+ * Wire-format generation. **Bump ONLY on a breaking change** to ClientMsg /
20
+ * ServerMsg / GameStateView (a field removed/renamed/retyped, or a semantic
21
+ * change a frozen client would mis-handle). Additive, backward-compatible
22
+ * changes (a new optional field, a new effect a client can ignore) do NOT bump.
23
+ *
24
+ * Why this exists: web auto-updates, but an App-Store / Play build is FROZEN on
25
+ * the user's device. A client sends the version it was built against (`pv`) on
26
+ * its first message; the server rejects `pv < PROTOCOL_VERSION` with
27
+ * `{ t:'error', code:'client_outdated' }` so old native builds get a clean
28
+ * "please update" instead of silently mis-rendering. Unversioned clients (no
29
+ * `pv`, e.g. the legacy web harness) are never rejected.
30
+ */
31
+ declare const PROTOCOL_VERSION = 1;
32
+ /**
33
+ * Geem is a PLATFORM of GAMES (jackbox-style); the trivia board is Game #1.
34
+ * Every room runs one game, identified by `gameId`. Add ids here as games ship.
35
+ * (NOTE: "modes" — controller/race/no-host — are variants WITHIN the trivia
36
+ * game, not separate games. See the platform-of-games decision.)
37
+ */
38
+ declare const GAME_IDS: readonly ["trivia"];
39
+ type GameId = (typeof GAME_IDS)[number];
40
+ /** Unambiguous room-code alphabet — no 0/O, 1/I/L. */
41
+ declare const ROOM_ALPHABET = "ACDEFGHJKMNPQRTUVWXY2346789";
42
+ declare const HelpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
43
+ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
44
+ t: z.ZodLiteral<"createRoom">;
45
+ name: z.ZodOptional<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>;
46
+ pv: z.ZodOptional<z.ZodNumber>;
47
+ idToken: z.ZodOptional<z.ZodString>;
48
+ }, "strip", z.ZodTypeAny, {
49
+ t: "createRoom";
50
+ name?: string | undefined;
51
+ pv?: number | undefined;
52
+ idToken?: string | undefined;
53
+ }, {
54
+ t: "createRoom";
55
+ name?: string | undefined;
56
+ pv?: number | undefined;
57
+ idToken?: string | undefined;
58
+ }>, z.ZodObject<{
59
+ t: z.ZodLiteral<"joinRoom">;
60
+ code: z.ZodString;
61
+ name: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
62
+ pv: z.ZodOptional<z.ZodNumber>;
63
+ idToken: z.ZodOptional<z.ZodString>;
64
+ }, "strip", z.ZodTypeAny, {
65
+ code: string;
66
+ t: "joinRoom";
67
+ name: string;
68
+ pv?: number | undefined;
69
+ idToken?: string | undefined;
70
+ }, {
71
+ code: string;
72
+ t: "joinRoom";
73
+ name: string;
74
+ pv?: number | undefined;
75
+ idToken?: string | undefined;
76
+ }>, z.ZodObject<{
77
+ t: z.ZodLiteral<"resume">;
78
+ code: z.ZodString;
79
+ token: z.ZodString;
80
+ pv: z.ZodOptional<z.ZodNumber>;
81
+ idToken: z.ZodOptional<z.ZodString>;
82
+ }, "strip", z.ZodTypeAny, {
83
+ code: string;
84
+ t: "resume";
85
+ token: string;
86
+ pv?: number | undefined;
87
+ idToken?: string | undefined;
88
+ }, {
89
+ code: string;
90
+ t: "resume";
91
+ token: string;
92
+ pv?: number | undefined;
93
+ idToken?: string | undefined;
94
+ }>, z.ZodObject<{
95
+ t: z.ZodLiteral<"leaveRoom">;
96
+ }, "strip", z.ZodTypeAny, {
97
+ t: "leaveRoom";
98
+ }, {
99
+ t: "leaveRoom";
100
+ }>, z.ZodObject<{
101
+ t: z.ZodLiteral<"approve">;
102
+ playerId: z.ZodString;
103
+ }, "strip", z.ZodTypeAny, {
104
+ t: "approve";
105
+ playerId: string;
106
+ }, {
107
+ t: "approve";
108
+ playerId: string;
109
+ }>, z.ZodObject<{
110
+ t: z.ZodLiteral<"reject">;
111
+ playerId: z.ZodString;
112
+ }, "strip", z.ZodTypeAny, {
113
+ t: "reject";
114
+ playerId: string;
115
+ }, {
116
+ t: "reject";
117
+ playerId: string;
118
+ }>, z.ZodObject<{
119
+ t: z.ZodLiteral<"kick">;
120
+ playerId: z.ZodString;
121
+ }, "strip", z.ZodTypeAny, {
122
+ t: "kick";
123
+ playerId: string;
124
+ }, {
125
+ t: "kick";
126
+ playerId: string;
127
+ }>, z.ZodObject<{
128
+ t: z.ZodLiteral<"startGame">;
129
+ game: z.ZodOptional<z.ZodEnum<["trivia"]>>;
130
+ totalRounds: z.ZodNumber;
131
+ categoryIds: z.ZodArray<z.ZodString, "many">;
132
+ }, "strip", z.ZodTypeAny, {
133
+ t: "startGame";
134
+ totalRounds: number;
135
+ categoryIds: string[];
136
+ game?: "trivia" | undefined;
137
+ }, {
138
+ t: "startGame";
139
+ totalRounds: number;
140
+ categoryIds: string[];
141
+ game?: "trivia" | undefined;
142
+ }>, z.ZodObject<{
143
+ t: z.ZodLiteral<"pickLevel">;
144
+ categoryId: z.ZodString;
145
+ level: z.ZodNumber;
146
+ }, "strip", z.ZodTypeAny, {
147
+ t: "pickLevel";
148
+ categoryId: string;
149
+ level: number;
150
+ }, {
151
+ t: "pickLevel";
152
+ categoryId: string;
153
+ level: number;
154
+ }>, z.ZodObject<{
155
+ t: z.ZodLiteral<"nextTurn">;
156
+ }, "strip", z.ZodTypeAny, {
157
+ t: "nextTurn";
158
+ }, {
159
+ t: "nextTurn";
160
+ }>, z.ZodObject<{
161
+ t: z.ZodLiteral<"playAgain">;
162
+ }, "strip", z.ZodTypeAny, {
163
+ t: "playAgain";
164
+ }, {
165
+ t: "playAgain";
166
+ }>, z.ZodObject<{
167
+ t: z.ZodLiteral<"markOpen">;
168
+ correct: z.ZodBoolean;
169
+ }, "strip", z.ZodTypeAny, {
170
+ t: "markOpen";
171
+ correct: boolean;
172
+ }, {
173
+ t: "markOpen";
174
+ correct: boolean;
175
+ }>, z.ZodObject<{
176
+ t: z.ZodLiteral<"revealNoHost">;
177
+ }, "strip", z.ZodTypeAny, {
178
+ t: "revealNoHost";
179
+ }, {
180
+ t: "revealNoHost";
181
+ }>, z.ZodObject<{
182
+ t: z.ZodLiteral<"awardNoHost">;
183
+ teamIndex: z.ZodNumber;
184
+ }, "strip", z.ZodTypeAny, {
185
+ t: "awardNoHost";
186
+ teamIndex: number;
187
+ }, {
188
+ t: "awardNoHost";
189
+ teamIndex: number;
190
+ }>, z.ZodObject<{
191
+ t: z.ZodLiteral<"noHostNobody">;
192
+ }, "strip", z.ZodTypeAny, {
193
+ t: "noHostNobody";
194
+ }, {
195
+ t: "noHostNobody";
196
+ }>, z.ZodObject<{
197
+ t: z.ZodLiteral<"buzz">;
198
+ }, "strip", z.ZodTypeAny, {
199
+ t: "buzz";
200
+ }, {
201
+ t: "buzz";
202
+ }>, z.ZodObject<{
203
+ t: z.ZodLiteral<"answer">;
204
+ index: z.ZodNumber;
205
+ }, "strip", z.ZodTypeAny, {
206
+ t: "answer";
207
+ index: number;
208
+ }, {
209
+ t: "answer";
210
+ index: number;
211
+ }>, z.ZodObject<{
212
+ t: z.ZodLiteral<"useHelp">;
213
+ helpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
214
+ }, "strip", z.ZodTypeAny, {
215
+ t: "useHelp";
216
+ helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
217
+ }, {
218
+ t: "useHelp";
219
+ helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
220
+ }>, z.ZodObject<{
221
+ t: z.ZodLiteral<"sabotage">;
222
+ helpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
223
+ targetTeam: z.ZodNumber;
224
+ }, "strip", z.ZodTypeAny, {
225
+ t: "sabotage";
226
+ helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
227
+ targetTeam: number;
228
+ }, {
229
+ t: "sabotage";
230
+ helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
231
+ targetTeam: number;
232
+ }>]>;
233
+ type ClientMsg = z.infer<typeof ClientMsg>;
234
+ interface LobbyPlayer {
235
+ id: string;
236
+ name: string;
237
+ approved: boolean;
238
+ connected: boolean;
239
+ }
240
+ /** Per-recipient context attached to lobby/state pushes. */
241
+ interface YouContext {
242
+ id: string;
243
+ isHost: boolean;
244
+ /** This player's team index once the game has started; null in the lobby / for the host. */
245
+ teamIndex: number | null;
246
+ }
247
+ type GameStatus = 'setup' | 'categorySelection' | 'inProgress' | 'completed';
248
+ type GameplayFlow = 'classic' | 'openJudged' | 'noHost' | 'buzzer';
249
+ type BuzzerGameMode = 'off' | 'solo' | 'race' | 'controller';
250
+ /** The help/lifeline ids (same set as the inbound `HelpType` zod enum above). */
251
+ type HelpName = z.infer<typeof HelpType>;
252
+ type SoundName = 'correct' | 'wrong' | 'timeup' | 'warning' | 'help' | 'ding' | 'pop' | 'swoosh';
253
+ interface TeamView {
254
+ id: string;
255
+ name: string;
256
+ emoji: string;
257
+ color: string;
258
+ score: number;
259
+ currentStreak: number;
260
+ /** This team's lifelines and whether each has been spent. */
261
+ helps: {
262
+ type: HelpName;
263
+ isUsed: boolean;
264
+ }[];
265
+ }
266
+ interface QuestionView {
267
+ text: string;
268
+ options: string[];
269
+ hiddenOptionIndices: number[];
270
+ revealed: boolean;
271
+ selectedAnswerIndex: number | null;
272
+ /** Only present once revealed (anti-cheat). Null while the question is live. */
273
+ correctAnswerIndex: number | null;
274
+ imageName?: string;
275
+ videoName?: string;
276
+ }
277
+ interface GameStateView {
278
+ status: GameStatus;
279
+ flow: GameplayFlow;
280
+ mode: BuzzerGameMode;
281
+ round: number;
282
+ totalRounds: number;
283
+ currentTeamIndex: number;
284
+ answeringTeamIndex: number;
285
+ teams: TeamView[];
286
+ question: QuestionView | null;
287
+ /** Race: the team that buzzed first (null otherwise). */
288
+ firstBuzzTeamIndex: number | null;
289
+ lockedOutTeamIndices: number[];
290
+ isOpenStealIntro: boolean;
291
+ doublePointsActive: boolean;
292
+ stealPointsActive: boolean;
293
+ trippedTeamIndices: number[];
294
+ /** Shared board: per category id, the level numbers already played. */
295
+ usedLevels: Record<string, number[]>;
296
+ }
297
+ /** Transient presentation effects the relay emits; clients play them once. */
298
+ type Effect = {
299
+ t: 'stopTimer';
300
+ } | {
301
+ t: 'startTimer';
302
+ duration: number;
303
+ } | {
304
+ t: 'resumeTimer';
305
+ } | {
306
+ t: 'addTime';
307
+ seconds: number;
308
+ } | {
309
+ t: 'closeBuzzer';
310
+ } | {
311
+ t: 'openBuzzer';
312
+ } | {
313
+ t: 'serveQuestion';
314
+ } | {
315
+ t: 'sound';
316
+ name: SoundName;
317
+ } | {
318
+ t: 'reveal';
319
+ } | {
320
+ t: 'celebrate';
321
+ hard: boolean;
322
+ } | {
323
+ t: 'scorePop';
324
+ amount: number;
325
+ streakBonus: number;
326
+ } | {
327
+ t: 'chargeCredit';
328
+ } | {
329
+ t: 'stealPause';
330
+ ms: number;
331
+ } | {
332
+ t: 'openStealIntro';
333
+ ms: number;
334
+ } | {
335
+ t: 'noHostAdvance';
336
+ ms: number;
337
+ } | {
338
+ t: 'endGame';
339
+ };
340
+ type ServerMsg = {
341
+ t: 'roomCreated';
342
+ code: string;
343
+ youId: string;
344
+ token: string;
345
+ pv: number;
346
+ } | {
347
+ t: 'joinPending';
348
+ } | {
349
+ t: 'joinRequest';
350
+ playerId: string;
351
+ name: string;
352
+ } | {
353
+ t: 'joined';
354
+ youId: string;
355
+ token: string;
356
+ pv: number;
357
+ } | {
358
+ t: 'joinRejected';
359
+ reason: string;
360
+ } | {
361
+ t: 'kicked';
362
+ } | {
363
+ t: 'roomClosed';
364
+ reason: string;
365
+ } | {
366
+ t: 'lobby';
367
+ code: string;
368
+ gameId: GameId;
369
+ hostId: string;
370
+ hostName: string;
371
+ players: LobbyPlayer[];
372
+ categories: {
373
+ id: string;
374
+ name: string;
375
+ packId: string;
376
+ packName: string;
377
+ icon: string;
378
+ hasImage: boolean;
379
+ }[];
380
+ you: YouContext;
381
+ } | {
382
+ t: 'state';
383
+ gameId: GameId;
384
+ view: GameStateView;
385
+ you: YouContext;
386
+ teamConnected: boolean[];
387
+ } | {
388
+ t: 'fx';
389
+ effects: Effect[];
390
+ } | {
391
+ t: 'error';
392
+ code: string;
393
+ message: string;
394
+ };
395
+
396
+ export { type BuzzerGameMode, ClientMsg, type Effect, GAME_IDS, type GameId, type GameStateView, type GameStatus, type GameplayFlow, type HelpName, LIMITS, type LobbyPlayer, PROTOCOL_VERSION, type QuestionView, ROOM_ALPHABET, type ServerMsg, type SoundName, type TeamView, type YouContext };
@@ -0,0 +1,396 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Geem Online wire format — server-authoritative (Shape B).
5
+ * Clients send INPUTS (`ClientMsg`, always validated server-side); the server
6
+ * runs @thegeem/engine and pushes authoritative STATE (`ServerMsg`).
7
+ *
8
+ * SELF-CONTAINED: this package owns the wire contract and depends only on `zod`
9
+ * (no @thegeem/engine import) so it can be published publicly + generated into
10
+ * Swift/Kotlin. The engine re-exports these wire types and PRODUCES them.
11
+ */
12
+ declare const LIMITS: {
13
+ readonly MAX_NAME_LEN: 20;
14
+ readonly ROOM_CODE_LEN: 4;
15
+ readonly MAX_PLAYERS: 12;
16
+ readonly MAX_MSG_PER_SEC: 8;
17
+ };
18
+ /**
19
+ * Wire-format generation. **Bump ONLY on a breaking change** to ClientMsg /
20
+ * ServerMsg / GameStateView (a field removed/renamed/retyped, or a semantic
21
+ * change a frozen client would mis-handle). Additive, backward-compatible
22
+ * changes (a new optional field, a new effect a client can ignore) do NOT bump.
23
+ *
24
+ * Why this exists: web auto-updates, but an App-Store / Play build is FROZEN on
25
+ * the user's device. A client sends the version it was built against (`pv`) on
26
+ * its first message; the server rejects `pv < PROTOCOL_VERSION` with
27
+ * `{ t:'error', code:'client_outdated' }` so old native builds get a clean
28
+ * "please update" instead of silently mis-rendering. Unversioned clients (no
29
+ * `pv`, e.g. the legacy web harness) are never rejected.
30
+ */
31
+ declare const PROTOCOL_VERSION = 1;
32
+ /**
33
+ * Geem is a PLATFORM of GAMES (jackbox-style); the trivia board is Game #1.
34
+ * Every room runs one game, identified by `gameId`. Add ids here as games ship.
35
+ * (NOTE: "modes" — controller/race/no-host — are variants WITHIN the trivia
36
+ * game, not separate games. See the platform-of-games decision.)
37
+ */
38
+ declare const GAME_IDS: readonly ["trivia"];
39
+ type GameId = (typeof GAME_IDS)[number];
40
+ /** Unambiguous room-code alphabet — no 0/O, 1/I/L. */
41
+ declare const ROOM_ALPHABET = "ACDEFGHJKMNPQRTUVWXY2346789";
42
+ declare const HelpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
43
+ declare const ClientMsg: z.ZodDiscriminatedUnion<"t", [z.ZodObject<{
44
+ t: z.ZodLiteral<"createRoom">;
45
+ name: z.ZodOptional<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>;
46
+ pv: z.ZodOptional<z.ZodNumber>;
47
+ idToken: z.ZodOptional<z.ZodString>;
48
+ }, "strip", z.ZodTypeAny, {
49
+ t: "createRoom";
50
+ name?: string | undefined;
51
+ pv?: number | undefined;
52
+ idToken?: string | undefined;
53
+ }, {
54
+ t: "createRoom";
55
+ name?: string | undefined;
56
+ pv?: number | undefined;
57
+ idToken?: string | undefined;
58
+ }>, z.ZodObject<{
59
+ t: z.ZodLiteral<"joinRoom">;
60
+ code: z.ZodString;
61
+ name: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
62
+ pv: z.ZodOptional<z.ZodNumber>;
63
+ idToken: z.ZodOptional<z.ZodString>;
64
+ }, "strip", z.ZodTypeAny, {
65
+ code: string;
66
+ t: "joinRoom";
67
+ name: string;
68
+ pv?: number | undefined;
69
+ idToken?: string | undefined;
70
+ }, {
71
+ code: string;
72
+ t: "joinRoom";
73
+ name: string;
74
+ pv?: number | undefined;
75
+ idToken?: string | undefined;
76
+ }>, z.ZodObject<{
77
+ t: z.ZodLiteral<"resume">;
78
+ code: z.ZodString;
79
+ token: z.ZodString;
80
+ pv: z.ZodOptional<z.ZodNumber>;
81
+ idToken: z.ZodOptional<z.ZodString>;
82
+ }, "strip", z.ZodTypeAny, {
83
+ code: string;
84
+ t: "resume";
85
+ token: string;
86
+ pv?: number | undefined;
87
+ idToken?: string | undefined;
88
+ }, {
89
+ code: string;
90
+ t: "resume";
91
+ token: string;
92
+ pv?: number | undefined;
93
+ idToken?: string | undefined;
94
+ }>, z.ZodObject<{
95
+ t: z.ZodLiteral<"leaveRoom">;
96
+ }, "strip", z.ZodTypeAny, {
97
+ t: "leaveRoom";
98
+ }, {
99
+ t: "leaveRoom";
100
+ }>, z.ZodObject<{
101
+ t: z.ZodLiteral<"approve">;
102
+ playerId: z.ZodString;
103
+ }, "strip", z.ZodTypeAny, {
104
+ t: "approve";
105
+ playerId: string;
106
+ }, {
107
+ t: "approve";
108
+ playerId: string;
109
+ }>, z.ZodObject<{
110
+ t: z.ZodLiteral<"reject">;
111
+ playerId: z.ZodString;
112
+ }, "strip", z.ZodTypeAny, {
113
+ t: "reject";
114
+ playerId: string;
115
+ }, {
116
+ t: "reject";
117
+ playerId: string;
118
+ }>, z.ZodObject<{
119
+ t: z.ZodLiteral<"kick">;
120
+ playerId: z.ZodString;
121
+ }, "strip", z.ZodTypeAny, {
122
+ t: "kick";
123
+ playerId: string;
124
+ }, {
125
+ t: "kick";
126
+ playerId: string;
127
+ }>, z.ZodObject<{
128
+ t: z.ZodLiteral<"startGame">;
129
+ game: z.ZodOptional<z.ZodEnum<["trivia"]>>;
130
+ totalRounds: z.ZodNumber;
131
+ categoryIds: z.ZodArray<z.ZodString, "many">;
132
+ }, "strip", z.ZodTypeAny, {
133
+ t: "startGame";
134
+ totalRounds: number;
135
+ categoryIds: string[];
136
+ game?: "trivia" | undefined;
137
+ }, {
138
+ t: "startGame";
139
+ totalRounds: number;
140
+ categoryIds: string[];
141
+ game?: "trivia" | undefined;
142
+ }>, z.ZodObject<{
143
+ t: z.ZodLiteral<"pickLevel">;
144
+ categoryId: z.ZodString;
145
+ level: z.ZodNumber;
146
+ }, "strip", z.ZodTypeAny, {
147
+ t: "pickLevel";
148
+ categoryId: string;
149
+ level: number;
150
+ }, {
151
+ t: "pickLevel";
152
+ categoryId: string;
153
+ level: number;
154
+ }>, z.ZodObject<{
155
+ t: z.ZodLiteral<"nextTurn">;
156
+ }, "strip", z.ZodTypeAny, {
157
+ t: "nextTurn";
158
+ }, {
159
+ t: "nextTurn";
160
+ }>, z.ZodObject<{
161
+ t: z.ZodLiteral<"playAgain">;
162
+ }, "strip", z.ZodTypeAny, {
163
+ t: "playAgain";
164
+ }, {
165
+ t: "playAgain";
166
+ }>, z.ZodObject<{
167
+ t: z.ZodLiteral<"markOpen">;
168
+ correct: z.ZodBoolean;
169
+ }, "strip", z.ZodTypeAny, {
170
+ t: "markOpen";
171
+ correct: boolean;
172
+ }, {
173
+ t: "markOpen";
174
+ correct: boolean;
175
+ }>, z.ZodObject<{
176
+ t: z.ZodLiteral<"revealNoHost">;
177
+ }, "strip", z.ZodTypeAny, {
178
+ t: "revealNoHost";
179
+ }, {
180
+ t: "revealNoHost";
181
+ }>, z.ZodObject<{
182
+ t: z.ZodLiteral<"awardNoHost">;
183
+ teamIndex: z.ZodNumber;
184
+ }, "strip", z.ZodTypeAny, {
185
+ t: "awardNoHost";
186
+ teamIndex: number;
187
+ }, {
188
+ t: "awardNoHost";
189
+ teamIndex: number;
190
+ }>, z.ZodObject<{
191
+ t: z.ZodLiteral<"noHostNobody">;
192
+ }, "strip", z.ZodTypeAny, {
193
+ t: "noHostNobody";
194
+ }, {
195
+ t: "noHostNobody";
196
+ }>, z.ZodObject<{
197
+ t: z.ZodLiteral<"buzz">;
198
+ }, "strip", z.ZodTypeAny, {
199
+ t: "buzz";
200
+ }, {
201
+ t: "buzz";
202
+ }>, z.ZodObject<{
203
+ t: z.ZodLiteral<"answer">;
204
+ index: z.ZodNumber;
205
+ }, "strip", z.ZodTypeAny, {
206
+ t: "answer";
207
+ index: number;
208
+ }, {
209
+ t: "answer";
210
+ index: number;
211
+ }>, z.ZodObject<{
212
+ t: z.ZodLiteral<"useHelp">;
213
+ helpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
214
+ }, "strip", z.ZodTypeAny, {
215
+ t: "useHelp";
216
+ helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
217
+ }, {
218
+ t: "useHelp";
219
+ helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
220
+ }>, z.ZodObject<{
221
+ t: z.ZodLiteral<"sabotage">;
222
+ helpType: z.ZodEnum<["removeTwoAnswers", "changeQuestion", "extraTime", "doublePoints", "stealPoints", "restPlayer", "tripLevel"]>;
223
+ targetTeam: z.ZodNumber;
224
+ }, "strip", z.ZodTypeAny, {
225
+ t: "sabotage";
226
+ helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
227
+ targetTeam: number;
228
+ }, {
229
+ t: "sabotage";
230
+ helpType: "removeTwoAnswers" | "changeQuestion" | "extraTime" | "doublePoints" | "stealPoints" | "restPlayer" | "tripLevel";
231
+ targetTeam: number;
232
+ }>]>;
233
+ type ClientMsg = z.infer<typeof ClientMsg>;
234
+ interface LobbyPlayer {
235
+ id: string;
236
+ name: string;
237
+ approved: boolean;
238
+ connected: boolean;
239
+ }
240
+ /** Per-recipient context attached to lobby/state pushes. */
241
+ interface YouContext {
242
+ id: string;
243
+ isHost: boolean;
244
+ /** This player's team index once the game has started; null in the lobby / for the host. */
245
+ teamIndex: number | null;
246
+ }
247
+ type GameStatus = 'setup' | 'categorySelection' | 'inProgress' | 'completed';
248
+ type GameplayFlow = 'classic' | 'openJudged' | 'noHost' | 'buzzer';
249
+ type BuzzerGameMode = 'off' | 'solo' | 'race' | 'controller';
250
+ /** The help/lifeline ids (same set as the inbound `HelpType` zod enum above). */
251
+ type HelpName = z.infer<typeof HelpType>;
252
+ type SoundName = 'correct' | 'wrong' | 'timeup' | 'warning' | 'help' | 'ding' | 'pop' | 'swoosh';
253
+ interface TeamView {
254
+ id: string;
255
+ name: string;
256
+ emoji: string;
257
+ color: string;
258
+ score: number;
259
+ currentStreak: number;
260
+ /** This team's lifelines and whether each has been spent. */
261
+ helps: {
262
+ type: HelpName;
263
+ isUsed: boolean;
264
+ }[];
265
+ }
266
+ interface QuestionView {
267
+ text: string;
268
+ options: string[];
269
+ hiddenOptionIndices: number[];
270
+ revealed: boolean;
271
+ selectedAnswerIndex: number | null;
272
+ /** Only present once revealed (anti-cheat). Null while the question is live. */
273
+ correctAnswerIndex: number | null;
274
+ imageName?: string;
275
+ videoName?: string;
276
+ }
277
+ interface GameStateView {
278
+ status: GameStatus;
279
+ flow: GameplayFlow;
280
+ mode: BuzzerGameMode;
281
+ round: number;
282
+ totalRounds: number;
283
+ currentTeamIndex: number;
284
+ answeringTeamIndex: number;
285
+ teams: TeamView[];
286
+ question: QuestionView | null;
287
+ /** Race: the team that buzzed first (null otherwise). */
288
+ firstBuzzTeamIndex: number | null;
289
+ lockedOutTeamIndices: number[];
290
+ isOpenStealIntro: boolean;
291
+ doublePointsActive: boolean;
292
+ stealPointsActive: boolean;
293
+ trippedTeamIndices: number[];
294
+ /** Shared board: per category id, the level numbers already played. */
295
+ usedLevels: Record<string, number[]>;
296
+ }
297
+ /** Transient presentation effects the relay emits; clients play them once. */
298
+ type Effect = {
299
+ t: 'stopTimer';
300
+ } | {
301
+ t: 'startTimer';
302
+ duration: number;
303
+ } | {
304
+ t: 'resumeTimer';
305
+ } | {
306
+ t: 'addTime';
307
+ seconds: number;
308
+ } | {
309
+ t: 'closeBuzzer';
310
+ } | {
311
+ t: 'openBuzzer';
312
+ } | {
313
+ t: 'serveQuestion';
314
+ } | {
315
+ t: 'sound';
316
+ name: SoundName;
317
+ } | {
318
+ t: 'reveal';
319
+ } | {
320
+ t: 'celebrate';
321
+ hard: boolean;
322
+ } | {
323
+ t: 'scorePop';
324
+ amount: number;
325
+ streakBonus: number;
326
+ } | {
327
+ t: 'chargeCredit';
328
+ } | {
329
+ t: 'stealPause';
330
+ ms: number;
331
+ } | {
332
+ t: 'openStealIntro';
333
+ ms: number;
334
+ } | {
335
+ t: 'noHostAdvance';
336
+ ms: number;
337
+ } | {
338
+ t: 'endGame';
339
+ };
340
+ type ServerMsg = {
341
+ t: 'roomCreated';
342
+ code: string;
343
+ youId: string;
344
+ token: string;
345
+ pv: number;
346
+ } | {
347
+ t: 'joinPending';
348
+ } | {
349
+ t: 'joinRequest';
350
+ playerId: string;
351
+ name: string;
352
+ } | {
353
+ t: 'joined';
354
+ youId: string;
355
+ token: string;
356
+ pv: number;
357
+ } | {
358
+ t: 'joinRejected';
359
+ reason: string;
360
+ } | {
361
+ t: 'kicked';
362
+ } | {
363
+ t: 'roomClosed';
364
+ reason: string;
365
+ } | {
366
+ t: 'lobby';
367
+ code: string;
368
+ gameId: GameId;
369
+ hostId: string;
370
+ hostName: string;
371
+ players: LobbyPlayer[];
372
+ categories: {
373
+ id: string;
374
+ name: string;
375
+ packId: string;
376
+ packName: string;
377
+ icon: string;
378
+ hasImage: boolean;
379
+ }[];
380
+ you: YouContext;
381
+ } | {
382
+ t: 'state';
383
+ gameId: GameId;
384
+ view: GameStateView;
385
+ you: YouContext;
386
+ teamConnected: boolean[];
387
+ } | {
388
+ t: 'fx';
389
+ effects: Effect[];
390
+ } | {
391
+ t: 'error';
392
+ code: string;
393
+ message: string;
394
+ };
395
+
396
+ export { type BuzzerGameMode, ClientMsg, type Effect, GAME_IDS, type GameId, type GameStateView, type GameStatus, type GameplayFlow, type HelpName, LIMITS, type LobbyPlayer, PROTOCOL_VERSION, type QuestionView, ROOM_ALPHABET, type ServerMsg, type SoundName, type TeamView, type YouContext };
package/dist/index.js ADDED
@@ -0,0 +1,65 @@
1
+ // src/index.ts
2
+ import { z } from "zod";
3
+ var LIMITS = {
4
+ MAX_NAME_LEN: 20,
5
+ ROOM_CODE_LEN: 4,
6
+ MAX_PLAYERS: 12,
7
+ MAX_MSG_PER_SEC: 8
8
+ };
9
+ var PROTOCOL_VERSION = 1;
10
+ var GAME_IDS = ["trivia"];
11
+ var ROOM_ALPHABET = "ACDEFGHJKMNPQRTUVWXY2346789";
12
+ var UNSAFE_CHARS = /[\p{Cc}<>]/gu;
13
+ var DisplayName = z.string().trim().min(1).max(LIMITS.MAX_NAME_LEN).transform((s) => s.replace(UNSAFE_CHARS, "").trim()).refine((s) => s.length >= 1, "name is empty after sanitizing");
14
+ var RoomCode = z.string().trim().toUpperCase().regex(new RegExp(`^[${ROOM_ALPHABET}]{${LIMITS.ROOM_CODE_LEN}}$`), "bad room code");
15
+ var PlayerId = z.string().max(40);
16
+ var HelpType = z.enum([
17
+ "removeTwoAnswers",
18
+ "changeQuestion",
19
+ "extraTime",
20
+ "doublePoints",
21
+ "stealPoints",
22
+ "restPlayer",
23
+ "tripLevel"
24
+ ]);
25
+ var Pv = z.number().int().min(1).max(1e6).optional();
26
+ var IdToken = z.string().max(4096).optional();
27
+ var ClientMsg = z.discriminatedUnion("t", [
28
+ // room / lobby
29
+ z.object({ t: z.literal("createRoom"), name: DisplayName.optional(), pv: Pv, idToken: IdToken }),
30
+ z.object({ t: z.literal("joinRoom"), code: RoomCode, name: DisplayName, pv: Pv, idToken: IdToken }),
31
+ z.object({ t: z.literal("resume"), code: RoomCode, token: z.string().max(120), pv: Pv, idToken: IdToken }),
32
+ z.object({ t: z.literal("leaveRoom") }),
33
+ z.object({ t: z.literal("approve"), playerId: PlayerId }),
34
+ z.object({ t: z.literal("reject"), playerId: PlayerId }),
35
+ z.object({ t: z.literal("kick"), playerId: PlayerId }),
36
+ // host game actions
37
+ z.object({
38
+ t: z.literal("startGame"),
39
+ game: z.enum(GAME_IDS).optional(),
40
+ // which game to play; defaults to 'trivia'
41
+ totalRounds: z.number().int().min(1).max(50),
42
+ categoryIds: z.array(z.string().max(60)).min(1).max(12)
43
+ }),
44
+ z.object({ t: z.literal("pickLevel"), categoryId: z.string().max(60), level: z.number().int().min(1).max(6) }),
45
+ z.object({ t: z.literal("nextTurn") }),
46
+ z.object({ t: z.literal("playAgain") }),
47
+ // host: after a game ends, return the SAME players to the lobby
48
+ z.object({ t: z.literal("markOpen"), correct: z.boolean() }),
49
+ z.object({ t: z.literal("revealNoHost") }),
50
+ z.object({ t: z.literal("awardNoHost"), teamIndex: z.number().int().min(0).max(LIMITS.MAX_PLAYERS) }),
51
+ z.object({ t: z.literal("noHostNobody") }),
52
+ // player actions
53
+ z.object({ t: z.literal("buzz") }),
54
+ z.object({ t: z.literal("answer"), index: z.number().int().min(0).max(10) }),
55
+ z.object({ t: z.literal("useHelp"), helpType: HelpType }),
56
+ // sabotage a rival (active team only): spend a sabotage help to debuff targetTeam's next question
57
+ z.object({ t: z.literal("sabotage"), helpType: HelpType, targetTeam: z.number().int().min(0).max(LIMITS.MAX_PLAYERS) })
58
+ ]);
59
+ export {
60
+ ClientMsg,
61
+ GAME_IDS,
62
+ LIMITS,
63
+ PROTOCOL_VERSION,
64
+ ROOM_ALPHABET
65
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@thegeem/protocol",
3
+ "version": "0.1.0",
4
+ "description": "Geem's wire protocol — shared zod schemas + TypeScript types for talking to the Geem game server over Socket.IO. The source of truth all clients implement against.",
5
+ "license": "UNLICENSED",
6
+ "homepage": "https://geem.tv",
7
+ "type": "module",
8
+ "main": "./dist/index.cjs",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "keywords": [
23
+ "geem",
24
+ "protocol",
25
+ "socket.io",
26
+ "zod"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsup",
30
+ "typecheck": "tsc --noEmit",
31
+ "release": "node scripts/release.mjs"
32
+ },
33
+ "dependencies": {
34
+ "zod": "^3.23.8"
35
+ },
36
+ "devDependencies": {
37
+ "typescript": "^5.5.4"
38
+ },
39
+ "module": "./dist/index.js",
40
+ "types": "./dist/index.d.ts"
41
+ }