The instant replay: a chronicle line's eye relives the turn, firsthand

The dream feature lands, behind a preference that ships off for now.
The chronicle's lines grow structure — each knows its turn, counted by
the same boundary events on both ends of the wire (the server counts
the deal's own events too, or every number would sit one turn behind).
The first notable line of a turn — combat, spectacle, a targeted cast
— wears a small eye; clicking it asks the server for that one turn's
steps, rebuilt and redacted like any reel, and the replay opens
STRAIGHT into first person through the eyes of the wizard whose turn
it was: their position, the viewer's knowledge, nothing private leaked.
It plays that turn and stops. Save-video and the board toggle come
along for free.

Two camera bugs die with it: the replay tween effect tracked its own
writes, restarting the ease every frame until walks decayed into the
slow wall-piercing drift Eric saw — untracked reads run one tween per
step now. And the reel camera follows whichever eyes the reel wears,
not always your own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 16:05:07 -04:00
co-authored by Claude Fable 5
parent 68be32c43e
commit f52d0d88a9
6 changed files with 196 additions and 21 deletions
+60 -4
View File
@@ -202,6 +202,32 @@ export function humanize(e: GameEvent): string | null {
}
}
/** A chronicle line: its words, the turn it belongs to (counted by the
* same boundary events the server counts), and whether it carries the
* turn's instant-replay eye. */
export interface LogLine {
text: string;
turn: number | null;
notable: boolean;
}
/** The boundaries the turn count walks — mirrored by the server's
* momentSteps, so a chronicle turn number addresses the same commands. */
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
/** Does this event make a turn worth reliving? Combat and spectacle. */
export function isNotableEvent(e: GameEvent): boolean {
if (e.type === "spellCast") return e.target !== null || e.targetCell !== null;
return NOTABLE_EVENTS.has(e.type);
}
const NOTABLE_EVENTS = new Set([
"punched", "attackResolved", "damaged", "died", "knockedBack", "shoved",
"washedBack", "retreatedInHorror", "creatureAttacked", "boobytrapSprung",
"firewallBurned", "objectThrown", "gameWon", "positionsSwapped",
"teleported", "stonesDestroyed", "thumbOfGod", "stoneTurnedToWater",
"waterwallCrashes", "sectorRotated", "sectorRelocated", "homeBasesSwapped",
]);
const SEAT_KEY = "wizwar-seat";
const SEATS_KEY = "wizwar-seats";
const CHAT_SEEN_KEY = "wizwar-chat-seen";
@@ -265,7 +291,7 @@ class Net {
/** How many watch from the gallery (0 hides the count). */
audience = $state(0);
view = $state<GameView | null>(null);
log = $state<string[]>([]);
log = $state<LogLine[]>([]);
error = $state<string | null>(null);
/** Every seat this browser holds, across rooms. */
seats = $state<Seat[]>(loadSeats());
@@ -288,6 +314,13 @@ class Net {
onFx: ((events: GameEvent[]) => void) | null = null;
/** A catch-up reel delivered by the server. */
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
/** One turn's reel, summoned from a chronicle line's instant-replay eye. */
moment = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
/** Turns witnessed so far: counts the same boundary events the server
* counts, so a chronicle line can name the turn it belongs to. */
private turnCounter = -1;
/** The turn that already carries an instant-replay eye (one per turn). */
private eyeTurn = -1;
private seen: Record<string, number> = loadSeen();
/** Room whose live stream this connection has already shown once: states
* after the first mark themselves seen while the tab is visible. */
@@ -387,6 +420,9 @@ class Net {
case "catchUp":
this.catchUp = msg.steps;
break;
case "moment":
this.moment = msg.steps;
break;
case "events": {
let talk = 0;
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
@@ -395,8 +431,13 @@ class Net {
if (e.type === "gameStarted" && !msg.replayed) {
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
}
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
const line = humanize(e);
if (line) this.log = [...this.log, line];
if (line) {
const notable = this.turnCounter >= 0 && this.turnCounter !== this.eyeTurn && isNotableEvent(e);
if (notable) this.eyeTurn = this.turnCounter;
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
}
}
if (talk > 0) {
this.chatCount += talk;
@@ -415,7 +456,7 @@ class Net {
break;
}
case "chat": {
this.log = [...this.log, `\u{1F4AC} ${msg.player}: ${msg.text}`];
this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false }];
this.chatCount += 1;
if (this.roomId) this.markChatSeen();
break;
@@ -454,7 +495,7 @@ class Net {
}
this.error = msg.message;
// The toast fades; the chronicle remembers why nothing happened.
this.log = [...this.log, `${msg.message}`];
this.log = [...this.log, { text: `${msg.message}`, turn: null, notable: false }];
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
break;
}
@@ -475,6 +516,8 @@ class Net {
watch(roomId: string): void {
this.you = null;
this.log = [];
this.turnCounter = -1;
this.eyeTurn = -1;
this.send({ type: "watch", roomId: roomId.toUpperCase() });
}
@@ -495,6 +538,8 @@ class Net {
this.token = seat.token;
this.roomIdPending = seat.roomId;
this.log = [];
this.turnCounter = -1;
this.eyeTurn = -1;
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
}
@@ -602,6 +647,8 @@ class Net {
this.started = false;
this.players = [];
this.log = [];
this.turnCounter = -1;
this.eyeTurn = -1;
this.token = null;
this.spectating = false;
this.audience = 0;
@@ -639,6 +686,15 @@ class Net {
this.markSeen();
}
/** Summon one turn's reel by its chronicle turn number. */
requestMoment(turn: number): void {
this.send({ type: "moment", turn });
}
closeMoment(): void {
this.moment = null;
}
command(command: Command): void {
this.send({ type: "command", command });
}