A Cyber-Athletic agentic platform that visualises live crowd density across a stadium, provides dynamic AI-powered wayfinding, and streams real-time IoT sensor data — all running entirely on your local machine. No API keys required.
The Pulse is a full-stack project built around three core ideas:
| Idea | Implementation |
|---|---|
| Live crowd intelligence | A simulator writes IoT-style sensor snapshots every 5 s; the backend pushes them to every browser via SSE |
| Dynamic wayfinding | A weighted Dijkstra algorithm re-routes around congested zones in real time |
| Cyber-Athletic UI | Neon-glowing SVG stadium map with density-driven colour transitions (green → yellow → orange → red) |
┌──────────────────────────────────────┐ ┌─────────────────────────────┐
│ Google Cloud Run │ SSE │ Firebase Hosting │
│ (Docker container) │◄───────│ (Global CDN) │
│ │ │ │
│ simulator/generator.js │ │ frontend/dist/ │
│ │ writes every 5s │ │ (React SPA) │
│ ▼ │ │ │
│ data/stadium-state.json │ │ VITE_API_BASE_URL │
│ │ fs.watch │ │ → Cloud Run URL │
│ ▼ │ └─────────────────────────────┘
│ backend/server.js (SSE + REST API) │
└──────────────────────────────────────┘
The simulator and backend run together in one Docker container on Cloud Run — they share the same in-container filesystem.
Install the tools you need (one-time):
# 1. Google Cloud SDK → https://cloud.google.com/sdk/docs/install
gcloud auth login
gcloud config set project YOUR_PROJECT_ID
# 2. Firebase CLI
npm install -g firebase-tools
firebase login
# 3. Enable required Google Cloud APIs
gcloud services enable run.googleapis.com artifactregistry.googleapis.com cloudbuild.googleapis.comCreate a project at console.cloud.google.com → New Project and note your Project ID.
git init
git add .
git commit -m "feat: The Pulse — production ready"
git remote add origin https://github.com/YOUR_USERNAME/the-pulse-stadium.git
git push -u origin mainBuild the Docker image using Cloud Build (no Docker needed locally) and deploy:
# From the project root
gcloud builds submit --tag gcr.io/YOUR_PROJECT_ID/the-pulse-backend .
gcloud run deploy the-pulse-backend \
--image gcr.io/YOUR_PROJECT_ID/the-pulse-backend \
--platform managed \
--region us-central1 \
--allow-unauthenticated \
--port 3001 \
--min-instances 1 \
--set-env-vars PORT=3001,ALLOWED_ORIGINS=https://YOUR_PROJECT_ID.web.app
--min-instances 1keeps the container always alive so the simulator never stops writing data.
Copy the Cloud Run URL printed at the end — e.g. https://the-pulse-backend-abcdef-uc.a.run.app
Update .firebaserc in frontend/ with your real project ID:
{ "projects": { "default": "YOUR_PROJECT_ID" } }Build the frontend (bakes in the Cloud Run URL at compile time):
cd frontend
# Windows PowerShell
$env:VITE_API_BASE_URL="https://the-pulse-backend-abcdef-uc.a.run.app"
# Mac / Linux
export VITE_API_BASE_URL=https://the-pulse-backend-abcdef-uc.a.run.app
npm run buildDeploy to Firebase:
firebase deploy --only hostingYour frontend goes live at: https://YOUR_PROJECT_ID.web.app
Allow the Firebase domain in the backend's CORS whitelist:
gcloud run services update the-pulse-backend \
--region us-central1 \
--update-env-vars ALLOWED_ORIGINS=https://YOUR_PROJECT_ID.web.app| Check | URL |
|---|---|
| Backend health | https://the-pulse-backend-xyz-uc.a.run.app/health |
| Stadium state | https://the-pulse-backend-xyz-uc.a.run.app/api/state |
| Frontend | https://YOUR_PROJECT_ID.web.app |
Cloud Run (backend) — set via --set-env-vars or Google Cloud Console
PORT=3001
ALLOWED_ORIGINS=https://YOUR_PROJECT_ID.web.appFrontend build — set before running npm run build
VITE_API_BASE_URL=https://the-pulse-backend-xyz-uc.a.run.app
⚠️ VITE_variables are baked in at build time. If the Cloud Run URL changes, rebuild and redeploy the frontend.
the-pulse-stadium/
├── .gitignore # Excludes .env, node_modules, data/, dist/
├── Dockerfile # Cloud Run container — runs simulator + backend together
├── .dockerignore
├── railway.json # (alternative) Railway deployment config
├── README.md
│
├── backend/
│ ├── .env # Local config — PORT, ALLOWED_ORIGINS (gitignored)
│ ├── .env.example # Template — safe to commit
│ ├── server.js # Express SSE bridge with strict CORS whitelist
│ └── package.json # Deps: express, cors, dotenv
│
├── frontend/
│ ├── .env # Local config — VITE_API_BASE_URL (gitignored)
│ ├── .env.example # Template — safe to commit
│ ├── src/
│ │ ├── components/
│ │ │ ├── StadiumMap.jsx # Accessible SVG map (ARIA roles, keyboard nav)
│ │ │ ├── Navigator.jsx # Wayfinding UI (Dijkstra)
│ │ │ ├── ZonePanel.jsx # Selected zone detail panel
│ │ │ └── Header.jsx # Top bar with live status + HC toggle
│ │ ├── hooks/
│ │ │ ├── useStadiumData.jsx # SSE consumer — URLs from env var
│ │ │ └── useNavigator.jsx # Route calculation hook
│ │ ├── lib/
│ │ │ ├── navigator.js # Dijkstra + MinHeap implementation
│ │ │ └── navigator.test.js # 20 Vitest unit tests
│ │ ├── index.css # Design system + high-contrast mode
│ │ └── data/
│ │ └── stadium-graph.json # Node/edge graph definition
│ └── package.json # Includes "test" and "test:watch" scripts
│
├── simulator/
│ └── generator.js # IoT data simulator (writes every 5 s)
│
└── data/
└── stadium-state.json # Live state file (auto-generated, gitignored)
You need three terminals running simultaneously.
# Backend
cd backend
npm install
# Frontend
cd ../frontend
npm install# Backend
cp backend/.env.example backend/.env
# Frontend
cp frontend/.env.example frontend/.envThe defaults work out of the box for local development. Edit only if you change ports.
# From the project root
node simulator/generator.jsGenerates data/stadium-state.json every 5 seconds with randomised occupancy across 18 stadium zones.
cd backend
node server.jsRuns on http://localhost:3001
| Endpoint | Description |
|---|---|
GET /health |
Liveness probe — returns client count + timestamp |
GET /api/state |
One-shot JSON snapshot of the latest stadium state |
GET /api/stream |
SSE stream — pushes stadium-update events on every file change |
cd frontend
npm run devOpens at http://localhost:5173
The simulator tracks 18 zones across 5 categories:
| Category | Zones |
|---|---|
| Gates | North, South, East, West |
| Seating | Section A (Upper), B (Upper), C (Lower), D (Lower), VIP Lounge |
| Concourses | North Concourse, South Concourse |
| Amenities | Food Court, Restrooms NW/SE, Merchandise Store, Medical Bay |
| Emergency | Emergency Exit E1, Emergency Exit E2 |
| Level | Fill Ratio | Colour |
|---|---|---|
| 🟢 Low | < 30% | #00ff88 |
| 🟡 Medium | 30 – 65% | #f5d000 |
| 🟠 High | 65 – 85% | #ff6600 |
| 🔴 Critical | ≥ 85% | #ff1a3a |
The navigator uses a density-weighted Dijkstra algorithm to find the least-congested path between any two zones.
weight(edge) = distance × (1 + destinationFillRatio)
- Empty zone (0%) → multiplier ×1.0 (free flow)
- Half-full (50%) → multiplier ×1.5 (slowed)
- Full (100%) → multiplier ×2.0 (worst case)
This steers the algorithm away from congested sections even when they are geographically shorter, naturally routing evacuees and fans via the clearest path.
| Function | Description |
|---|---|
buildAdjacency(graph) |
Converts edge list → undirected adjacency map |
dijkstra(adj, start, end, getDensityFn) |
Returns PathResult | null |
buildNodeMap(graph) |
O(1) node label/zoneId lookups |
formatPath(result, nodeMap) |
Turn-by-turn human-readable directions |
classifyDensity(ratio) |
Maps 0–1 fill ratio → 'low' | 'medium' | 'high' | 'critical' |
| Library | Role |
|---|---|
| React 19 | UI framework |
| Vite 8 | Dev server + bundler |
| Tailwind CSS 4 | Utility styling |
| Framer Motion | Animations & transitions |
| Lucide React | Icon library |
| Vitest | Unit test runner |
| Library | Role |
|---|---|
| Express 5 | HTTP server |
| cors | CORS middleware (strict whitelist) |
| dotenv | Environment variable loader |
Node fs.watch |
File watching (debounced, 100 ms) |
simulator/generator.js
│ writes every 5 s
▼
data/stadium-state.json
│ fs.watch detects change
▼
backend/server.js ──SSE──▶ browser (EventSource)
│
useStadiumData hook
│
StadiumMap / Navigator / ZonePanel
Sensitive configuration is kept out of source code using .env files. Each service has its own .env — copy the .example template to get started.
backend/.env
PORT=3001
ALLOWED_ORIGINS=http://localhost:5173frontend/.env
VITE_API_BASE_URL=http://localhost:3001
.envfiles are gitignored. Only.env.exampletemplates are committed.
The backend enforces a strict origin whitelist — requests from any origin not listed in ALLOWED_ORIGINS are rejected with a CORS error:
// Only origins in the comma-separated ALLOWED_ORIGINS list are permitted
cors({
origin(origin, callback) {
if (!origin || ALLOWED_ORIGINS.includes(origin)) return callback(null, true)
callback(new Error(`CORS: origin "${origin}" is not allowed.`))
}
})The previous Access-Control-Allow-Origin: * wildcard header on the SSE endpoint has been removed; the CORS middleware handles it instead.
All SVG map zones are fully keyboard-accessible:
- Tab — cycles focus through every zone on the map
- Enter / Space — selects the focused zone (same as a click)
- Focused zones display a visible dashed white focus ring
Every interactive zone exposes:
role="button"
tabIndex="0"
aria-pressed="true|false" <!-- whether the zone is selected -->
aria-label="North Gate — medium density, 42% full, wait time 3 minutes"The SVG container itself has role="img" with an aria-label that explains keyboard usage. Decorative elements (grid lines, pitch markings, crosshair) are marked aria-hidden="true".
The live status indicator in the header has aria-live="polite" so screen readers announce connection changes.
A HC toggle button in the header switches the entire UI to a high-contrast theme:
- Solid black background — no glassmorphism, no blurred panels
- Full-opacity zone colours — no semi-transparent overlays or neon glow
- Yellow focus rings (
#ffff00) for maximum keyboard visibility - All CSS tokens overridden via
[data-high-contrast="1"]on<html> - Preference is persisted in
localStorage— survives page refresh
Unit tests for the Dijkstra algorithm live in frontend/src/lib/navigator.test.js and are run with Vitest (no DOM or browser needed — pure JS functions only).
cd frontend
npm test # run once and exit
npm run test:watch # re-run on file save✓ buildAdjacency (2 tests)
✓ dijkstra — happy path (3 tests)
✓ dijkstra — start equals end (1 test)
✓ dijkstra — disconnected node (2 tests)
✓ dijkstra — zero-weight edges (2 tests)
✓ dijkstra — non-existent node (2 tests)
✓ dijkstra — congestionNote (2 tests)
✓ formatPath (2 tests)
✓ classifyDensity (4 tests)
Test Files 1 passed (1)
Tests 20 passed (20)
Duration 1.78s
| Scenario | What is verified |
|---|---|
| Happy path | Finds a valid path between two connected nodes |
| Start === End | Returns zero-cost result with empty segments |
| Disconnected node | Returns null — no infinite loop or crash |
| Zero-weight edge | Resolves correctly — no NaN or negative weights |
| Non-existent node | Throws a descriptive error message |
| Congestion note | ⚠ warning fires when route density ≥ 0.85 |
| Density avoidance | Route actively steers away from critical-density nodes |
buildAdjacency |
Edges are bidirectional in the adjacency map |
classifyDensity |
All four thresholds map to the correct label |
The backend emits two named event types on GET /api/stream:
Full stadium state payload on every simulator tick.
Informational messages from the server (e.g. "Waiting for simulator data…").
| Problem | Fix |
|---|---|
| Map shows no data / grey zones | Make sure the simulator is running first — the backend needs data/stadium-state.json to exist |
ENOENT error in backend |
Run the simulator at least once to create the data file |
| CORS error in browser | Ensure the backend is running on port 3001 and ALLOWED_ORIGINS in backend/.env includes your frontend origin |
| SSE reconnecting in a loop | Check the backend terminal — the state file may be missing or malformed |
.env changes not picked up |
Restart the backend (node server.js) after editing backend/.env |
| High-contrast mode stuck on after refresh | Clear localStorage key hc in browser DevTools → Application → Local Storage |
MIT — built for hackathon purposes. Go build something awesome. ⚡
{ "meta": { "version": "1.0.0", "generatedAt": "...", "intervalMs": 5000 }, "stadium": { "name": "The Pulse Arena", "totalCapacity": 17210, "totalOccupancy": 9344, "globalFillRatio": 0.5430, "globalDensity": "medium" }, "zones": [ { "id": "gate-north", "label": "North Gate", "type": "gate", "capacity": 800, "occupancy": 312, "fillRatio": 0.39, "density": "medium", "waitTime": 4, // minutes (gates only, null for other zones) "alertActive": false } // … 17 more zones ] }