A full walkthrough of how CROWDAQ and PulseDaq make decisions — from live game event to ad placement, fan message, and revenue tick.
┌─────────────────────────────────────────────────────────────┐
│ CROWDAQ + PulseDaq │
│ Real-Time Decision Graph │
└─────────────────────────────────────────────────────────────┘
┌──────────────────────┐
│ LIVE SPORTS DATA │ API-Football / Opta / Nadwell
│ (Goals, Cards, VAR) │ Orchestrator (production)
└──────────┬───────────┘
│ WebSocket / Supabase Realtime
▼
┌──────────────────────────────────────────────────────┐
│ DECISION ENGINE (server) │
│ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ Excitement │ │ Display Mode │ │
│ │ Score │ │ Selector │ │
│ │ Calculator │ │ LIVE/STATS/AD │ │
│ └──────┬───────┘ └───────┬────────┘ │
│ │ │ │
│ ┌──────▼──────────────────▼────────┐ │
│ │ Ad Rotation Engine │ │
│ │ Slot A (30s) · B (45s) · C (60s)│ │
│ │ Revenue tracking per impression │ │
│ └──────────────────┬───────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────┐ │
│ │ Message Generator (Claude AI) │ │
│ │ Novice / Casual / Expert │ │
│ │ Cached per event_id + tier │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ CROWDAQ Screen │ │ PulseDaq Fan App │
│ (bar TV) │ │ (fan's phone) │
│ · Scoreboard │ │ · Score chip │
│ · Excitement │ │ · Pulse bar │
│ · Event / Stats │ │ · Commentary msg │
│ · Ad slots │ │ · Badge unlocks │
│ · QR code │ │ · [PLAY-BY-PLAY / │
└──────────────────┘ │ COLOR] tag │
└──────────────────────┘
interface GameEvent {
matchId: string // "WC2026-USAMEX-GC"
matchMin: number // 23
type: EventType // "GOAL" | "NEARLYGOAL" | "YELLOWCARD" | "REDCARD"
// | "VAR" | "GOALCONFIRMED" | "SUBSTITUTION"
// | "HALFTIME" | "SECONDHALF" | "FULLTIME" | "KICKOFF"
team: string // "USA"
player: string // "Pulisic"
score: { home: 1, away: 0 }
excitement: number // 0–100 (computed by engine)
description: string // "Pulisic slots home from close range. 1-0 USA."
timestamp: string // ISO 8601
}The bar screen cycles through three display modes between game events — mimicking a televised broadcast. Each mode is triggered by how many ticks have elapsed since the last game event fired.
function resolveDisplayMode(ticksSinceEvent: number, eventType?: EventType): DisplayMode {
// Halftime jumps straight to AD_CENTER — no LIVE or STATS phase
if (eventType === 'HALFTIME') return 'AD_CENTER';
if (ticksSinceEvent >= 6) return 'AD_CENTER'; // cut to commercial
if (ticksSinceEvent >= 3) return 'STATS'; // broadcast stats
return 'LIVE'; // event card
}
// In the tick loop:
ticksSinceLastEvent++;
const newMode = resolveDisplayMode(ticksSinceLastEvent);
if (newMode !== currentMode && (newMode === 'STATS' || newMode === 'AD_CENTER')) {
// Push downtime (color) messages to all connected phones
pushToPhones({ novice: event.noviceDowntime, casual: event.casualDowntime,
expert: event.expertDowntime, commentaryType: 'color' });
logEntry('MODE', `Display → ${newMode} · color commentary dispatched`);
}The bar can push real-time notifications directly to the phones of fans who are actively watching via PulseDaq. These appear as iOS-style toast banners sliding in from below the Dynamic Island. Pushes are triggered two ways: automatically by game events, or as ambient venue specials on a match-minute schedule.
Fired when the simulation crosses a match-minute threshold and no higher-priority event push is active. Bars configure these from their dashboard — or they're pre-set from CROWDAQ templates.
// Event push takes priority over ambient
if (EVENT_PUSHES[eventType]) {
push = eventType === 'GOAL'
? (usaScored ? USA_GOAL_PUSH : MEX_GOAL_PUSH)
: EVENT_PUSHES[eventType];
barPushTicksRemaining = 7; // ~7 seconds at 1×
}
// Ambient only fires when no push is active
if (!currentBarPush && matchMin >= ambientPushes[nextIdx].afterMin) {
push = ambientPushes[nextIdx];
ambientPushesShown++;
}The fan app has three tabs, each surfacing a different layer of the data. All three update in real-time from the same event stream. The tab state is local to each device — a fan can be on Stats while another fan next to them is watching match messages.
// lib/data/otherGames.ts
// Each of the 4 parallel games has a scoring timeline and a minute offset
// relative to USA's current match minute.
// England vs Iran — kicked off 30 min before USA
const engMin = Math.min(usaMatchMin + 30, 98);
// Spain vs Germany — kicks off 45 min AFTER USA (shows as "Upcoming" early on)
const spaRawMin = usaMatchMin - 45;
const spaMin = Math.max(0, spaRawMin);
const spaStatus = spaRawMin < 0 ? 'pre' : spaMin >= 91 ? 'ft' : 'live';
// Score computed from a timeline of goal moments
function scoreAt(min, events) {
let home = 0, away = 0;
for (const e of events) {
if (min >= e.min) { home = e.home; away = e.away; }
}
return { home, away };
}
// e.g. Brazil vs Serbia goals: [20' Serbia 0-1] [30' Brazil 1-1] [58' Brazil 2-1] [73' Brazil 3-1]Three independent ad slots run on separate countdown timers. Each slot holds a queue of ads that rotate automatically. Revenue is tracked per impression, with a multiplier when the display is in AD_CENTER mode (the ad occupies the full main panel).
function rotateAd(state, slot) {
const ad = ADS[slot][nextIndex];
// AD_CENTER multiplier: ad fills the main screen = 1.5× impressions
const multiplier = state.displayMode === 'AD_CENTER' ? 1.5 : 1.0;
// Revenue = rate × active users in venue × display multiplier
const earned = ad.rate × activeUsers × multiplier;
totalRevenue += earned;
// e.g. Guinness at $0.08 × 3 users × 1.5 (AD_CENTER) = $0.36 per rotation
}
// Ad selection decision tree (production):
// 1. If excitement > 80 (GOAL just fired) → prioritize beer/celebration ads
// 2. If displayMode === 'HALFTIME' → bar specials (Ryan's Happy Hour)
// 3. If excitement < 40 (quiet period) → highest-CPM ad available
// 4. Default → round-robin by slot timer| Venue Size | Active Users | Sessions/Game | Rev/Game (est.) | Rev/Month (est.) |
|---|---|---|---|---|
| Small bar | 10–20 | 3 games/wk | $8–$18 | $90–$200 |
| Ryan's Bar (KC) | 50–100 | 8 games/wk | $40–$90 | $450–$1,000 |
| 10-bar network | 500–1,000 | 8 games/wk | $400–$900 | $4,500–$10,000 |
| 50-bar network | 2,500–5,000 | 8 games/wk | $2,000–$4,500 | $22k–$50k |
For each game event, three commentary messages are generated — one per fan tier. Messages are cached by event_id + tier so Claude is only called once per event type, regardless of how many fans are connected. Two commentary types serve different purposes in the broadcast cadence.
// lib/messages/generate.ts
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
// System prompt is cached — only sent once, subsequent calls are cache hits
const SYSTEM_PROMPT = `You are a live soccer commentary engine for CROWDAQ/PulseDaq.
Generate calibrated commentary for three fan tiers from a single game event.
NOVICE: No jargon. Emojis encouraged. Explain WHY it matters. ≤60 words.
CASUAL: Player names, basic stats, assume they know the rules. ≤80 words.
EXPERT: Tactics, xG, formations, pressing intensity. No hand-holding. ≤100 words.
Always return JSON: { novice, casual, expert, noviceType, casualType, expertType }
Types: "play-by-play" (immediate reaction) or "color" (tactical analysis between events).`;
export async function generateMessages(event: GameEvent, commentaryContext: 'live' | 'downtime') {
// Cache key: skip Claude if we already generated for this event + context
const cacheKey = `${event.matchId}:${event.matchMin}:${event.type}:${commentaryContext}`;
const cached = await messageCache.get(cacheKey);
if (cached) return cached;
const response = await client.messages.create({
model: "claude-haiku-4-5-20251001", // fastest + cheapest for speed-critical push
max_tokens: 500,
system: [
{
type: "text",
text: SYSTEM_PROMPT,
cache_control: { type: "ephemeral" }, // cache the system prompt — 5 min TTL
}
],
messages: [{
role: "user",
content: `Event: ${event.type} at ${event.matchMin}' — ${event.description}
Score: ${event.score.home}–${event.score.away}
Context: ${commentaryContext === 'live' ? 'Event just happened' : 'Between events — provide insight/analysis'}`,
}],
});
const result = JSON.parse(response.content[0].text);
await messageCache.set(cacheKey, result, { ttl: 3600 }); // cache 1 hour
return result;
}
// Cost estimate: Haiku at $0.00025/1K input tokens
// Cached system prompt: ~$0.000025 per call after first (90% savings)
// Per game (~15 events × 2 contexts × 3 tiers): ~$0.02 totalThe excitement score (0–100) is a composite signal computed from incoming game events. It drives the PULSE bar on both the CROWDAQ screen and each PulseDaq phone, and determines when to trigger certain ad categories (high excitement = beer celebration ads).
function computeExcitement(prev: number, event: GameEvent): number {
const deltas: Record<EventType, number> = {
GOAL: 30,
NEARLYGOAL: 15,
REDCARD: 20,
YELLOWCARD: 5,
VAR: 10,
GOALCONFIRMED: 24, // VAR confirms + relief
SUBSTITUTION: -2,
HALFTIME: -10,
SECONDHALF: 5,
FULLTIME: 20,
KICKOFF: 0,
};
const raw = prev + (deltas[event.type] ?? 0);
return Math.min(100, Math.max(20, raw)); // clamp [20, 100]
}
// Decay between events (called each tick):
function decayExcitement(current: number): number {
return Math.max(20, current - 0.5);
}Three independent engines run on every tick to keep the broadcast experience tight, the ad inventory working at full efficiency, and the fan app content feeling live even during quiet periods. All three are pure functions operating on simulation state — no side effects, easy to test.
A fourth engine caps bar push notification frequency at 15 ticks between pushes — preventing notification fatigue during event-dense sequences. High-priority events (GOAL, FULLTIME) bypass the cap and always fire immediately.
// lib/simulation/SimulationEngine.ts
// Push frequency cap: min ticks between bar pushes
// (exceptions: GOAL, FULLTIME always fire immediately)
const PUSH_FREQUENCY_CAP = 15;
function applyBarPush(state, ev) {
const isHighPriority =
ev.type === 'GOAL' || ev.type === 'FULLTIME';
if (!isHighPriority &&
state.ticksSinceLastPush < PUSH_FREQUENCY_CAP) {
return state; // Too soon — skip push
}
return {
...state,
currentBarPush: push,
barPushTicksRemaining: PUSH_DURATION_TICKS,
ticksSinceLastPush: 0, // reset cap
};
}
// Content cadence — fires fill message in quiet periods
const CADENCE_THRESHOLD = 20;
if (ticksSinceLastMessage > CADENCE_THRESHOLD
&& displayMode !== 'LIVE') {
const fill = getCadenceFill(
matchMin, scoreUSA, scoreMEX,
gameStats, contextualExcitement, cadenceFillIndex
);
// → updates novice/casual/expert messages
// → resets ticksSinceLastMessage to 0
cadenceFillIndex++;
}// lib/data/cadenceContent.ts
// 5 time-windowed message pools (25 fills total)
const FILLS_0_20: CadenceFill[] = [ ...rivalry, pulisic, group, atmosphere, tactics ]
const FILLS_20_45: CadenceFill[] = [ ...halftime-approaching, possession, historical, stats, engagement ]
const FILLS_45_60: CadenceFill[] = [ ...second-half, tactical-adjust, tournament-stakes, pressing, wc-context ]
const FILLS_60_75: CadenceFill[] = [ ...late-tension, substitutions, set-pieces, rivalry-history, crowd ]
const FILLS_75_94: CadenceFill[] = [ ...late-tension, desperate-late, injury-time, advance-r16, peak-drama ]
// Score-context messages injected every 3rd fill
function getScoreContextFill(matchMin, scoreUSA, scoreMEX) {
if (scoreDiff === 0 && matchMin >= 70) return TIED_LATE_MESSAGES;
if (diff > 0 && matchMin >= 60) return USA_PROTECTING_MESSAGES;
if (diff < 0 && matchMin >= 60) return USA_CHASING_MESSAGES;
return null; // fall through to time-pool
}
// Stats-triggered fill (injected every 4th fill when SOT > 4)
if (shotsOnTarget > 4 && fillIndex % 4 === 1) return STATS_LIVE_FILL;The bar owner portal gives Ryan a real-time view of his CROWDAQ deployment — revenue, fan activity, push campaigns, and match schedule. It scales from a free Basic tier through Partner and Premium tiers, each unlocking new capabilities as the business relationship deepens.
A TV screen that runs itself. Live World Cup data on display. A QR code that brings fans to their phones and keeps them in his bar longer. No staff training required.
His Guinness poster goes digital. His happy hour special goes to every fan who scanned in the last 30 minutes — at the exact moment they might leave. He controls Slot B.
He can pitch local advertisers with real data: "327 fans in my bar last Tuesday. Average session 41 minutes. $0.54 revenue per fan per game. Here is the CPM."
The QR code on the CROWDAQ bar screen is permanent — it never rotates and requires no staff management. A fan can scan at any moment during the match and be live within 2 taps.
Third-party advertisers (Guinness, local sponsors) pay per impression across the bar network.
$0.06–$0.12 per impression
Revenue = rate × active users × display multiplier
AD_CENTER mode: 1.5× multiplier (full-screen placement)
Platform takes 30% of third-party ad revenue. Bar keeps 70%.
Fans who scanned a bar's QR code in the last 30 days can be reached with geo-targeted push notifications.
Bar pushes specials before game day
"Tonight: $3 drafts for PulseDaq users. USA game kicks off at 8pm."
Priced per push. Bar pays platform fee.
A fan who scans a QR code at Ryan's Bar during the USA vs Mexico match is not just a “user.” They are a verified, venue-anchored, emotionally engaged sports fan with a known location, a known team, and a timestamped emotional context (excitement score at time of scan). That is the inventory unit advertisers are buying — not a cookie, not a demographic guess. A real person, in a real bar, watching a real game, who just felt something.