@homie-rocks/studio 0.14.3 → 0.15.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.
@@ -27,6 +27,12 @@
27
27
  * that went quiet, reconnects with its seat token.
28
28
  * - Reports role/seat/rtt/snapshot rate to the parent shell by postMessage
29
29
  * (for the debug strip) and on `window.__homieNet` (for e2e probes).
30
+ * - Watching (revision 5, NETPLAY.md section 16): a watcher is a screen that
31
+ * never takes a seat. `net.viewSeat` is whose view to draw (a player's own
32
+ * seat; for a watcher, the player it follows, or null for the overview),
33
+ * `net.follow(seat | 'auto' | null)` changes it (the watch page's strip and
34
+ * keys 1-9, A and O call it), and Auto follows the action: the newest
35
+ * `net.spotlight(seat)`, else the leader of the game's `scores` probe.
30
36
  *
31
37
  * WHAT IT DOES NOT DO: rendering, physics, input devices, bots. `Roster` below
32
38
  * is the bot-yield bookkeeping a host needs; the bots themselves are the game's.
@@ -38,6 +44,22 @@ export const NETPLAY_VERSION = 1;
38
44
  export type Role = 'host' | 'replica' | 'screen';
39
45
  export type Device = 'phone' | 'desk' | 'tv';
40
46
  export type Want = 'play' | 'screen';
47
+ /** Whose view a watcher draws: a seat, `'auto'` (the action), or `null` (the overview camera). */
48
+ export type Follow = number | 'auto' | null;
49
+ /**
50
+ * Why the view changed: `start` (the first), `seat` (a player's own seat), `asked` (the watcher or its page chose),
51
+ * `auto` (Auto moved to the action), `left` (the followed player left; Auto took over), `back` (they came back),
52
+ * `policy` (the room or the game allows the overview only now, or again more).
53
+ */
54
+ export type ViewWhy = 'start' | 'seat' | 'asked' | 'auto' | 'left' | 'back' | 'policy';
55
+ export interface ViewChange {
56
+ /** The seat whose camera and HUD to draw; null: the overview camera. */
57
+ seat: number | null;
58
+ /** What was asked: a seat, 'auto', or null (the whole room). */
59
+ following: Follow;
60
+ prev: number | null;
61
+ why: ViewWhy;
62
+ }
41
63
  /** Who moves a seated body by default. `owner`: the seat's own browser (instant). `host`: the host's rules. */
42
64
  export type Movement = 'owner' | 'host';
43
65
 
@@ -53,6 +75,12 @@ export interface NetConfig {
53
75
  device?: Device;
54
76
  want?: Want;
55
77
  debug?: boolean;
78
+ /** Revision 5: this browser watches (a screen that never takes a seat), from the game's watch door. */
79
+ watch?: boolean;
80
+ /** Whose view the watcher starts on: a seat number, 'auto' (the default) or 'overview'. */
81
+ follow?: number | 'auto' | 'overview';
82
+ /** What the game lets its watchers see (game.json "watch"): 'follow' (the default) or 'overview'. */
83
+ watchPolicy?: 'follow' | 'overview';
56
84
  }
57
85
 
58
86
  export interface HostRef { id: string; seat: number | null }
@@ -67,6 +95,8 @@ export interface Peer {
67
95
  role: Role;
68
96
  /** Muted by the studio's owner (section 15): hide this player's chat and emotes. */
69
97
  muted?: boolean;
98
+ /** A watcher (section 16): a screen that came to watch; it never takes a seat. */
99
+ watch?: boolean;
70
100
  }
71
101
 
72
102
  /** One seat's entry in the body-control table: [seat, rs, own (1|0), ack]. */
