Signal
Example apps / signal / v0.1.0
Overlay app
A round widget drawn with SVG: a ring that sweeps once a minute, a clock, and a Ping button that times a call to the game.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>REDnode signal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- A borderless, round widget. Everything outside the ring, and the hole in
the middle, is transparent: clicks there pass through to what is behind. -->
<div class="tab" data-rednode-drag title="Drag to move">REDNODE // SIGNAL</div>
<svg class="ring" viewBox="0 0 320 320" aria-hidden="true">
<circle class="track" cx="160" cy="160" r="128"/>
<circle id="sweep" class="sweep" cx="160" cy="160" r="128"/>
<circle class="inner" cx="160" cy="160" r="106"/>
<g id="ticks"></g>
</svg>
<div class="readout">
<span id="time">--:--:--</span>
<span id="frame" class="dim">NO GAME</span>
</div>
<button id="ping">Ping</button>
<script src="app.js"></script>
</body>
</html>
:root {
--line: #262c3d;
--fg: #e3e6ee;
--dim: #8a93a8;
--yellow: #fcee0a;
--cyan: #19f3ff;
--red: #ff2a52;
--display: "Chakra Petch", "Arial Narrow", sans-serif;
--mono: "JetBrains Mono", "Consolas", monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; width: 320px; height: 320px; overflow: hidden; background: transparent; }
body { position: relative; color: var(--fg); user-select: none; }
.ring { position: absolute; inset: 0; width: 320px; height: 320px; }
.track { fill: none; stroke: rgba(8, 9, 13, .82); stroke-width: 34; }
.sweep {
fill: none; stroke: var(--cyan); stroke-width: 6; stroke-linecap: butt;
transform: rotate(-90deg); transform-origin: 160px 160px;
filter: drop-shadow(0 0 4px rgba(25, 243, 255, .8));
}
.inner { fill: none; stroke: rgba(252, 238, 10, .55); stroke-width: 1.5; }
#ticks line { stroke: rgba(227, 230, 238, .5); stroke-width: 2; }
#ticks line.major { stroke: var(--yellow); stroke-width: 3; }
/* The drag handle: a tab on top of the ring. */
.tab {
position: absolute; left: 50%; top: 4px; transform: translateX(-50%);
padding: 4px 14px; font: 600 11px var(--mono); letter-spacing: .16em; color: #08090d;
background: var(--yellow); cursor: move; white-space: nowrap;
clip-path: polygon(8px 0, 100% 0, calc(100% - 8px) 100%, 0 100%);
}
/* Readout on the lower part of the ring, outside the transparent hole. */
.readout {
position: absolute; left: 50%; top: 236px; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 0;
font: 500 14px var(--mono); line-height: 1.25; text-shadow: 0 0 6px #000;
}
.readout .dim { color: var(--dim); font-size: 11px; letter-spacing: .1em; }
button {
position: absolute; left: 50%; top: 282px; transform: translateX(-50%);
padding: 5px 16px; font: 700 12px var(--display); letter-spacing: .14em; text-transform: uppercase;
color: #08090d; background: var(--cyan); border: 0; cursor: pointer;
clip-path: polygon(0 0, calc(100% - 7px) 0, 100% 7px, 100% 100%, 0 100%);
}
button:hover { background: #8ef9ff; }
button:active { transform: translateX(-50%) translateY(1px); }
body.unfocused .tab { background: var(--dim); }
// Signal: a small round HUD widget. Tests a second window, dragging by a tab,
// and click-through on the transparent hole in the middle.
'use strict';
const $ = (id) => document.getElementById(id);
const RADIUS = 128;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
// Tick marks around the ring, every 5 seconds; majors every 15.
const ticks = $('ticks');
for (let i = 0; i < 12; i++) {
const a = (i / 12) * 2 * Math.PI;
const major = i % 3 === 0;
const r1 = 145;
const r2 = major ? 156 : 151;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', 160 + Math.sin(a) * r1);
line.setAttribute('y1', 160 - Math.cos(a) * r1);
line.setAttribute('x2', 160 + Math.sin(a) * r2);
line.setAttribute('y2', 160 - Math.cos(a) * r2);
if (major) line.classList.add('major');
ticks.appendChild(line);
}
// The sweep fills the ring once a minute.
const sweep = $('sweep');
sweep.style.strokeDasharray = `0 ${CIRCUMFERENCE}`;
function tick() {
const now = new Date();
const seconds = now.getSeconds() + now.getMilliseconds() / 1000;
const length = (seconds / 60) * CIRCUMFERENCE;
sweep.style.strokeDasharray = `${length} ${CIRCUMFERENCE}`;
$('time').textContent = now.toTimeString().slice(0, 8);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
$('ping').addEventListener('click', async () => {
const started = performance.now();
try {
const r = await window.rednode.call('game.info');
$('frame').textContent = `FRAME ${r.frame} · ${(performance.now() - started).toFixed(0)} MS`;
} catch (err) {
$('frame').textContent = 'NO ANSWER';
}
});
window.rednode.on((m) => {
if (m.event === 'link') $('frame').textContent = m.connected ? 'LINKED' : 'NO GAME';
if (m.event === 'hello') $('frame').textContent = 'LINKED';
if (m.event === 'focus') document.body.classList.toggle('unfocused', !m.focused);
});
REDnode runs JavaScript. The rednode package also has TypeScript types, so an editor checks these calls; compile TypeScript to .mjs first.
{
"id": "signal",
"name": "Signal",
"version": "0.1.0",
"author": "Edgemodders",
"description": "A small round HUD widget with a transparent middle. Clicks there pass through.",
"entry": "index.html",
"kind": "window",
"width": 320,
"height": 320,
"anchor": "top-right",
"x": -120,
"y": 140,
"open": false
}
The manifest names the package and says how REDnode runs it. Its fields are in Overlay apps.
How it works
The hole in the middle and everything outside the ring is transparent, so clicks there reach the game. Any shape works.
window.rednode.call("game.info") asks the plugin for the game's frame number; the time it takes is the round trip to the game.
Pages get host events with window.rednode.on: link when the game connects, focus when the overlay gains or loses focus.
What it uses
SVG window.rednode.call window.rednode.on click-through
Try this
- Turn the ring red while the game is not linked.
Use it
Copy the files into red4ext/plugins/REDnode/apps/signal/ in the game folder, or start your own package from them: change id and name in the manifest first. The whole folder is on GitHub.