Overlay structure
A Nerva overlay is a folder containing:
my-overlay/
├── manifest.json # Required, overlay metadata
├── overlay.html # Required, the rendered overlay
├── style.css # Optional, external stylesheet
└── assets/ # Optional, images, fonts, SVGs
├── heart.svg
└── custom-font.woff2 manifest.json
{
"name": "my-overlay",
"author": "Your Name",
"version": "1.0",
"description": "A short description of your overlay"
} namemust be unique and folder-safe (no spaces, lowercase recommended).nameis used as the folder name when installed.
Data, JavaScript API
Nerva updates a global object on every BLE state transition and on
every HR packet. The shape is the canonical
MonitorSnapshot: a ble block and a
vitals block. vitals is null whenever the device is not live
(idle, scanning, connecting, connection lost), there is no
synthetic “empty” packet, your overlay is expected to render its
own disconnected state when vitals is missing.
window.__NERVA__ = {
ble: {
status: "connected", // "idle" | "scanning" | "connecting" | "connected" | "reconnecting" | "connectionLost"
deviceName: "Polar H10", // null when not connected
address: "AA:BB:...", // null when not connected
reconnectingSecs: null // seconds since reconnection started (only during "reconnecting")
},
vitals: { // null when ble.status is not "connected" or "reconnecting"
bpm: 72, // u16, always present in a vitals object
rawRr: [820, 815], // ms intervals from this packet, or null
sensorContact: true, // BLE-reported contact flag
energyExpended: null, // kJ since session start, or null
stress: 43, // 0-100, or null while the engine is calibrating (~30s for BPM-only)
stressBand: "moderate", // "low" | "moderate" | "highFocus" | "peak", or null when stress is null
rmssd: 42.3, // ms, or null when no RR-intervals
sdnn: 51.2, // ms, or null when no RR-intervals
pnn50: 18.4 // %, or null when no RR-intervals
},
dragMode: false // true while the user is repositioning the desktop overlay window
};
The desktop overlay window also dispatches a
nerva-dragmode custom event with
detail.active as a boolean when entering or exiting
drag mode. Use it to render a visual affordance (a border, a grab
cursor) so users know the overlay is movable. It only fires inside
the desktop window, OBS browser sources never see it.
Deriving common predicates
The wire format avoids redundant booleans, derive them client-side:
const nerva = window.__NERVA__;
const isLive = nerva.vitals != null; // device is streaming
const isConnected = nerva.ble.status === "connected";
const isReconnecting = nerva.ble.status === "reconnecting";
const hasRR = nerva.vitals?.rmssd != null; // device sends RR-intervals
const isCalibrated = nerva.vitals?.stress != null; // stress engine has enough data Event listener
Nerva dispatches a custom event on each update:
window.addEventListener('nerva-update', (e) => {
const m = e.detail;
if (m.vitals) {
document.getElementById('bpm').textContent = m.vitals.bpm;
document.getElementById('stress').textContent = m.vitals.stress ?? '--';
} else {
document.getElementById('bpm').textContent = '--';
}
}); Data, CSS Custom Properties
Nerva injects these on :root, updated in real-time:
:root {
--nerva-bpm: 72; /* 0 when no vitals */
--nerva-stress: 43; /* 0 when null */
--nerva-stress-pct: 43%; /* For gauge fills */
--nerva-bpm-zone: 'rest'; /* rest | moderate | high | extreme */
--nerva-ble-status: 'connected'; /* idle | scanning | connecting | connected | reconnecting | connectionLost */
}
The default overlay also toggles a status-<bleStatus>
class on the root content element so you can style each BLE state
independently:
.content.status-idle,
.content.status-scanning,
.content.status-connecting,
.content.status-connectionLost { opacity: 0.6; }
.content.status-reconnecting { opacity: 0.85; }
.content.status-connected { opacity: 1; } Examples
Heart pulse speed tied to BPM:
.heart {
animation: pulse calc(60s / var(--nerva-bpm)) ease-in-out infinite;
} Stress bar fill:
.stress-fill {
width: var(--nerva-stress-pct);
} Data, WebSocket
Overlays connect to the local WebSocket for live data:
const ws = new WebSocket('ws://' + location.host + '/ws');
ws.onmessage = (e) => {
if (e.data === 'reload') { location.reload(); return; }
const m = JSON.parse(e.data);
// m.ble.status, m.vitals?.bpm, m.vitals?.stress, …
};
ws.onclose = () => setTimeout(connect, 1000); // auto-reconnect
The server pushes a fresh MonitorSnapshot on every real
change (BLE transition or new HR packet) and a literal
"reload" text message whenever you change the active
overlay in the app, all connected clients (OBS, desktop) reload
automatically.
Guidelines
Transparent background required
Overlays are rendered on top of games and streams. Your
<html> and <body> must have
background: transparent:
html, body {
background: transparent;
overflow: hidden;
margin: 0;
} Performance tips
- Prefer CSS animations over JavaScript animations.
- Avoid heavy DOM manipulation on every update.
- Use
transformandopacityfor animations (GPU-accelerated). - Keep total JS payload small (< 50 KB).
- Avoid
setInterval, use thenerva-updateevent instead.
Recommended dimensions
- Default overlay window size: 150 x 150 px (square).
- Users can resize freely, design fluidly, don't pin to a single size.
- Use relative units (
%,vh,vw) where possible.
Testing locally
- Open your
overlay.htmldirectly in a browser. - Paste this mock script to simulate data:
<script>
// Mock Nerva data for testing, mirrors the canonical MonitorSnapshot.
window.__NERVA__ = {
ble: { status: 'idle', deviceName: null, address: null, reconnectingSecs: null },
vitals: null,
dragMode: false
};
function bandFor(s) {
if (s < 25) return 'low';
if (s < 50) return 'moderate';
if (s < 75) return 'highFocus';
return 'peak';
}
let bpm = 60;
setInterval(() => {
bpm = 60 + Math.round(Math.random() * 40);
const stress = Math.round(Math.random() * 100);
window.__NERVA__.ble = { status: 'connected', deviceName: 'Mock H10', address: '00:00', reconnectingSecs: null };
window.__NERVA__.vitals = {
bpm, stress, stressBand: bandFor(stress),
rmssd: 42, sdnn: 51, pnn50: 18,
rawRr: null, sensorContact: true, energyExpended: null
};
document.documentElement.style.setProperty('--nerva-bpm', bpm);
document.documentElement.style.setProperty('--nerva-stress', stress);
document.documentElement.style.setProperty('--nerva-stress-pct', stress + '%');
window.dispatchEvent(new CustomEvent('nerva-update', { detail: window.__NERVA__ }));
}, 500);
</script>
Or run Nerva and open
http://localhost:9876/widget?overlay=your-overlay-name.
Packaging
As a folder
Copy your overlay folder to:
- Linux:
~/.config/nerva/overlays/my-overlay/ - Windows:
%APPDATA%/nerva/overlays/my-overlay/
As a .nerva-overlay file
ZIP your overlay folder:
cd my-overlay && zip -r ../my-overlay.nerva-overlay . && cd ..
Import via the Nerva app: Style > Import skin,
then pick the .nerva-overlay (or .zip)
file from the system picker.
Reference overlay
The bundled default overlay serves as the reference
implementation. Browse the live previews and the
.nerva-overlay bundles on the
overlays page, or look at
overlays/default/ in the Nerva repo for a working
example of every feature on this page.