@@ -205,6 +235,10 @@ export interface NetplayOptions<C = unknown> {
205
235
  /** Defaults to `window.HOMIE_NET`. `null` forces offline. */
206
236
  config?: NetConfig | null;
207
237
  want?: Want;
238
+ /** Revision 5: watch (a screen that never takes a seat). Defaults to the shell's `HOMIE_NET.watch`. */
239
+ watch?: boolean;
240
+ /** A watcher's keys (1-9 a player, A Auto, O or 0 the whole room, arrows the next player). Default true. */
241
+ watchKeys?: boolean;
208
242
  /** May this browser be elected host? Default true. */
209
243
  canHost?: boolean;
210
244
  /** Game id, for the relay's logs. */
@@ -260,6 +294,8 @@ export interface NetHandlers<S, A, C> {
260
294
  announce: (a: Announcement) => void;
261
295
  /** The studio's owner muted or unmuted a player in this room. */
262
296
  mute: (m: Mute) => void;
297
+ /** Whose view to draw changed (a watcher's follow, or a player's own seat). Listening says the game draws it. */
298
+ view: (v: ViewChange) => void;
263
299
  }
264
300
 
265
301
  export interface Netplay<S = unknown, A = unknown, C = unknown> {
@@ -300,6 +336,32 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
300
336
  * it (opens that player's card with Mute and Kick); for everyone else it is nothing.
301
337
  */
302
338
  pickPlayer(seat: number | null): void;
339
+ /**
340
+ * WATCHING (revision 5, NETPLAY.md section 16). `watching`: this browser is a watcher, a screen that never takes a
341
+ * seat. `viewSeat`: whose camera and HUD to draw: a player's own seat, the seat a watcher follows, or null for the
342
+ * overview camera. Reading it (or listening for `view`) tells the watch page that this game draws the followed
343
+ * player; a game that never does is shown as its overview, and the page says so.
344
+ */
345
+ readonly watching: boolean;
346
+ readonly viewSeat: number | null;
347
+ /** What the watcher asked for: a seat, 'auto' or null (the whole room). */
348
+ readonly following: Follow;
349
+ /**
350
+ * `viewSeat` without saying the game draws it: for an overlay that only marks the followed player (the port's
351
+ * HUD), so a game whose camera does not follow is still shown to its watchers as its overview.
352
+ */
353
+ readonly watchedSeat: number | null;
354
+ /** Whether this watcher may follow one player (false: the game or the room shows it the overview only). */
355
+ readonly canFollow: boolean;
356
+ /** Watcher: follow a seat, 'auto' (the action) or null (the whole room). False when not a watcher or not allowed. */
357
+ follow(target: Follow | 'overview'): boolean;
358
+ /**
359
+ * Anyone: something happened to this player (a hit, a goal, a pickup). Auto cuts to the newest one once the player
360
+ * it shows has had a few seconds. A cheap no-op except on a watcher in Auto.
361
+ */
362
+ spotlight(seat: number | null): void;
363
+ /** The seated players, in seat order (the watch strip's order: key 1 is the first). */
364
+ players(): Peer[];
303
365
  /** Resolves with the first role (welcome, or offline fallback). */
304
366
  readonly ready: Promise<RoleChange<S, C>>;
305
367
  on<K extends keyof NetHandlers<S, A, C>>(kind: K, fn: NetHandlers<S, A, C>[K]): () => void;
@@ -352,6 +414,12 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
352
414
 
353
415
  /* ------------------------------------------------------------------ helpers */
354
416
 
417
+ /**
418
+ * The contract's 12 player colours (NETPLAY.md section 3): a peer's `colour` (its seat % 12) indexes this list. The
419
+ * watch page's player strip draws them, so a game that colours its players by seat matches it.
420
+ */
421
+ export const PALETTE: readonly string[] = Object.freeze(['#8fe36a', '#ffd166', '#ef6f6c', '#6cb4ee', '#c792ea', '#f4a261', '#2ec4b6', '#ff8fab', '#a7c957', '#e9c46a', '#90e0ef', '#f28482']);
422
+
355
423
  export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
356
424
  export function lerpAngle(a: number, b: number, t: number): number {
357
425
  let d = (b - a) % (Math.PI * 2);
@@ -518,7 +586,7 @@ const LADDER = [250, 500, 1000, 2000, 4000];
518
586
  * second of frames at once), and a kick that ended the page's play for good left a phone frozen with no word.
519
587
  * After one it comes back (its seat token keeps its body), sending at the slow rate below.
520
588
  */
521
- const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed']);
589
+ const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many', 'kicked', 'room-closed', 'watch-off']);
522
590
  /**
523
591
  * Input pacing (NETPLAY.md §5): at most this many `in` frames in any rolling second from this browser, whatever
524
592
  * the frame rate or the press rate: two thirds of the relay's cap of 60, so frames that reach it bunched (a
@@ -539,6 +607,14 @@ const IN_BACKLOG_BYTES = 2048;
539
607
  * (billed 20:1), and in a 32-seat room the replicas' inputs are nearly all of them (NETPLAY.md §11).
540
608
  */
541
609
  const IDLE_INPUT_MS = 250;
610
+ /** Auto (section 16): a player shown is shown at least this long before the action cuts to another. */
611
+ const AUTO_MIN_MS = 3500;
612
+ /** Auto, with no action: look again (the leader, or the next player) this often. */
613
+ const AUTO_HOLD_MS = 12_000;
614
+ /** A followed player who left and comes back within this long is followed again. */
615
+ const WISH_MS = 30_000;
616
+ /** The watch page's live scores, from the game's `scores` probe, at most this often. */
617
+ const SCORES_MS = 1000;
542
618
 
543
619
  function heldList(held: Iterable<string> | Record<string, boolean> | undefined): string[] {
544
620
  if (!held) return [];
@@ -570,7 +646,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
570
646
  const connectTimeoutMs = opts.connectTimeoutMs ?? 4000;
571
647
  const staleMs = Math.max(2500, opts.staleMs ?? 6000);
572
648
  const device: Device = cfg?.device ?? guessDevice();
573
- const want: Want = opts.want ?? cfg?.want ?? 'play';
649
+ // A watcher (section 16) is a screen that never takes a seat, whatever else it is told.
650
+ const watching = Boolean(opts.watch ?? cfg?.watch);
651
+ const want: Want = watching ? 'screen' : (opts.want ?? cfg?.want ?? 'play');
574
652
  const canHost = opts.canHost ?? true;
575
653
  const defaultOwn = (opts.movement ?? 'owner') === 'owner';
576
654
 
@@ -588,7 +666,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
588
666
 
589
667
  const handlers: { [K in keyof NetHandlers<S, A, C>]: Set<NetHandlers<S, A, C>[K]> } = {
590
668
  role: new Set(), join: new Set(), leave: new Set(), input: new Set(), event: new Set(),
591
- snapshot: new Set(), control: new Set(), state: new Set(), round: new Set(), roster: new Set(), status: new Set(), announce: new Set(), mute: new Set(),
669
+ snapshot: new Set(), control: new Set(), state: new Set(), round: new Set(), roster: new Set(), status: new Set(), announce: new Set(), mute: new Set(), view: new Set(),
592
670
  };
593
671
  const emit = <K extends keyof NetHandlers<S, A, C>>(kind: K, arg: Parameters<NetHandlers<S, A, C>[K]>[0]): void => {
594
672
  for (const fn of [...handlers[kind]]) {
@@ -686,6 +764,27 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
686
764
 
687
765
  let probes: Probes = {};
688
766
 
767
+ // watching (section 16)
768
+ const firstFollow = cfg?.follow;
769
+ let following: Follow = typeof firstFollow === 'number' && Number.isInteger(firstFollow) && firstFollow >= 0 ? firstFollow : firstFollow === 'overview' ? null : 'auto';
770
+ /** The seat drawn now, and whether the game has shown it draws a followed player (it read viewSeat or listens). */
771
+ let viewNow: number | null = null;
772
+ let viewKnown = false;
773
+ let follows = false;
774
+ /** The page's word (game.json "watch": "overview") and the relay's (welcome.watch / watch frames). */
775
+ const policyFollow = cfg?.watchPolicy !== 'overview';
776
+ let relayFollow = true;
777
+ let relayWhy: string | null = null;
778
+ /** Auto's choice and when it was made; the newest spotlight; a followed player who left, and when. */
779
+ let autoSeat: number | null = null;
780
+ let autoAt = 0;
781
+ let spot = { seat: -1, at: 0 };
782
+ /** A spotlight waiting for the player shown to have had their moment: Auto looks again exactly then. */
783
+ let spotTimer: ReturnType<typeof setTimeout> | null = null;
784
+ let wish: number | null = null;
785
+ let wishAt = 0;
786
+ let lastViewPost = '';
787
+
689
788
  const wall = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
690
789
  const now = (): number => (offline ? Date.now() : Date.now() + offset);
691
790
 
@@ -761,6 +860,101 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
761
860
  for (const [k, d] of stateMap) stateSent.set(k, JSON.stringify(d));
762
861
  }
763
862
 
863
+ /* ------------------------------------------------------------ watching (section 16) */
864
+ const canFollowNow = (): boolean => watching && policyFollow && relayFollow;
865
+ /** The seated players, in seat order (never a watcher, never a waiting screen). */
866
+ function seated(): Peer[] {
867
+ return [...peers.values()].filter((p) => typeof p.seat === 'number' && !p.watch).sort((a, b) => (a.seat as number) - (b.seat as number));
868
+ }
869
+ const presentSeat = (s: number | null): boolean => s !== null && seated().some((p) => p.seat === s);
870
+ /** The leader of the game's `scores` probe ([{ seat, score }]), among the seated players; null without one. */
871
+ function leader(list: Peer[]): { seat: number; score: number } | null {
872
+ const fn = probes['scores'];
873
+ if (typeof fn !== 'function') return null;
874
+ let rows: unknown;
875
+ try { rows = (fn as () => unknown)(); } catch { return null; }
876
+ if (!Array.isArray(rows)) return null;
877
+ let best: { seat: number; score: number } | null = null;
878
+ for (const r of rows as { seat?: unknown; score?: unknown }[]) {
879
+ if (!r || typeof r.seat !== 'number' || !Number.isFinite(Number(r.score)) || !list.some((p) => p.seat === r.seat)) continue;
880
+ if (!best || Number(r.score) > best.score) best = { seat: r.seat, score: Number(r.score) };
881
+ }
882
+ return best;
883
+ }
884
+ const scoreOf = (s: number): number => {
885
+ const fn = probes['scores'];
886
+ try { const r = typeof fn === 'function' ? ((fn as () => { seat?: number; score?: number }[])() ?? []).find((x) => x && x.seat === s) : null; return r ? Number(r.score) || 0 : 0; } catch { return 0; }
887
+ };
888
+ /**
889
+ * AUTO: the newest spotlight on a present player (once the one shown has had AUTO_MIN_MS); with no action, every
890
+ * AUTO_HOLD_MS the leader of the game's scores (ties keep who is shown); with no scores, the next player in seat order.
891
+ */
892
+ function pickAuto(): number | null {
893
+ const list = seated();
894
+ if (!list.length) return null;
895
+ const has = (s: number): boolean => list.some((p) => p.seat === s);
896
+ const t = wall();
897
+ const cur = autoSeat !== null && has(autoSeat) ? autoSeat : null;
898
+ const dwell = t - autoAt;
899
+ let next: number | null = cur;
900
+ if (spot.seat >= 0 && spot.at > autoAt && has(spot.seat) && spot.seat !== cur && (cur === null || dwell >= AUTO_MIN_MS)) next = spot.seat;
901
+ else if (cur === null || dwell >= AUTO_HOLD_MS) {
902
+ const lead = leader(list);
903
+ if (lead) next = cur !== null && scoreOf(cur) >= lead.score ? cur : lead.seat;
904
+ else if (cur === null) next = (list[0] as Peer).seat;
905
+ else { const i = list.findIndex((p) => p.seat === cur); next = (list[(i + 1) % list.length] as Peer).seat; }
906
+ if (next === cur) autoAt = t; // looked again, and stayed: the next look is a hold from now
907
+ }
908
+ if (next !== autoSeat) { autoSeat = next; autoAt = t; }
909
+ // Action is waiting on the player shown: cut the moment their AUTO_MIN_MS is up, not on the next look.
910
+ if (spot.seat >= 0 && spot.at > autoAt && spot.seat !== next && has(spot.seat) && !spotTimer && !closed) {
911
+ spotTimer = setTimeout(() => { spotTimer = null; if (following === 'auto') resolveView('auto'); }, Math.max(0, AUTO_MIN_MS - (t - autoAt)) + 5);
912
+ }
913
+ return next;
914
+ }
915
+ /** Work out whose view to draw now, and tell the game (a `view` event) and the watch page when it changed. */
916
+ function resolveView(why: ViewWhy): void {
917
+ let next: number | null;
918
+ if (!watching) next = offline ? null : seat;
919
+ else if (!canFollowNow()) next = null;
920
+ else if (following === null) next = null;
921
+ else if (typeof following === 'number') {
922
+ if (presentSeat(following)) next = following;
923
+ else if (!roleKnown || !peers.size) next = null; // not in the room yet: the welcome says who is here
924
+ else {
925
+ // The followed player left: Auto takes over, and their view comes back if they do (a reload, a blip).
926
+ wish = following; wishAt = wall(); following = 'auto'; why = 'left';
927
+ autoSeat = null; next = pickAuto();
928
+ }
929
+ } else {
930
+ if (wish !== null && presentSeat(wish) && wall() - wishAt < WISH_MS) { following = wish; wish = null; why = 'back'; next = following; }
931
+ else { if (wish !== null && wall() - wishAt >= WISH_MS) wish = null; next = pickAuto(); }
932
+ }
933
+ if (!viewKnown || next !== viewNow) {
934
+ const prev = viewNow;
935
+ viewNow = next;
936
+ const first = !viewKnown;
937
+ viewKnown = true;
938
+ emit('view', { seat: next, following: watching ? following : next, prev, why: first ? 'start' : why });
939
+ }
940
+ postView(why);
941
+ }
942
+ function postView(why: ViewWhy | string = 'start'): void {
943
+ if (!post || !watching) return;
944
+ const whyNot = !policyFollow ? 'overview' : !relayFollow ? (relayWhy ?? 'overview') : null;
945
+ const msg = { what: 'view', seat: viewNow, following, follows, canFollow: canFollowNow(), whyNot, why };
946
+ const sig = JSON.stringify([msg.seat, msg.following, msg.follows, msg.canFollow, msg.whyNot]);
947
+ if (sig === lastViewPost) return;
948
+ lastViewPost = sig;
949
+ post(msg);
950
+ }
951
+ /** The game shows it draws a followed player: tell the watch page, so its strip lets the watcher choose. */
952
+ function markFollows(): void {
953
+ if (follows || !watching) return;
954
+ follows = true;
955
+ postView('start');
956
+ }
957
+
764
958
  function setRole(next: Role, why: string, extra: Partial<RoleChange<S, C>> = {}): void {
765
959
  const prev = roleKnown ? role : null;
766
960
  const was = role;
@@ -786,6 +980,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
786
980
  post?.({ what: 'role', role: next, prev, why, seat });
787
981
  resolveReady(e);
788
982
  emit('role', e);
983
+ resolveView('seat');
789
984
  notifyStats(true);
790
985
  }
791
986
 
@@ -827,7 +1022,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
827
1022
  }
828
1023
  case 'join': {
829
1024
  const p = m['peer'] as Peer | undefined;
830
- if (p && typeof p.id === 'string') { peers.set(p.id, p); if (role === 'host' && p.seat !== null) ctlOf(p.seat); emit('join', p); }
1025
+ if (p && typeof p.id === 'string') { peers.set(p.id, p); if (role === 'host' && p.seat !== null) ctlOf(p.seat); emit('join', p); if (watching) resolveView('auto'); }
831
1026
  return;
832
1027
  }
833
1028
  case 'leave': {
@@ -837,6 +1032,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
837
1032
  const s = typeof m['seat'] === 'number' ? m['seat'] : (p?.seat ?? null);
838
1033
  if (s !== null) { inputs.delete(s); presses.delete(s); }
839
1034
  emit('leave', { id: pid, seat: s, why: String(m['why'] ?? 'closed') });
1035
+ if (watching) resolveView('auto');
840
1036
  return;
841
1037
  }
842
1038
  case 'round': {
@@ -885,6 +1081,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
885
1081
  if (next === 'host') { lastSnapSentAt = -Infinity; if (snap) tick = Math.max(tick, snap.k); }
886
1082
  return;
887
1083
  }
1084
+ case 'watch': {
1085
+ // The relay's word on following (section 16): a seat this browser took in another tab, or taken back.
1086
+ relayFollow = m['follow'] !== false;
1087
+ relayWhy = typeof m['why'] === 'string' ? m['why'] : null;
1088
+ resolveView('policy');
1089
+ return;
1090
+ }
888
1091
  case 'error': {
889
1092
  const code = String(m['code'] ?? '');
890
1093
  // Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
@@ -926,6 +1129,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
926
1129
  peers.clear();
927
1130
  if (Array.isArray(m['peers'])) for (const p of m['peers'] as Peer[]) peers.set(p.id, p);
928
1131
  if (typeof m['st'] === 'number' && rtt === null) offset = (m['st'] as number) - Date.now();
1132
+ if (watching) {
1133
+ // A relay from before revision 5 says nothing: following is the page's to allow.
1134
+ const w = m['watch'] as { follow?: boolean; why?: string } | undefined;
1135
+ relayFollow = !w || w.follow !== false;
1136
+ relayWhy = w && typeof w.why === 'string' ? w.why : null;
1137
+ }
929
1138
  const ckpt = (m['ckpt'] as Checkpoint<C> | null) ?? null;
930
1139
  let snap = (m['snap'] as Snapshot<S> | null) ?? null;
931
1140
  const a = m['announce'] as Announcement | undefined;
@@ -951,6 +1160,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
951
1160
  if (next === 'host' && snap) tick = Math.max(tick, snap.k);
952
1161
  }
953
1162
  if (continuing) reannounce();
1163
+ resolveView('seat');
954
1164
  ping(); setTimeout(ping, 120); setTimeout(ping, 260);
955
1165
  }
956
1166
 
@@ -1203,7 +1413,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1203
1413
  ws = sock;
1204
1414
  sock.onopen = () => {
1205
1415
  lastMsgAt = wall();
1206
- raw({ t: 'hello', v: NETPLAY_VERSION, token, name: name || undefined, device, want, canHost, game: opts.game, max: opts.maxPlayers });
1416
+ raw({ t: 'hello', v: NETPLAY_VERSION, token, name: name || undefined, device, want, canHost, game: opts.game, max: opts.maxPlayers, ...(watching ? { watch: true } : {}) });
1207
1417
  };
1208
1418
  sock.onmessage = (ev) => onMessage(ev.data);
1209
1419
  sock.onclose = () => lost(sock, 'closed');
@@ -1261,6 +1471,52 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1261
1471
  } catch { /* not a browser */ }
1262
1472
  }
1263
1473
 
1474
+ // ---------------------------------------------------------------- watching
1475
+ let watchTimer: ReturnType<typeof setInterval> | null = null;
1476
+ if (watching && !offline) {
1477
+ // Auto looks again twice a second; the watch page gets live scores from the game's `scores` probe.
1478
+ let lastScores = 0;
1479
+ watchTimer = setInterval(() => {
1480
+ if (closed) return;
1481
+ if (following === 'auto' || wish !== null) resolveView('auto');
1482
+ const fn = probes['scores'];
1483
+ const t = wall();
1484
+ if (post && typeof fn === 'function' && t - lastScores >= SCORES_MS) {
1485
+ lastScores = t;
1486
+ try {
1487
+ const rows = ((fn as () => { seat?: unknown; score?: unknown }[])() ?? []).filter((r) => r && typeof r.seat === 'number' && Number.isFinite(Number(r.score)));
1488
+ post({ what: 'scores', scores: rows.map((r) => ({ seat: r.seat, score: Number(r.score) })) });
1489
+ } catch { /* a probe that throws reports nothing */ }
1490
+ }
1491
+ }, 500);
1492
+ try {
1493
+ // The watch page's strip and keys: { t: 'homie-watch', follow: seat | 'auto' | null }, from the page around the frame only.
1494
+ g.addEventListener?.('message', (ev: MessageEvent) => {
1495
+ if (ev.source !== g.parent || !ev.data || typeof ev.data !== 'object') return;
1496
+ const d = ev.data as { t?: unknown; follow?: unknown };
1497
+ if (d.t !== 'homie-watch') return;
1498
+ const f = d.follow;
1499
+ if (f === 'auto' || f === null || f === 'overview' || (typeof f === 'number' && Number.isInteger(f))) api.follow(f as Follow | 'overview');
1500
+ });
1501
+ // The same keys inside the frame, for a watcher who clicked into the game.
1502
+ if (opts.watchKeys !== false) {
1503
+ g.addEventListener?.('keydown', (ev: KeyboardEvent) => {
1504
+ if (ev.metaKey || ev.ctrlKey || ev.altKey || !follows) return;
1505
+ const list = seated();
1506
+ const k = ev.key;
1507
+ if (/^[1-9]$/.test(k)) { const p = list[Number(k) - 1]; if (p) api.follow(p.seat); return; }
1508
+ if (k === 'a' || k === 'A') { api.follow('auto'); return; }
1509
+ if (k === 'o' || k === 'O' || k === '0') { api.follow(null); return; }
1510
+ if ((k === 'ArrowRight' || k === 'ArrowLeft') && list.length) {
1511
+ const i = list.findIndex((p) => p.seat === viewNow);
1512
+ const n = k === 'ArrowRight' ? (i + 1) % list.length : (i <= 0 ? list.length - 1 : i - 1);
1513
+ api.follow((list[n] as Peer).seat);
1514
+ }
1515
+ });
1516
+ }
1517
+ } catch { /* not a browser */ }
1518
+ }
1519
+
1264
1520
  // ---------------------------------------------------------------- API
1265
1521
  const api: Netplay<S, A, C> = {
1266
1522
  get role() { return role; },
@@ -1283,9 +1539,34 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1283
1539
  get announcement() { return announcement && (announcement.until === undefined || announcement.until > now()) ? announcement : null; },
1284
1540
  isMuted(s: number | null): boolean { return s !== null && (muted.get(s) ?? 0) > now(); },
1285
1541
  pickPlayer(s: number | null): void { post?.({ what: 'pick', seat: typeof s === 'number' ? s : null }); },
1542
+ get watching() { return watching; },
1543
+ get viewSeat() { markFollows(); return watching ? viewNow : (offline ? null : seat); },
1544
+ get following() { return watching ? following : seat; },
1545
+ get watchedSeat() { return watching ? viewNow : (offline ? null : seat); },
1546
+ get canFollow() { return canFollowNow(); },
1547
+ follow(target) {
1548
+ if (!watching) return false;
1549
+ const next: Follow | undefined = target === 'auto' ? 'auto' : target === null || target === 'overview' ? null
1550
+ : typeof target === 'number' && Number.isInteger(target) && target >= 0 && target < 64 ? target : undefined;
1551
+ if (next === undefined || !canFollowNow()) return false;
1552
+ following = next;
1553
+ wish = null;
1554
+ // Auto starts from the player on screen (no cut for its own sake); a pick is shown at once.
1555
+ if (next === 'auto') { autoSeat = viewNow; autoAt = wall(); }
1556
+ resolveView('asked');
1557
+ postView('asked');
1558
+ return true;
1559
+ },
1560
+ spotlight(s) {
1561
+ if (!watching || typeof s !== 'number') return;
1562
+ spot = { seat: s, at: wall() };
1563
+ if (following === 'auto') resolveView('auto');
1564
+ },
1565
+ players: seated,
1286
1566
  ready,
1287
1567
  on(kind, fn) {
1288
1568
  (handlers[kind] as Set<unknown>).add(fn);
1569
+ if (kind === 'view') markFollows();
1289
1570
  return () => { (handlers[kind] as Set<unknown>).delete(fn); };
1290
1571
  },
1291
1572
  now,
@@ -1426,6 +1707,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1426
1707
  if (pingTimer) clearInterval(pingTimer);
1427
1708
  if (ckptTimer) clearInterval(ckptTimer);
1428
1709
  if (statsTimer) clearInterval(statsTimer);
1710
+ if (watchTimer) clearInterval(watchTimer);
1711
+ if (spotTimer) clearTimeout(spotTimer);
1429
1712
  clearTimeout(connectTimer);
1430
1713
  try { raw({ t: 'bye' }); ws?.close(1000, 'bye'); } catch { /* gone */ }
1431
1714
  },
@@ -1443,6 +1726,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
1443
1726
  get peers() { return [...peers.values()]; },
1444
1727
  get round() { return roundInfo; },
1445
1728
  get roster() { return slots; },
1729
+ get watching() { return watching; },
1730
+ get viewSeat() { return viewNow; },
1731
+ get following() { return following; },
1446
1732
  get state() { return Object.fromEntries(stateMap); },
1447
1733
  get lastCheckpoint() { return lastCkpt ? { k: lastCkpt.k, st: lastCkpt.st } : null; },
1448
1734
  /** The newest snapshot this browser received: tick, server time, sender seat, local Date.now() of arrival. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.14.3",
3
+ "version": "0.15.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/port/hud.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * results card between rounds. Everything is text on small translucent chips;
5
5
  * nothing opaque sits in the middle of the screen during play; names are set
6
6
  * with textContent (people type them). A big screen (a spectator) gets the
7
- * same board without any "you".
7
+ * same board without any "you"; a watcher following a player (NETPLAY.md
8
+ * section 16) sees that player's row and result marked the way "you" is.
8
9
  *
9
10
  * A game with its own HUD should draw the same facts itself (clock from
10
11
  * room.clock(), results from room.results()) instead.
@@ -59,19 +60,21 @@ export function createHud<B extends BodyBase>(room: Room<B, any, any>, opts: Hud
59
60
  const paint = (): void => {
60
61
  const c = room.clock();
61
62
  clock.textContent = c.phase === 'none' ? 'Joining…' : c.phase === 'live' ? `Round ${c.n} · ${Math.floor(c.secondsLeft / 60)}:${String(c.secondsLeft % 60).padStart(2, '0')}` : `Next round in ${c.secondsLeft}`;
62
- const me = room.mySeat();
63
+ // A watcher's followed player is marked as "you" is (net.watchedSeat says it without claiming the camera follows).
64
+ const me = room.net.watching ? room.net.watchedSeat : room.mySeat();
65
+ const watchingSeat = room.net.watching && me !== null;
63
66
  const rows = room.view().slice().sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot)).slice(0, phone ? 4 : 5);
64
67
  board.textContent = '';
65
68
  for (const b of rows) {
66
69
  const d = document.createElement('div');
67
- const mine = !spectator() && me !== null && b.seat === me && !b.bot;
70
+ const mine = (watchingSeat || !spectator()) && me !== null && b.seat === me && !b.bot;
68
71
  d.className = mine ? 'me' : b.bot ? 'bot' : '';
69
- d.textContent = `${mine ? 'You' : b.name}${b.bot ? ' · bot' : ''} ${b.score}`;
72
+ d.textContent = `${mine && !watchingSeat ? 'You' : b.name}${b.bot ? ' · bot' : ''} ${b.score}`;
70
73
  board.append(d);
71
74
  }
72
75
  const r = room.round;
73
76
  if (r && r.phase === 'over' && r.results) {
74
- const key = `${r.n}:${r.endsAt}`;
77
+ const key = `${r.n}:${r.endsAt}:${me}`;
75
78
  if (key !== lastResults) {
76
79
  lastResults = key;
77
80
  results.textContent = '';
@@ -79,8 +82,8 @@ export function createHud<B extends BodyBase>(room: Room<B, any, any>, opts: Hud
79
82
  const ol = document.createElement('ol');
80
83
  for (const row of r.results.slice(0, 6)) {
81
84
  const li = document.createElement('li');
82
- const mine = !spectator() && me !== null && row.seat === me && !row.bot;
83
- li.textContent = `${mine ? 'You' : row.name}${row.bot ? ' (bot)' : ''} — ${row.score}${opts.unit ? ` ${opts.unit}` : ''}`;
85
+ const mine = (watchingSeat || !spectator()) && me !== null && row.seat === me && !row.bot;
86
+ li.textContent = `${mine && !watchingSeat ? 'You' : row.name}${row.bot ? ' (bot)' : ''} — ${row.score}${opts.unit ? ` ${opts.unit}` : ''}`;
84
87
  if (mine) li.style.color = 'var(--hp-accent,#ffd166)';
85
88
  ol.append(li);
86
89
  }
package/port/room.ts CHANGED
@@ -83,6 +83,14 @@ export interface Room<B extends BodyBase, F = unknown, S = unknown> {
83
83
  readonly round: RoundInfo | null;
84
84
  /** My seat (0 when offline: a solo game with bots, the way a plain file or a dev server plays). */
85
85
  mySeat(): number | null;
86
+ /**
87
+ * WATCHING (NETPLAY.md section 16): whose camera and HUD to draw. My own seat when I play (0 offline); for a
88
+ * watcher, the seat it follows, or null for the overview camera. Point your camera at `viewBody()` (for your own
89
+ * seat, keep drawing your local body) and your HUD at its numbers; null: your spectator overview.
90
+ */
91
+ viewSeat(): number | null;
92
+ /** The body of viewSeat() as everyone draws it (host: the real one; others: interpolated), or null. */
93
+ viewBody(): B | null;
86
94
  /** Host: my own body. Replica: null (draw yours from your local state). */
87
95
  mine(): B | null;
88
96
  /** Host, every frame: the round clock and the snapshot. Replica: no-op. Call AFTER your rules moved the bodies. */
@@ -320,6 +328,13 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
320
328
  get bodies() { return bodies; },
321
329
  get round() { return hosting ? round : (net.roundInfo ?? round); },
322
330
  mySeat,
331
+ viewSeat: () => (net.offline ? 0 : net.viewSeat),
332
+ viewBody() {
333
+ const s = net.offline ? 0 : net.viewSeat;
334
+ if (s === null) return null;
335
+ for (const b of room.view()) if (!b.bot && b.seat === s) return b;
336
+ return null;
337
+ },
323
338
  mine: () => (hosting ? bodyOfSeat(mySeat()) : null),
324
339
  update() {
325
340
  if (!hosting) return;
package/port/touch.ts CHANGED
@@ -113,7 +113,10 @@ function isTouchDevice(): boolean {
113
113
  }
114
114
 
115
115
  export function createTouchControls(opts: TouchOptions = {}): TouchControls {
116
- const enabled = Boolean(opts.force) || isTouchDevice();
116
+ // A spectator (a big screen, or a watcher: NETPLAY.md section 16) plays nothing: no stick, no buttons on its glass.
117
+ const shell = (globalThis as { HOMIE_NET?: { want?: string; watch?: boolean } }).HOMIE_NET;
118
+ const spectator = shell?.watch === true || shell?.want === 'screen';
119
+ const enabled = Boolean(opts.force) || (isTouchDevice() && !spectator);
117
120
  type StickCfg = { zone: [number, number, number, number]; radius: number; dead: number; keys: Partial<Record<Dir, string>> | false; fourWay: boolean };
118
121
  const stickOpt: StickCfg | null = opts.stick === false ? null : { zone: [0, 0.3, 0.55, 1], radius: 56, dead: 7, keys: false, fourWay: false, ...(opts.stick ?? {}) } as StickCfg;
119
122
  const lookOpt = opts.look ? { zone: [0.45, 0, 1, 1] as [number, number, number, number], ...opts.look } : null;
package/site/SITE.md CHANGED
@@ -29,6 +29,8 @@ songs (each with a `cover`), videos (each with a `poster`), the latest posts, an
29
29
  (`homie-studio stats share on`) it also says "played this week": `played` for the whole studio and each game's own
30
30
  (`games[].played`, `{ "days": 7, "plays", "rounds" }`). To keep the studio's rooms off the hub, studio.json takes
31
31
  `"rooms": { "share": false }`: the manifest then names no `rooms` (the studio's own pages still list them).
32
+ Each room in `/api/rooms` carries `play` and, unless its game says `"watch": false`, `watch` (its watch door), so
33
+ the hub offers Watch beside Join.
32
34
 
33
35
  ## A game's landing: `/<id>/`
34
36
 
@@ -48,12 +50,20 @@ Made from the game's own files, nothing invented:
48
50
  phone scans to play, and how to put the game on a TV (`/<id>/tv` shows the room with its own join code).
49
51
  - **Live rooms**, each with Join. When studio.json shares its stats (`stats.share`), also "played this week".
50
52
  - **How to play**: `landing.about`, `landing.howToPlay` (a few lines), the controls, the room facts.
51
- - **Credits**: the studio and `landing.credits` (`[{ "role", "name", "url" }]`); for a port, the original, its
52
- author and licence and every part inside it (`credits.json`, as the port skill writes it), with the full
53
- licence texts on `/<id>/credits`; and Homie's open engine.
53
+ - **Credits**: the studio and `landing.credits` (`[{ "role", "name", "url" }]`); for a remix, "Remix of
54
+ <game> by <studio>" linked to the original's page (game.json `remixOf`, which `game remix` writes; it is also
55
+ under the game's name in the hero); for a port, the original, its author and licence and every part inside it
56
+ (`credits.json`, as the port skill writes it), with the full licence texts on `/<id>/credits`; Homie's open
57
+ engine; and the source's licence.
54
58
  - **Make a game like this**: the two commands that install the Homie plugin and the words to say. When the game
55
- shares its source (the default; `"share": { "source": false }` keeps it closed) the words remix it from
56
- `/games/<id>/source.json`, and the button goes to `https://homie.rocks/studio/?remix=<that address>`.
59
+ shares its source (the default; `"share": { "source": false }` keeps it closed, and the owner's Remixable
60
+ switch withdraws it) and its licence allows remixing, the words remix it from `/games/<id>/source.json`, and
61
+ the button goes to `https://homie.rocks/studio/?remix=<that address>`.
62
+
63
+ A game's licence is game.json `"license"`, the owner's pick: `"remix-with-credit"` (the default: a remix says
64
+ "Remix of <game> by <studio>" with a link back), `"remix-freely"` (the credit is welcome, not asked for) or
65
+ `"no-remix"` (the source can be read; `game remix` refuses it), and `{ "kind": "<one of those>", "spdx": "MIT" }`
66
+ names a licence too. The source.json carries it, with `credit`: the studio's name, the game's name and its page.
57
67
 
58
68
  game.json's `landing` block, every key optional:
59
69
 
@@ -156,6 +166,32 @@ The game runs in a sandboxed frame with an opaque origin, which is why its allow
156
166
  (`fullscreen *; autoplay *; gamepad *`): Safari refuses a bare `gamepad` there (`getGamepads()` throws a
157
167
  SecurityError), so a controller would not work.
158
168
 
169
+ ## The watch door: `/<id>/watch?room=<room>`
170
+
171
+ Anyone can watch a live room from any player's view (NETPLAY.md section 16). Every room row (Home, Rooms, a
172
+ game's landing) has a **Watch** button beside Join, and a landing offers "Watch a live room" while somebody plays.
173
+ The page is the game itself, drawn by the watcher's own browser as a watcher: a screen that never takes a seat.
174
+
175
+ - **The top bar:** LIVE, the game and the room, the round's clock, how many are watching, and **Play** (into
176
+ this room). **The caption** says whose view this is ("Watching Velvet Comet", "Auto · following the action").
177
+ - **The strip** at the bottom: **Auto**, each player (their colour, name and live score when the game exposes
178
+ `scores`; the leader's score lit), and **Whole room**. A tap switches; on a computer keys 1-9 pick the n-th
179
+ player, A is Auto, O (or 0) the whole room, the arrows the next or previous player, F full screen. The address
180
+ keeps the view (`&follow=<seat>|auto|overview`), so a copied link watches the same player.
181
+ - It fits a phone (the strip scrolls sideways), a computer, and a TV-sized screen (bigger type; `?hand=tv`). The
182
+ bars fade after five seconds without a touch, a move or a key, and come back with one.
183
+ - With no `room`, it watches the busiest public room (`/<id>/api/watch`, which reserves nothing); with nobody
184
+ playing it says so, offers Play, and finds a room the moment one starts. A room everyone left, a kick, a closed
185
+ room and a full one are each said plainly, with the way on.
186
+ - **What a game decides.** A game that draws `net.viewSeat` lets the watcher switch; one that does not is watched
187
+ as its overview, and the strip only names the players. game.json `"watch": "overview"` keeps watchers on the
188
+ whole room (hidden hands or roles); `"watch": false` removes the door (the page says the game is played, not
189
+ watched, and the socket is refused).
190
+ - **The same door as Play:** a private or invite-only game is watched only by those it lets in, with their
191
+ ticket; a kick holds the browser out of watching that room too. A browser that holds a seat in a room watches
192
+ that room as the whole room only, so a second tab is not a peek.
193
+ - Each opening counts as a `watch` in the studio's stats.
194
+
159
195
  ## Player accounts: `/account/`
160
196
 
161
197
  A studio whose games keep saves (game.json `"saves": true`, [../saves/SAVES.md](../saves/SAVES.md)) has a player