Proof-of-concept demo
Example apps / demo / v0.1.0
Overlay app
The overlay's proof of concept: live drawing, input, calls to the game and events from it, each in a tile.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>REDnode overlay demo</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@500;600;700&family=Rajdhani:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="panel">
<header data-rednode-drag title="Drag to move">
<div class="brand"><span class="dot"></span><b>REDNODE</b> // OVERLAY <span class="dim">PROOF OF CONCEPT</span></div>
<div class="status">
<span id="link" class="pill off">NO GAME</span>
<span id="mode" class="pill">PASSIVE</span>
</div>
</header>
<section class="grid">
<article>
<h2><span>01</span> Compositor + live HTML</h2>
<p class="big mono" id="clock">--:--:--.---</p>
<div class="bar"><i id="sweep"></i></div>
<p class="dim">Page frames: <b id="raf">0</b>. If the clock ticks and the bar moves in game, steps 1 and 2 work.</p>
</article>
<article>
<h2><span>02</span> Input</h2>
<div class="row">
<button id="click">Click me</button>
<span class="mono">Clicks: <b id="clicks">0</b></span>
</div>
<input id="text" type="text" placeholder="Type here" autocomplete="off" spellcheck="false">
<p class="dim">Last key: <b id="key" class="mono">none</b>. The camera should not move while you do this.</p>
</article>
<article>
<h2><span>03</span> Bridge to the game</h2>
<div class="row">
<button id="pos">Read player position</button>
<button id="info" class="ghost">Game frame</button>
</div>
<p class="mono result" id="result">No call yet.</p>
<p class="dim">Runs <code>GetPlayer</code> and <code>GetWorldPosition</code> on the game thread. Walk, then read again.</p>
</article>
<article>
<h2><span>04</span> Events from the game</h2>
<ol id="events" class="mono"><li class="dim">Waiting. Load a save to see OnGameAttached.</li></ol>
</article>
</section>
<footer class="mono">
<span><b>F10</b> passive / interactive / hidden</span>
<span><b>DRAG</b> the title bar to move</span>
<span><b>CLICK</b> empty space or <b>ESC</b> to return to the game</span>
<span id="windowed" hidden>WINDOW MODE: NOT CONNECTED TO THE GAME BY DESIGN</span>
</footer>
</main>
<script src="app.js"></script>
</body>
</html>
/* REDnode HUD tokens, same as docs/assets/css/hud.css */
:root {
--bg: rgba(8, 9, 13, .88);
--panel: rgba(16, 19, 27, .92);
--line: #262c3d;
--fg: #e3e6ee;
--dim: #8a93a8;
--yellow: #fcee0a;
--cyan: #19f3ff;
--red: #ff2a52;
--green: #5dffa1;
--display: "Chakra Petch", "Arial Narrow", sans-serif;
--body: "Rajdhani", "Segoe UI", sans-serif;
--mono: "JetBrains Mono", "Consolas", monospace;
--cut: 18px;
}
* { box-sizing: border-box; }
html, body { margin: 0; width: 1024px; height: 640px; overflow: hidden; background: transparent; }
body { color: var(--fg); font: 17px/1.4 var(--body); user-select: none; }
.panel {
position: absolute; inset: 8px;
display: grid; grid-template-rows: auto 1fr auto; gap: 14px; padding: 18px 22px;
background:
linear-gradient(rgba(25,243,255,.04) 1px, transparent 1px) 0 0 / 32px 32px,
linear-gradient(90deg, rgba(25,243,255,.04) 1px, transparent 1px) 0 0 / 32px 32px,
var(--bg);
border: 1px solid rgba(252,238,10,.55);
clip-path: polygon(0 0, calc(100% - var(--cut)) 0, 100% var(--cut), 100% 100%, var(--cut) 100%, 0 calc(100% - var(--cut)));
}
header {
display: flex; justify-content: space-between; align-items: center; font: 12px var(--mono); letter-spacing: .14em; color: var(--dim);
margin: -18px -22px 0; padding: 14px 22px 10px; border-bottom: 1px solid var(--line); cursor: move;
}
.panel.unfocused { border-color: rgba(252,238,10,.2); }
.panel.unfocused header .brand b { color: var(--dim); }
.brand b { color: var(--yellow); font-weight: 500; }
.dot { display: inline-block; width: 8px; height: 8px; margin-right: 8px; background: var(--green); }
.status { display: flex; gap: 8px; }
.pill { padding: 3px 9px; border: 1px solid var(--line); color: var(--cyan); }
.pill.off { color: var(--red); border-color: rgba(255,42,82,.5); }
.pill.on { color: var(--green); border-color: rgba(93,255,161,.5); }
.pill.hot { color: #08090d; background: var(--yellow); border-color: var(--yellow); }
.grid { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 14px; min-height: 0; }
article { min-height: 0; overflow: hidden; padding: 14px 16px; background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--cyan); }
h2 { margin: 0 0 10px; font: 600 16px var(--display); letter-spacing: .08em; text-transform: uppercase; }
h2 span { color: var(--yellow); font: 12px var(--mono); margin-right: 8px; }
p { margin: 8px 0 0; }
.dim { color: var(--dim); font-size: 15px; }
.dim b { color: var(--fg); }
.mono { font-family: var(--mono); }
.big { margin: 0; font-size: 34px; color: var(--yellow); letter-spacing: .02em; }
code { font: 13px var(--mono); color: var(--yellow); }
.bar { position: relative; height: 6px; margin-top: 10px; background: #0b0d13; border: 1px solid var(--line); overflow: hidden; }
.bar i { position: absolute; top: 0; bottom: 0; width: 22%; background: linear-gradient(90deg, transparent, var(--cyan)); }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
button {
font: 600 14px var(--display); letter-spacing: .1em; text-transform: uppercase;
padding: 8px 14px; color: #08090d; background: var(--yellow); border: 0; cursor: pointer;
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 0 100%);
}
button:hover { background: #fff36b; }
button:active { transform: translateY(1px); }
button.ghost { color: var(--cyan); background: transparent; outline: 1px solid rgba(25,243,255,.5); }
input {
width: 100%; margin-top: 12px; padding: 8px 10px; font: 14px var(--mono); color: var(--fg);
background: #0b0d13; border: 1px solid var(--line); border-left: 3px solid var(--yellow); outline: none;
}
input:focus { border-color: var(--cyan); }
.result { margin-top: 12px; padding: 8px 10px; min-height: 38px; font-size: 14px; background: #0b0d13; border: 1px solid var(--line); }
.result.ok { color: var(--green); }
.result.err { color: var(--red); }
#events { margin: 0; padding: 0; list-style: none; font-size: 13px; }
#events li { padding: 4px 0; border-bottom: 1px solid var(--line); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#events li b { color: var(--cyan); font-weight: 400; }
footer { display: flex; flex-wrap: wrap; gap: 6px 22px; font-size: 12px; color: var(--dim); letter-spacing: .08em; }
footer b { color: var(--yellow); font-weight: 500; }
// Demo overlay app. Every tile tests one step of the proof of concept.
'use strict';
const $ = (id) => document.getElementById(id);
// Step 1 + 2: live rendering. The clock and bar change every frame.
let rafCount = 0;
function tick(now) {
const d = new Date();
$('clock').textContent = `${d.toTimeString().slice(0, 8)}.${String(d.getMilliseconds()).padStart(3, '0')}`;
$('sweep').style.left = `${((now / 20) % 130) - 22}%`;
$('raf').textContent = ++rafCount;
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
// Step 3: input.
let clicks = 0;
$('click').addEventListener('click', () => {
$('clicks').textContent = ++clicks;
});
document.addEventListener('keydown', (e) => {
$('key').textContent = e.key === ' ' ? 'Space' : e.key;
});
// Step 4: bridge calls.
async function call(method, format) {
const out = $('result');
out.className = 'mono result';
out.textContent = `Calling ${method}...`;
const started = performance.now();
try {
const result = await window.rednode.call(method);
const ms = (performance.now() - started).toFixed(1);
out.className = 'mono result ok';
out.textContent = `${format(result)} (${ms} ms)`;
} catch (err) {
out.className = 'mono result err';
out.textContent = String(err.message || err).replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
}
}
$('pos').addEventListener('click', () =>
call('player.position', (p) => `X ${p.x.toFixed(2)} Y ${p.y.toFixed(2)} Z ${p.z.toFixed(2)}`),
);
$('info').addEventListener('click', () => call('game.info', (r) => `REDnode frame ${r.frame}`));
// Status and events from the game.
function setLink(connected) {
$('link').textContent = connected ? 'GAME LINKED' : 'NO GAME';
$('link').className = `pill ${connected ? 'on' : 'off'}`;
}
function setMode(mode) {
$('mode').textContent = (mode || 'passive').toUpperCase();
$('mode').className = `pill${mode === 'interactive' ? ' hot' : ''}`;
}
function addEvent(html) {
const list = $('events');
if (list.firstElementChild?.classList.contains('dim')) list.innerHTML = '';
const li = document.createElement('li');
li.innerHTML = `<span class="dim">${new Date().toTimeString().slice(0, 8)}</span> ${html}`;
list.prepend(li);
while (list.children.length > 6) list.lastElementChild.remove();
}
window.rednode.on((m) => {
if (m.event === 'link') {
setLink(m.connected);
addEvent(m.connected ? '<b>link</b> connected to the game' : '<b>link</b> lost');
} else if (m.event === 'hello') {
setLink(true);
setMode(m.mode);
addEvent(`<b>hello</b> ${m.plugin}`);
} else if (m.event === 'overlay') {
setMode(m.mode);
addEvent(`<b>mode</b> ${m.mode}`);
} else if (m.event === 'focus') {
document.querySelector('.panel').classList.toggle('unfocused', !m.focused);
} else if (m.event === 'game') {
addEvent(`<b>${m.name.split(' ')[0]}</b> value ${m.value}, frame ${m.frame}`);
}
});
window.rednode.state().then((s) => {
setLink(s.connected);
setMode(s.mode);
$('windowed').hidden = !s.windowed;
});
REDnode runs JavaScript. The rednode package also has TypeScript types, so an editor checks these calls; compile TypeScript to .mjs first.
{
"id": "demo",
"name": "Proof-of-concept demo",
"version": "0.1.0",
"author": "Edgemodders",
"description": "The first overlay test: live HTML, input, the bridge and game events, written without the UI kit.",
"entry": "index.html",
"kind": "window",
"width": 1024,
"height": 640,
"anchor": "center",
"x": 0,
"y": 0,
"open": false
}
The manifest names the package and says how REDnode runs it. Its fields are in Overlay apps.
How it works
The first tile redraws every frame, the second takes clicks and keys, the third calls the game and the fourth lists events. It was the first page REDnode drew in the game.
It uses plain HTML and CSS, no UI kit, to show that any web page works.
What it uses
requestAnimationFrame window.rednode.call window.rednode.state window.rednode.on
Try this
- Load a save below and watch OnGameAttached arrive in tile 04.
Use it
Copy the files into red4ext/plugins/REDnode/apps/demo/ 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.