diff --git a/e2e-realtime/realtime.spec.ts b/e2e-realtime/realtime.spec.ts
index 31924e3..fd8c40f 100644
--- a/e2e-realtime/realtime.spec.ts
+++ b/e2e-realtime/realtime.spec.ts
@@ -46,6 +46,12 @@ test('GM hosts a session; a player on another device sees live combat', async ({
await player.getByTestId('player-live').getByRole('link').click();
await expect(player.getByText('Lia the Brave').first()).toBeVisible();
+ // GM rolls publicly on the Dice page → the player sees it in the table feed.
+ await gm.getByLabel('Primary').getByRole('link', { name: 'Dice' }).click();
+ await gm.getByRole('button', { name: 'Roll', exact: true }).click();
+ await expect(player.getByText('Table rolls')).toBeVisible();
+ await expect(player.getByText('GM').first()).toBeVisible();
+
await gmCtx.close();
await playerCtx.close();
});
diff --git a/server/src/index.ts b/server/src/index.ts
index 730ec23..78d983d 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -100,6 +100,7 @@ export function buildServer() {
case 'seatGrant': hub.seatGrant(sender, m.gmSecret, m.targetPlayerId, m.character); break;
case 'playerPatch': hub.playerPatch(sender, m.characterId, m.diff); break;
case 'playerRoll': hub.playerRoll(sender, m.characterId, m.label, m.expression, m.total, m.breakdown); break;
+ case 'gmRoll': hub.gmRoll(sender, m.gmSecret, m.label, m.expression, m.total, m.breakdown); break;
}
});
socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); });
diff --git a/server/src/rooms.ts b/server/src/rooms.ts
index 8a024ca..1ffc143 100644
--- a/server/src/rooms.ts
+++ b/server/src/rooms.ts
@@ -184,6 +184,15 @@ export class RoomHub {
for (const p of room.players) if (p !== socket) p.send(msg);
}
+ /** The GM broadcasts one of their own (public) rolls to the players. */
+ gmRoll(socket: Sender, gmSecret: string, label: string, expression: string, total: number, breakdown: string): void {
+ const room = this.gmRoom(socket, gmSecret);
+ if (!room) return;
+ room.lastActivity = this.now();
+ const msg = { t: 'rollBroadcast', playerName: 'GM', label, expression, total, breakdown } as const;
+ for (const p of room.players) p.send(msg); // players only; the GM already sees it locally
+ }
+
disconnect(socket: Sender): void {
const conn = this.conns.get(socket);
if (!conn) return;
diff --git a/src/components/ui/RollTray.tsx b/src/components/ui/RollTray.tsx
index 097c2bd..661b84b 100644
--- a/src/components/ui/RollTray.tsx
+++ b/src/components/ui/RollTray.tsx
@@ -1,22 +1,42 @@
-import { useRollStore } from '@/stores/rollStore';
+import { useRollStore, type TrayRoll } from '@/stores/rollStore';
import { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check';
+import { naturalD20 } from '@/lib/dice/notation';
import { cn } from '@/lib/cn';
+/** A nat-20 / critical-success is a crit; a nat-1 / critical-failure is a fumble. */
+function critKind(roll: TrayRoll): 'crit' | 'fumble' | null {
+ if (roll.degree === 'critical-success') return 'crit';
+ if (roll.degree === 'critical-failure') return 'fumble';
+ const n = naturalD20(roll.result);
+ if (n === 20) return 'crit';
+ if (n === 1) return 'fumble';
+ return null;
+}
+
/** Floating result of the most recent roll, shared across the whole app. */
export function RollTray() {
const last = useRollStore((s) => s.last);
const dismiss = useRollStore((s) => s.dismiss);
if (!last) return null;
+ const crit = critKind(last);
+
return (
{last.label &&
{last.label}
}
+ {crit === 'crit' &&
✦ Critical ✦
}
+ {crit === 'fumble' &&
✦ Fumble ✦
}
{last.degree && (
{DEGREE_LABEL[last.degree]}
@@ -26,7 +46,7 @@ export function RollTray() {
-
{last.result.total}
+
{last.result.total}
{last.result.breakdown}
diff --git a/src/features/combat/CombatPage.tsx b/src/features/combat/CombatPage.tsx
index e42b4f0..47956ed 100644
--- a/src/features/combat/CombatPage.tsx
+++ b/src/features/combat/CombatPage.tsx
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { useEncounters, useEncounter } from './hooks';
import { EncounterTracker } from './EncounterTracker';
+import { RollFeed } from '@/features/player/RollFeed';
export function CombatPage() {
return {(campaign) => };
@@ -100,6 +101,9 @@ function Combat({ campaign }: { campaign: Campaign }) {
)}
+ {/* Players' live rolls reach the GM here while hosting a session. */}
+
+
setCreating(false)}
diff --git a/src/features/dice/DicePage.tsx b/src/features/dice/DicePage.tsx
index f2bc3e1..9714a4c 100644
--- a/src/features/dice/DicePage.tsx
+++ b/src/features/dice/DicePage.tsx
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
-import { rollDice, applyRollMode, DiceParseError, type RollResult } from '@/lib/dice/notation';
+import { rollDice, applyRollMode, naturalD20, DiceParseError, type RollResult } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { diceRepo } from '@/lib/db/repositories';
+import { broadcastGmRoll } from '@/lib/sync/wsSync';
import { useUiStore } from '@/stores/uiStore';
import { useMacroStore } from '@/stores/macroStore';
import { useRollStore } from '@/stores/rollStore';
+import { useSessionStore } from '@/stores/sessionStore';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
@@ -29,6 +31,9 @@ export function DicePage() {
const mode = useRollStore((s) => s.mode);
const toggleMode = useRollStore((s) => s.toggleMode);
+ const secret = useRollStore((s) => s.secret);
+ const toggleSecret = useRollStore((s) => s.toggleSecret);
+ const isGm = useSessionStore((s) => s.role) === 'gm';
const roll = async (expression: string, label = '') => {
try {
@@ -45,6 +50,7 @@ export function DicePage() {
total: result.total,
breakdown: result.breakdown,
});
+ if (!useRollStore.getState().secret) broadcastGmRoll((label + modeTag).trim() || 'Roll', finalExpr, result.total, result.breakdown);
} catch (e) {
setError(e instanceof DiceParseError ? e.message : 'Could not roll that expression');
}
@@ -105,12 +111,26 @@ export function DicePage() {
>
Disadvantage
+ {isGm && (
+
+ )}
{mode !== 'normal' && (
{mode === 'advantage' ? 'Advantage' : 'Disadvantage'} is on — the next single die rolls twice, keeping the {mode === 'advantage' ? 'highest' : 'lowest'}.
)}
+ {isGm && secret && (
+
🤫 Secret is on — your rolls are not shared with the players.
+ )}
{/* Saved rolls (macros) */}
@@ -150,15 +170,21 @@ export function DicePage() {
{error &&