Package OBS overlays for Docker

This commit is contained in:
2026-09-08 14:23:30 +00:00
commit 6f1c61a2ad
38 changed files with 2832 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
data/
__pycache__/
.pytest_cache/
*.pyc
*.zip
+35
View File
@@ -0,0 +1,35 @@
# OBS Overlays Docker Stack
LAN-only browser-source overlays for OBS. Each overlay runs in its own Docker container and keeps editable JSON state in `./data`, outside Git, so Portainer redeploys do not wipe the active stream state.
## Services
| Overlay | Container | Port | OBS URL | Editor URL | State file |
|---|---|---:|---|---|---|
| Pokemon Party | `pokemon-party-overlay` | 8765 | `http://SERVER-IP:8765/overlay.html` | `http://SERVER-IP:8765/editor.html` | `./data/pokemon-party/party.json` |
| Gauntlet | `gauntlet-overlay` | 8770 | `http://SERVER-IP:8770/overlay.html` | `http://SERVER-IP:8770/editor.html` | `./data/gauntlet/gauntlet.json` |
| Blackout Bingo | `blackout-bingo-overlay` | 8780 | `http://SERVER-IP:8780/overlay.html` | `http://SERVER-IP:8780/editor.html` | `./data/blackout-bingo/bingo.json` |
## Portainer Git deployment
1. Push this repo to Gitea.
2. In Portainer, go to Stacks -> Add stack.
3. Choose Git Repository.
4. Paste the Gitea repo URL.
5. Set Compose path to `docker-compose.yml`.
6. Deploy the stack.
Redeploy the stack after Git changes to rebuild containers while keeping `./data` state files.
## Local test
```bash
docker compose up -d --build
curl http://127.0.0.1:8765/overlay.html
curl http://127.0.0.1:8770/overlay.html
curl http://127.0.0.1:8780/api/bingo
```
## LAN-only note
This stack publishes plain HTTP ports on the Docker host. Do not port-forward these editors to the internet. If remote access is needed later, put them behind VPN or authenticated reverse proxy.
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
.pytest_cache/
*.pyc
*.zip
data/
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY . /app
EXPOSE 8780
CMD ["sh", "-c", "mkdir -p /data && [ -f /data/bingo.json ] || cp /app/defaults/bingo.json /data/bingo.json && ln -sf /data/bingo.json /app/bingo.json && exec python /app/server.py 8780"]
+69
View File
@@ -0,0 +1,69 @@
Blackout Bingo OBS Overlay Prototype
Local Windows run:
1. Extract the bingo-overlay folder.
2. Double-click start-bingo.bat.
3. Open the editor:
http://localhost:8780/editor.html
4. Add up to 100 tasks, one per line.
5. Click Save Tasks.
6. Click Generate Board to randomly pick 25 unique tasks for the 5x5 card.
7. Add this OBS Browser Source:
http://localhost:8780/overlay.html
Recommended OBS Browser Source size:
Width: 1000
Height: 900
Server/Docker run:
This overlay can also run on a server, Docker host, or Dell20.
1. Copy the bingo-overlay folder to the Docker host.
2. From inside the folder, run:
docker compose up -d --build
3. Open the editor from another machine:
http://SERVER-IP:8780/editor.html
4. Add this OBS Browser Source on the streaming PC:
http://SERVER-IP:8780/overlay.html
For Dell20, replace SERVER-IP with Dell20's LAN IP or hostname. If Dell20 resolves on your network, this will usually be one of:
http://obin-core.wheelz.lab:8780/overlay.html
http://192.168.20.123:8780/overlay.html
Docker persistence:
- docker-compose.yml stores the saved bingo state under:
./data/bingo.json
- This keeps the board and task list through container restarts/rebuilds.
- To reset everything manually, stop the stack and remove data/bingo.json, then start it again.
How completion works:
- This is a blackout bingo card.
- Each square can be marked by Player 1, Player 2, or both.
- Player names are editable at the top of editor.html.
- Blue mark = Player 1.
- Pink mark = Player 2.
- Gold/mixed highlight = both players completed that square.
Files:
server.py Save/API server
editor.html Task entry, board generation, and completion controls
overlay.html OBS overlay view
bingo.json Default/single-machine saved task pool, player names, and current board
start-bingo.bat Windows launcher
Dockerfile Container image definition
docker-compose.yml Docker deployment on a server
Notes:
- If running locally with the BAT file, keep the start-bingo.bat window open while streaming.
- If running with Docker, the container keeps running in the background until stopped.
- Generate Board clears completion marks because it creates a new card.
- Save Tasks preserves the current board and marks.
- The overlay auto-refreshes about every 1.5 seconds.
+160
View File
@@ -0,0 +1,160 @@
{
"players": [
"Player 1",
"Player 2"
],
"tasks": [
"Win a boss fight",
"Find a hidden item",
"Get a critical hit",
"Survive with 1 HP",
"Defeat a rival",
"Catch a new teammate",
"Lose a teammate",
"Win without healing",
"Find a rare item",
"Complete a side objective",
"Beat a dungeon",
"Unlock a shortcut",
"Get poisoned",
"Learn a new move",
"Buy something useful",
"Win first try",
"Win after a reset",
"Use a status move",
"Defeat an optional enemy",
"Reach a new town",
"Trigger a cutscene",
"Get ambushed",
"Upgrade equipment",
"Finish a challenge room",
"Chat picks a choice"
],
"board": [
{
"id": 0,
"task": "Buy something useful",
"completedBy": []
},
{
"id": 1,
"task": "Defeat a rival",
"completedBy": []
},
{
"id": 2,
"task": "Lose a teammate",
"completedBy": []
},
{
"id": 3,
"task": "Reach a new town",
"completedBy": []
},
{
"id": 4,
"task": "Defeat an optional enemy",
"completedBy": []
},
{
"id": 5,
"task": "Get ambushed",
"completedBy": []
},
{
"id": 6,
"task": "Chat picks a choice",
"completedBy": []
},
{
"id": 7,
"task": "Finish a challenge room",
"completedBy": []
},
{
"id": 8,
"task": "Beat a dungeon",
"completedBy": []
},
{
"id": 9,
"task": "Survive with 1 HP",
"completedBy": []
},
{
"id": 10,
"task": "Win first try",
"completedBy": []
},
{
"id": 11,
"task": "Win without healing",
"completedBy": []
},
{
"id": 12,
"task": "Trigger a cutscene",
"completedBy": []
},
{
"id": 13,
"task": "Find a hidden item",
"completedBy": []
},
{
"id": 14,
"task": "Complete a side objective",
"completedBy": []
},
{
"id": 15,
"task": "Upgrade equipment",
"completedBy": []
},
{
"id": 16,
"task": "Unlock a shortcut",
"completedBy": []
},
{
"id": 17,
"task": "Win after a reset",
"completedBy": []
},
{
"id": 18,
"task": "Find a rare item",
"completedBy": []
},
{
"id": 19,
"task": "Get a critical hit",
"completedBy": []
},
{
"id": 20,
"task": "Catch a new teammate",
"completedBy": []
},
{
"id": 21,
"task": "Win a boss fight",
"completedBy": []
},
{
"id": 22,
"task": "Learn a new move",
"completedBy": []
},
{
"id": 23,
"task": "Use a status move",
"completedBy": []
},
{
"id": 24,
"task": "Get poisoned",
"completedBy": []
}
]
}
+115
View File
@@ -0,0 +1,115 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bingo Overlay Editor</title>
<style>
:root { --bg:#0b1020; --panel:#151d34; --line:#2d3857; --text:#f7f0dc; --muted:#9faccc; --gold:#ffe66d; --blue:#2f8cff; --green:#35d06f; --bad:#ff6b6b; --good:#62e884; }
* { box-sizing:border-box; }
body { margin:0; background: radial-gradient(circle at top, #202b50, #0b1020 48%); color:var(--text); font-family:"Segoe UI", system-ui, sans-serif; }
.wrap { max-width:1220px; margin:0 auto; padding:28px 18px 60px; }
header { display:flex; justify-content:space-between; gap:18px; align-items:flex-end; margin-bottom:18px; }
h1 { margin:0; font-size:38px; letter-spacing:.06em; text-transform:uppercase; }
.hint { color:var(--muted); max-width:760px; line-height:1.45; }
.panel { background:rgba(21,29,52,.9); border:1px solid var(--line); border-radius:18px; padding:16px; box-shadow:0 18px 50px rgba(0,0,0,.3); margin-bottom:16px; }
.top { display:grid; grid-template-columns:1fr 1fr auto; gap:12px; align-items:end; }
label { display:block; color:var(--muted); font-size:13px; text-transform:uppercase; letter-spacing:.08em; margin-bottom:6px; }
input, textarea { width:100%; background:#090d1a; color:var(--text); border:1px solid var(--line); border-radius:12px; padding:10px 12px; font:inherit; }
textarea { min-height:300px; resize:vertical; line-height:1.35; }
button { border:0; border-radius:12px; padding:11px 14px; font-weight:800; cursor:pointer; color:#08101d; background:var(--gold); box-shadow:0 8px 20px rgba(0,0,0,.22); }
button.secondary { background:#253151; color:var(--text); border:1px solid var(--line); }
button.blue { background:var(--blue); }
button.green { background:var(--green); color:#041409; }
button.bad { background:var(--bad); color:white; }
button:disabled { opacity:.45; cursor:not-allowed; }
.actions { display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
.status { color:var(--muted); min-height:24px; margin-top:10px; }
.status.good { color:var(--good); } .status.bad { color:var(--bad); }
.columns { display:grid; grid-template-columns: 420px 1fr; gap:16px; }
.board { display:grid; grid-template-columns:repeat(5, 1fr); gap:8px; }
.cell { min-height:100px; border:1px solid var(--line); border-radius:13px; background:#0c1223; padding:8px; display:flex; flex-direction:column; justify-content:space-between; gap:8px; }
.task { font-weight:750; line-height:1.15; font-size:14px; }
.marks { display:grid; grid-template-columns:1fr 1fr; gap:5px; }
.marks button { padding:7px 5px; font-size:12px; background:#253151; color:var(--text); border:1px solid var(--line); }
.marks button.on.p1 { background:var(--blue); color:#03101a; }
.marks button.on.p2 { background:var(--green); color:#041409; }
.count { font-weight:800; color:var(--gold); }
.small { color:var(--muted); font-size:13px; line-height:1.35; }
@media (max-width: 920px) { .columns, .top { grid-template-columns:1fr; } header { display:block; } }
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1>Bingo Editor</h1>
<div class="hint">Add up to 100 possible tasks, one per line. Generate a 5x5 blackout card from 25 random unique tasks. Use the board controls to mark squares completed by either player.</div>
</div>
<div class="actions"><a href="/overlay.html" target="_blank"><button class="secondary">Open Overlay</button></a></div>
</header>
<section class="panel top">
<div><label>Player 1 name</label><input id="player1" value="Player 1"></div>
<div><label>Player 2 name</label><input id="player2" value="Player 2"></div>
<div class="actions"><button id="saveBtn">Save Tasks</button><button id="generateBtn" class="blue">Generate Board</button></div>
</section>
<div class="columns">
<section class="panel">
<label>Task pool <span class="count" id="taskCount">0 / 100</span></label>
<textarea id="tasks" placeholder="Enter one task per line..."></textarea>
<div class="actions" style="margin-top:12px">
<button id="saveBtn2">Save Tasks</button>
<button id="sampleBtn" class="secondary">Load Sample 30</button>
<button id="clearTasksBtn" class="bad">Clear Tasks</button>
</div>
<div id="status" class="status"></div>
<p class="small">Tip: Generate Board resets all completion marks. Save Tasks keeps the current board if the board cells still exist.</p>
</section>
<section class="panel">
<div class="actions" style="justify-content:space-between;margin-bottom:12px">
<div><label style="margin:0">Current board</label><div class="small">Click player buttons under each square to toggle completion.</div></div>
<button id="clearMarksBtn" class="secondary">Clear Marks</button>
</div>
<div id="board" class="board"></div>
</section>
</div>
</div>
<script>
let state = { players: ['Player 1', 'Player 2'], tasks: [], board: [] };
const sampleTasks = Array.from({length: 30}, (_, i) => [
'Win a boss fight','Find a hidden item','Get a critical hit','Survive with 1 HP','Defeat a rival','Catch a new teammate','Lose a teammate','Win without healing','Find a rare item','Complete a side objective','Beat a dungeon','Unlock a shortcut','Get poisoned','Learn a new move','Buy something useful','Win first try','Win after a reset','Use a status move','Defeat an optional enemy','Reach a new town','Trigger a cutscene','Get ambushed','Upgrade equipment','Finish a challenge room','Chat picks a choice','Complete a level','Find a secret door','Use only starter gear','Beat a timer','Make a risky choice'
][i]);
function getTasksFromBox() { return document.getElementById('tasks').value.split(/\r?\n/).map(t => t.trim()).filter(Boolean).slice(0, 100); }
function updateCount() { const n = document.getElementById('tasks').value.split(/\r?\n/).map(t => t.trim()).filter(Boolean).length; document.getElementById('taskCount').textContent = `${n} / 100`; document.getElementById('generateBtn').disabled = n < 25 || n > 100; }
function setStatus(msg, kind='') { const el = document.getElementById('status'); el.textContent = msg; el.className = 'status ' + kind; }
async function api(path, payload) { const res = await fetch(path, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload || {}) }); const json = await res.json(); if (!json.ok) throw new Error(json.error || 'Request failed'); return json.data; }
async function load() { const res = await fetch('/api/bingo?ts=' + Date.now(), {cache:'no-store'}); const json = await res.json(); state = json.data; document.getElementById('player1').value = state.players?.[0] || 'Player 1'; document.getElementById('player2').value = state.players?.[1] || 'Player 2'; document.getElementById('tasks').value = (state.tasks || []).join('\n'); updateCount(); renderBoard(); }
async function save() { state.players = [document.getElementById('player1').value.trim() || 'Player 1', document.getElementById('player2').value.trim() || 'Player 2']; state.tasks = getTasksFromBox(); state = await api('/api/bingo', state); setStatus('Saved bingo.json directly.', 'good'); renderBoard(); updateCount(); }
async function generate() { await save(); state = await api('/api/generate', {}); setStatus('Generated a new 5x5 board from 25 unique tasks.', 'good'); renderBoard(); }
async function mark(id, player) { state = await api('/api/mark', { cellId:id, player }); renderBoard(); }
async function clearMarks() { state.board = (state.board || []).map(c => ({...c, completedBy: []})); state = await api('/api/bingo', state); renderBoard(); setStatus('Cleared completion marks.', 'good'); }
function renderBoard() {
const board = document.getElementById('board');
const players = state.players || ['Player 1','Player 2'];
if (!state.board || state.board.length === 0) { board.innerHTML = '<div class="small">No board yet. Add at least 25 tasks and click Generate Board.</div>'; return; }
board.innerHTML = state.board.map(cell => {
const p1 = cell.completedBy?.includes(players[0]); const p2 = cell.completedBy?.includes(players[1]);
return `<div class="cell"><div class="task">${cell.task}</div><div class="marks"><button class="p1 ${p1?'on':''}" data-id="${cell.id}" data-player="${players[0]}">${players[0]}</button><button class="p2 ${p2?'on':''}" data-id="${cell.id}" data-player="${players[1]}">${players[1]}</button></div></div>`;
}).join('');
board.querySelectorAll('button[data-id]').forEach(btn => btn.addEventListener('click', () => mark(Number(btn.dataset.id), btn.dataset.player)));
}
document.getElementById('tasks').addEventListener('input', updateCount);
document.getElementById('saveBtn').addEventListener('click', () => save().catch(e => setStatus(e.message, 'bad')));
document.getElementById('saveBtn2').addEventListener('click', () => save().catch(e => setStatus(e.message, 'bad')));
document.getElementById('generateBtn').addEventListener('click', () => generate().catch(e => setStatus(e.message, 'bad')));
document.getElementById('clearMarksBtn').addEventListener('click', () => clearMarks().catch(e => setStatus(e.message, 'bad')));
document.getElementById('sampleBtn').addEventListener('click', () => { document.getElementById('tasks').value = sampleTasks.join('\n'); updateCount(); setStatus('Loaded sample tasks. Click Save or Generate Board.', ''); });
document.getElementById('clearTasksBtn').addEventListener('click', () => { document.getElementById('tasks').value = ''; updateCount(); setStatus('Task box cleared. Click Save to commit.', ''); });
load().catch(e => setStatus(e.message, 'bad'));
</script>
</body>
</html>
+221
View File
@@ -0,0 +1,221 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Blackout Bingo Overlay</title>
<style>
:root {
--ink: #050505;
--coal: #0b0b0a;
--charcoal: #151514;
--slate: #242424;
--stone: #3a3935;
--line: rgba(238, 235, 225, 0.18);
--line-strong: rgba(238, 235, 225, 0.36);
--text: #efede6;
--muted: #aaa69d;
--paper: #d9d6cc;
--p1: #2f8cff;
--p2: #35d06f;
--both: #ffffff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 30% 18%, rgba(100, 100, 94, .20), transparent 35%),
radial-gradient(circle at 80% 80%, rgba(60, 60, 58, .22), transparent 42%),
linear-gradient(135deg, #000 0%, #090909 44%, #161614 100%);
font-family: Inter, "Segoe UI", system-ui, sans-serif;
color: var(--text);
overflow: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
background-size: 46px 46px, 46px 46px;
mask-image: linear-gradient(to bottom, rgba(0,0,0,.45), transparent 78%);
}
.wrap {
width: 100vw;
height: 100vh;
padding: 12px;
display: grid;
place-items: center;
}
.card {
width: min(94vw, 980px);
background:
linear-gradient(145deg, rgba(34, 34, 32, .98), rgba(7, 7, 7, .99) 58%, rgba(18, 18, 17, .98)),
#0b0b0a;
border: 1px solid var(--line-strong);
border-radius: 18px;
padding: 16px;
box-shadow: 0 26px 80px rgba(0,0,0,.64), inset 0 0 0 1px rgba(255,255,255,.05);
position: relative;
overflow: hidden;
}
.card::before {
content: "BLACKOUT DOSSIER";
position: absolute;
right: 18px;
top: 14px;
color: rgba(238,235,225,.08);
font-family: Georgia, "Times New Roman", serif;
font-size: 54px;
font-weight: 800;
letter-spacing: -.05em;
line-height: .8;
pointer-events: none;
white-space: nowrap;
}
header {
position: relative;
display: grid;
grid-template-columns: 1fr auto;
align-items: end;
gap: 18px;
margin-bottom: 16px;
padding: 12px 2px 14px;
border-bottom: 1px solid var(--line);
}
h1 {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(28px, 3.7vw, 46px);
line-height: .98;
letter-spacing: -.045em;
color: var(--paper);
text-transform: none;
text-shadow: 0 2px 0 #000, 0 20px 36px rgba(0,0,0,.55);
}
.sub {
color: var(--muted);
font-size: 11px;
letter-spacing: .22em;
text-transform: uppercase;
margin-bottom: 8px;
}
.legend { display:flex; gap: 8px; align-items:center; color: var(--muted); font-size: 12px; flex-wrap: wrap; justify-content: end; max-width: 430px; }
.pill { border: 1px solid var(--line); border-radius: 999px; padding: 6px 10px; background: rgba(255,255,255,.045); box-shadow: inset 0 1px 0 rgba(255,255,255,.05); }
.p1 { color: var(--p1); } .p2 { color: var(--p2); } .both { color: white; }
.grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.cell {
position: relative;
min-height: clamp(78px, 11.6vh, 104px);
padding: 14px 10px 30px;
display: grid;
place-items: center;
text-align: center;
border-radius: 10px;
border: 1px solid rgba(238,235,225,.14);
background:
linear-gradient(155deg, rgba(255,255,255,.07), rgba(255,255,255,.018) 42%, rgba(0,0,0,.18)),
#161615;
box-shadow: 0 10px 24px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.05);
font-weight: 760;
font-size: clamp(12px, 1.45vw, 17px);
line-height: 1.12;
overflow: hidden;
color: #e8e5dc;
}
.cell::before {
content: attr(data-index);
position: absolute;
top: 7px;
left: 8px;
color: rgba(238,235,225,.22);
font-size: 9px;
letter-spacing: .18em;
font-weight: 800;
}
.cell.p1done {
border-color: rgba(47,140,255,.86);
background:
radial-gradient(circle at 50% 24%, rgba(47,140,255,.34), transparent 45%),
linear-gradient(145deg, rgba(47,140,255,.22), rgba(34,34,32,.78));
color: white;
}
.cell.p2done {
border-color: rgba(53,208,111,.80);
background:
repeating-linear-gradient(-45deg, rgba(53,208,111,.16) 0 5px, transparent 5px 13px),
linear-gradient(145deg, rgba(53,208,111,.25), rgba(16,16,15,.88));
color: #f1eee6;
}
.cell.bothdone {
border-color: rgba(115,236,255,.92);
background:
radial-gradient(circle at 30% 28%, rgba(47,140,255,.38), transparent 42%),
radial-gradient(circle at 72% 70%, rgba(53,208,111,.34), transparent 44%),
linear-gradient(145deg, rgba(210,207,197,.22), rgba(45,45,43,.74));
color: white;
box-shadow: 0 13px 32px rgba(0,0,0,.34), inset 0 0 0 1px rgba(115,236,255,.20);
}
.marks { position:absolute; left: 9px; right: 9px; bottom: 8px; display:flex; justify-content:center; gap: 6px; }
.mark { width: 24px; height: 7px; border-radius: 999px; opacity: .38; background: rgba(238,235,225,.26); border: 1px solid rgba(238,235,225,.15); }
.mark.on.p1m { opacity:1; background: #2f8cff; box-shadow: 0 0 16px rgba(47,140,255,.45); }
.mark.on.p2m { opacity:1; background: #35d06f; box-shadow: 0 0 16px rgba(53,208,111,.40); }
.empty { color: var(--muted); padding: 60px 20px; text-align: center; border: 1px dashed var(--line); border-radius: 12px; background: rgba(255,255,255,.035); }
</style>
</head>
<body>
<main class="wrap">
<section class="card">
<header>
<div>
<div class="sub">Stream Challenge</div>
<h1>Blackout Bingo</h1>
</div>
<div class="legend" id="legend"></div>
</header>
<div id="board" class="grid" aria-label="Bingo board"></div>
</section>
</main>
<script>
async function loadBingo() {
const res = await fetch('/api/bingo?ts=' + Date.now(), { cache: 'no-store' });
const json = await res.json();
return json.data;
}
function classFor(cell, players) {
const p1 = cell.completedBy?.includes(players[0]);
const p2 = cell.completedBy?.includes(players[1]);
if (p1 && p2) return 'bothdone';
if (p1) return 'p1done';
if (p2) return 'p2done';
return '';
}
function render(data) {
const players = data.players || ['Player 1', 'Player 2'];
document.getElementById('legend').innerHTML = `<span class="pill p1">${players[0]}</span><span class="pill p2">${players[1]}</span><span class="pill both">Both = blackout square</span>`;
const board = document.getElementById('board');
if (!data.board || data.board.length === 0) {
board.className = '';
board.innerHTML = '<div class="empty">No board generated yet. Open the editor, add at least 25 tasks, then generate a card.</div>';
return;
}
board.className = 'grid';
board.innerHTML = data.board.map((cell, i) => {
const p1 = cell.completedBy?.includes(players[0]);
const p2 = cell.completedBy?.includes(players[1]);
return `<div class="cell ${classFor(cell, players)}" data-index="${String(i + 1).padStart(2, '0')}"><span>${cell.task}</span><div class="marks"><i class="mark p1m ${p1 ? 'on' : ''}"></i><i class="mark p2m ${p2 ? 'on' : ''}"></i></div></div>`;
}).join('');
}
async function refresh() { try { render(await loadBingo()); } catch (e) { console.error(e); } }
refresh();
setInterval(refresh, 1500);
</script>
</body>
</html>
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
import json
import os
import random
import sys
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
DATA_FILE = Path(os.environ.get("BINGO_DATA_FILE", Path(__file__).with_name("bingo.json"))).expanduser()
MAX_TASKS = 100
BOARD_SIZE = 25
def default_data():
return {"players": ["Player 1", "Player 2"], "tasks": [], "board": []}
def load_data():
if not DATA_FILE.exists():
save_data(default_data())
with DATA_FILE.open("r", encoding="utf-8") as fh:
data = json.load(fh)
data.setdefault("players", ["Player 1", "Player 2"])
data.setdefault("tasks", [])
data.setdefault("board", [])
return data
def clean_data(data):
tasks = [str(t).strip() for t in data.get("tasks", []) if str(t).strip()]
if len(tasks) > MAX_TASKS:
raise ValueError("You can save up to 100 tasks.")
players = data.get("players") or ["Player 1", "Player 2"]
players = [str(p).strip() or f"Player {i + 1}" for i, p in enumerate(players[:2])]
while len(players) < 2:
players.append(f"Player {len(players) + 1}")
board = []
for i, cell in enumerate(data.get("board", [])[:BOARD_SIZE]):
task = str(cell.get("task", "")).strip()
if not task:
continue
completed = []
for player in cell.get("completedBy", []):
player = str(player).strip()
if player and player in players and player not in completed:
completed.append(player)
board.append({"id": int(cell.get("id", i)), "task": task, "completedBy": completed})
return {"players": players, "tasks": tasks, "board": board}
def save_data(data):
clean = clean_data(data)
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = DATA_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps(clean, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
tmp.replace(DATA_FILE)
return clean
def generate_board(seed=None):
data = load_data()
tasks = list(dict.fromkeys([t.strip() for t in data.get("tasks", []) if t.strip()]))
if len(tasks) < BOARD_SIZE:
raise ValueError("Add at least 25 unique tasks before generating a board.")
rng = random.Random(seed) if seed else random.SystemRandom()
selected = rng.sample(tasks, BOARD_SIZE)
data["board"] = [{"id": i, "task": task, "completedBy": []} for i, task in enumerate(selected)]
return save_data(data)
def toggle_mark(cell_id, player):
data = load_data()
players = data.get("players", ["Player 1", "Player 2"])
if player not in players:
raise ValueError("Unknown player.")
for cell in data.get("board", []):
if int(cell.get("id", -1)) == int(cell_id):
completed = cell.setdefault("completedBy", [])
if player in completed:
completed.remove(player)
else:
completed.append(player)
return save_data(data)
raise ValueError("Unknown board cell.")
class Handler(SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header("Cache-Control", "no-store")
super().end_headers()
def read_json(self):
length = int(self.headers.get("Content-Length", "0") or 0)
raw = self.rfile.read(length).decode("utf-8") if length else "{}"
return json.loads(raw or "{}")
def send_json(self, status, payload):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if urlparse(self.path).path == "/api/bingo":
self.send_json(200, {"ok": True, "data": load_data()})
return
return super().do_GET()
def do_POST(self):
path = urlparse(self.path).path
try:
payload = self.read_json()
if path == "/api/bingo":
self.send_json(200, {"ok": True, "data": save_data(payload)})
return
if path == "/api/generate":
self.send_json(200, {"ok": True, "data": generate_board(payload.get("seed"))})
return
if path == "/api/mark":
self.send_json(200, {"ok": True, "data": toggle_mark(payload.get("cellId"), payload.get("player"))})
return
self.send_json(404, {"ok": False, "error": "Not found"})
except Exception as exc:
self.send_json(400, {"ok": False, "error": str(exc)})
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8780
print(f"Bingo overlay server running at http://localhost:{port}/")
print(f"Editor: http://localhost:{port}/editor.html")
print(f"Overlay: http://localhost:{port}/overlay.html")
ThreadingHTTPServer(("", port), Handler).serve_forever()
+16
View File
@@ -0,0 +1,16 @@
@echo off
cd /d "%~dp0"
echo Starting Blackout Bingo overlay server...
echo.
echo Editor: http://localhost:8780/editor.html
echo Overlay: http://localhost:8780/overlay.html
echo.
echo Keep this window open while streaming. Closing it stops the overlay.
echo.
py server.py 8780
if errorlevel 1 (
echo.
echo Python launcher failed. Trying python instead...
python server.py 8780
)
pause
+91
View File
@@ -0,0 +1,91 @@
import json
import os
import subprocess
import sys
import tempfile
import time
import urllib.request
from pathlib import Path
def start_server(tmp_path):
src = Path(__file__).resolve().parents[1]
work = tmp_path / "app"
work.mkdir()
for name in ["server.py", "overlay.html", "editor.html"]:
(work / name).write_text((src / name).read_text(), encoding="utf-8")
(work / "bingo.json").write_text((src / "defaults" / "bingo.json").read_text(), encoding="utf-8")
proc = subprocess.Popen(
[sys.executable, "server.py", "8780"],
cwd=work,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
deadline = time.time() + 5
while time.time() < deadline:
try:
urllib.request.urlopen("http://127.0.0.1:8780/api/bingo", timeout=0.25).read()
return proc, work
except Exception:
time.sleep(0.1)
output = proc.stdout.read() if proc.stdout else ""
proc.kill()
raise AssertionError(f"server did not start: {output}")
def request_json(url, method="GET", payload=None):
data = None
headers = {}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=2) as resp:
return json.loads(resp.read().decode("utf-8"))
def test_save_rejects_more_than_100_tasks(tmp_path):
proc, work = start_server(tmp_path)
try:
payload = {"tasks": [f"Task {i}" for i in range(101)], "board": [], "players": ["P1", "P2"]}
try:
request_json("http://127.0.0.1:8780/api/bingo", "POST", payload)
except urllib.error.HTTPError as exc:
assert exc.code == 400
else:
raise AssertionError("expected 400 for too many tasks")
finally:
proc.kill()
def test_save_and_generate_25_unique_board_cells(tmp_path):
proc, work = start_server(tmp_path)
try:
tasks = [f"Challenge {i:02d}" for i in range(30)]
saved = request_json("http://127.0.0.1:8780/api/bingo", "POST", {"tasks": tasks, "board": [], "players": ["Ash", "Misty"]})
assert saved["ok"] is True
generated = request_json("http://127.0.0.1:8780/api/generate", "POST", {"seed": "demo"})
board = generated["data"]["board"]
assert len(board) == 25
assert len({cell["task"] for cell in board}) == 25
assert all(cell["completedBy"] == [] for cell in board)
assert (work / "bingo.json").exists()
finally:
proc.kill()
def test_mark_cell_toggles_two_players_independently(tmp_path):
proc, work = start_server(tmp_path)
try:
tasks = [f"Task {i}" for i in range(25)]
request_json("http://127.0.0.1:8780/api/bingo", "POST", {"tasks": tasks, "board": [], "players": ["Blue", "Red"]})
request_json("http://127.0.0.1:8780/api/generate", "POST", {"seed": "fixed"})
one = request_json("http://127.0.0.1:8780/api/mark", "POST", {"cellId": 0, "player": "Blue"})
assert one["data"]["board"][0]["completedBy"] == ["Blue"]
two = request_json("http://127.0.0.1:8780/api/mark", "POST", {"cellId": 0, "player": "Red"})
assert set(two["data"]["board"][0]["completedBy"]) == {"Blue", "Red"}
off = request_json("http://127.0.0.1:8780/api/mark", "POST", {"cellId": 0, "player": "Blue"})
assert off["data"]["board"][0]["completedBy"] == ["Red"]
finally:
proc.kill()
+30
View File
@@ -0,0 +1,30 @@
services:
pokemon-party-overlay:
build:
context: ./pokemon-party
container_name: pokemon-party-overlay
restart: unless-stopped
ports:
- "8765:8765"
volumes:
- ./data/pokemon-party:/data
gauntlet-overlay:
build:
context: ./gauntlet
container_name: gauntlet-overlay
restart: unless-stopped
ports:
- "8770:8770"
volumes:
- ./data/gauntlet:/data
blackout-bingo-overlay:
build:
context: ./blackout-bingo
container_name: blackout-bingo-overlay
restart: unless-stopped
ports:
- "8780:8780"
volumes:
- ./data/blackout-bingo:/data
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
.pytest_cache/
*.pyc
*.zip
data/
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY . /app
EXPOSE 8770
CMD ["sh", "-c", "mkdir -p /data && [ -f /data/gauntlet.json ] || cp /app/defaults/gauntlet.json /data/gauntlet.json && ln -sf /data/gauntlet.json /app/gauntlet.json && exec python /app/server.py --host 0.0.0.0 --port 8770 --directory /app"]
+33
View File
@@ -0,0 +1,33 @@
Gauntlet OBS Overlay
====================
This is a local browser-source overlay for OBS.
Files:
overlay.html The OBS overlay page
editor.html The editing page for up to 10 games
gauntlet.json The saved game list and statuses
server.py Local server with save/advance/reset endpoints
start-gauntlet.bat Double-click this to start the server on Windows
advance-game.bat Clears the active game and moves to the next game
reset-gauntlet.bat Resets the run to the first game
Fast start on your streaming PC:
1. Extract the zip.
2. Double-click start-gauntlet.bat.
3. Open the editor: http://localhost:8770/editor.html
4. Add your games and click Save gauntlet.json directly.
5. Add an OBS Browser Source pointed to: http://localhost:8770/overlay.html
Recommended OBS Browser Source size:
Width: 1120
Height: 350
How the gauntlet works:
- You can add up to 10 games.
- Blank title rows do not show on the overlay.
- Exactly one game should be Active. The active game is highlighted.
- advance-game.bat marks the active game Cleared and activates the next non-cleared game.
- reset-gauntlet.bat sets the first game Active and all other games Queued.
Keep the start-gauntlet.bat window open while streaming. Closing it stops the local server.
+8
View File
@@ -0,0 +1,8 @@
@echo off
setlocal
REM Clears the active Gauntlet game and moves the highlight to the next game.
powershell -NoProfile -ExecutionPolicy Bypass -Command "try { Invoke-RestMethod -Method Post -Uri 'http://localhost:8770/api/advance' | Out-Null; Write-Host 'Gauntlet advanced: current game cleared, next game active.' } catch { Write-Host 'ERROR: Could not reach the Gauntlet server. Start start-gauntlet.bat first.'; exit 1 }"
endlocal
+40
View File
@@ -0,0 +1,40 @@
{
"games": [
{
"title": "Super Mario World",
"subtitle": "96 exits optional",
"icon": "✅",
"status": "cleared"
},
{
"title": "Metroid Fusion",
"subtitle": "Any% clear",
"icon": "✅",
"status": "cleared"
},
{
"title": "Pokémon SoulSilver",
"subtitle": "Nuzlocke ruleset",
"icon": "⚡",
"status": "active"
},
{
"title": "Mega Man X",
"subtitle": "Boss order TBD",
"icon": "🌙",
"status": "queued"
},
{
"title": "Zelda: Minish Cap",
"subtitle": "Casual clear",
"icon": "🗡️",
"status": "queued"
},
{
"title": "Castlevania",
"subtitle": "Finale candidate",
"icon": "👑",
"status": "queued"
}
]
}
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gauntlet Overlay Editor</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}body{min-height:100vh;background:#110d09;color:#f8ead2;font-family:Segoe UI,Arial,sans-serif;padding:26px}.shell{max-width:1120px;margin:0 auto}.header{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;margin-bottom:18px}.eyebrow{text-transform:uppercase;letter-spacing:.18em;color:#e1b15f;font-size:12px;font-weight:800}.header h1{font-family:Georgia,'Times New Roman',serif;font-size:42px;line-height:1;margin-top:6px}.header p{color:#d9bd88;margin-top:8px;max-width:650px}.actions{display:flex;flex-wrap:wrap;gap:10px;justify-content:flex-end}.btn{border:1px solid rgba(255,226,170,.25);background:#2b1b12;color:#fff3d8;border-radius:10px;padding:11px 13px;font-weight:800;cursor:pointer;box-shadow:0 8px 20px rgba(0,0,0,.22)}.btn.primary{background:#e1b15f;color:#1b110b;border-color:#ffe0a0}.btn.danger{background:#452020}.btn:hover{filter:brightness(1.08)}.panel{background:linear-gradient(120deg,#3d2819,#1f1711);border:1px solid rgba(255,226,170,.22);border-radius:16px;padding:16px;box-shadow:0 20px 60px rgba(0,0,0,.42)}.grid{display:grid;gap:10px}.row{display:grid;grid-template-columns:42px 2fr 2fr 70px 126px 72px;gap:8px;align-items:center;padding:10px;border-radius:12px;background:rgba(0,0,0,.20);border:1px solid rgba(255,226,170,.12)}.row.active{border-color:#e1b15f;box-shadow:0 0 20px rgba(218,153,61,.15)}label{font-size:11px;color:#d9bd88;text-transform:uppercase;letter-spacing:.1em;display:block;margin-bottom:5px}input,select{width:100%;background:#17110d;color:#f8ead2;border:1px solid rgba(255,226,170,.22);border-radius:9px;padding:10px;font:inherit}input::placeholder{color:#806d50}.slot{width:34px;height:34px;border-radius:9px;background:#2f2117;display:grid;place-items:center;color:#e1b15f;font-weight:900}.small{font-size:13px;color:#d9bd88;margin:12px 2px}.status{margin-top:12px;color:#f0c36d;min-height:22px}.fallbacks{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}textarea{width:100%;height:220px;background:#0b0806;color:#f8ead2;border:1px solid rgba(255,226,170,.22);border-radius:12px;padding:12px;font-family:Consolas,monospace;margin-top:10px;display:none}.preview-link{color:#f0c36d;text-decoration:none;font-weight:800}.preview-link:hover{text-decoration:underline}@media(max-width:920px){.header{flex-direction:column}.actions{justify-content:flex-start}.row{grid-template-columns:38px 1fr}.field{grid-column:auto}.row .wide{grid-column:2}.row .btn{grid-column:2;width:max-content}}
</style>
</head>
<body>
<div class="shell">
<div class="header"><div><div class="eyebrow">OBS Browser Source Tool</div><h1>The Gauntlet Editor</h1><p>Add up to 10 games. Blank title rows are hidden from the overlay. Set one game as Active so the overlay highlights your current run.</p></div><div class="actions"><button class="btn primary" onclick="saveDirect()">Save gauntlet.json directly</button><button class="btn" onclick="advanceRun()">Clear current + next</button><button class="btn danger" onclick="resetRun()">Reset to beginning</button></div></div>
<div class="panel"><div class="grid" id="rows"></div><div class="small">Overlay: <a class="preview-link" href="overlay.html" target="_blank">open overlay.html</a>. Batch controls use the same advance/reset actions as this editor.</div><div class="fallbacks"><button class="btn" onclick="copyJson()">Copy JSON</button><button class="btn" onclick="downloadJson()">Download gauntlet.json</button><button class="btn" onclick="toggleJson()">Show/hide JSON</button></div><textarea id="jsonBox" spellcheck="false"></textarea><div class="status" id="status"></div></div>
</div>
<script>
const rows = document.getElementById('rows'); const statusEl = document.getElementById('status'); const jsonBox = document.getElementById('jsonBox');
const MAX = 10; const statuses = ['active','queued','cleared','paused','failed'];
function setStatus(msg, bad=false){statusEl.textContent = msg; statusEl.style.color = bad ? '#ffb2a8' : '#f0c36d';}
function rowTemplate(i, game={}){const row=document.createElement('div'); row.className='row'; row.dataset.index=i; row.innerHTML=`<div class="slot">${i+1}</div><div class="field wide"><label>Game title</label><input data-field="title" placeholder="Game ${i+1}" value="${esc(game.title||'')}"></div><div class="field wide"><label>Subtitle / rules</label><input data-field="subtitle" placeholder="Any%, Nuzlocke, casual clear..." value="${esc(game.subtitle||'')}"></div><div class="field"><label>Icon</label><input data-field="icon" placeholder="◆" maxlength="8" value="${esc(game.icon||'')}"></div><div class="field"><label>Status</label><select data-field="status">${statuses.map(s=>`<option value="${s}" ${s===(game.status||'queued')?'selected':''}>${s}</option>`).join('')}</select></div><button class="btn" type="button" onclick="setActive(${i})">Active</button>`; rows.appendChild(row);}
function esc(s){return String(s).replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));}
function buildRows(games=[]){rows.innerHTML=''; for(let i=0;i<MAX;i++) rowTemplate(i, games[i]||{}); refreshActiveStyle();}
function getData(){const games=[]; for(const row of rows.children){const title=row.querySelector('[data-field="title"]').value.trim(); if(!title) continue; const game={title, subtitle:row.querySelector('[data-field="subtitle"]').value.trim(), icon:row.querySelector('[data-field="icon"]').value.trim()||'◆', status:row.querySelector('[data-field="status"]').value}; games.push(game);} let activeSeen=false; for(const g of games){if(g.status==='active'){if(activeSeen)g.status='queued'; activeSeen=true;}} if(games.length && !activeSeen){const first=games.find(g=>g.status!=='cleared')||games[0]; first.status='active';} return {games};}
function applyData(data){buildRows((data&&Array.isArray(data.games))?data.games:[]);}
function refreshActiveStyle(){for(const row of rows.children){row.classList.toggle('active', row.querySelector('[data-field="status"]').value==='active'); row.querySelector('[data-field="status"]').onchange=refreshActiveStyle;}}
function setActive(index){for(const row of rows.children){row.querySelector('[data-field="status"]').value = Number(row.dataset.index)===index ? 'active' : (row.querySelector('[data-field="status"]').value==='active'?'queued':row.querySelector('[data-field="status"]').value);} refreshActiveStyle();}
async function load(){try{const res=await fetch('gauntlet.json?cache='+Date.now(),{cache:'no-store'}); applyData(await res.json()); setStatus('Loaded gauntlet.json');}catch(e){buildRows(); setStatus('Could not load gauntlet.json; starting blank.', true);}}
async function saveDirect(){try{const data=getData(); const res=await fetch('/api/gauntlet',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); const out=await res.json(); if(!out.ok) throw new Error(out.error||'Save failed'); applyData(out); setStatus('Saved gauntlet.json directly. OBS will update within about 2 seconds.');}catch(e){setStatus('Direct save failed. Start with py server.py, not python -m http.server. ' + e.message, true);}}
async function advanceRun(){try{const res=await fetch('/api/advance',{method:'POST'}); const out=await res.json(); if(!out.ok) throw new Error(out.error||'Advance failed'); applyData(out); setStatus('Current game cleared; next game is active.');}catch(e){setStatus('Advance failed: '+e.message, true);}}
async function resetRun(){if(!confirm('Reset the run to the beginning? First game becomes active and all others become queued.'))return; try{const res=await fetch('/api/reset',{method:'POST'}); const out=await res.json(); if(!out.ok) throw new Error(out.error||'Reset failed'); applyData(out); setStatus('Run reset to the beginning.');}catch(e){setStatus('Reset failed: '+e.message, true);}}
function updateJsonBox(){jsonBox.value=JSON.stringify(getData(), null, 2);} function toggleJson(){updateJsonBox(); jsonBox.style.display=jsonBox.style.display==='block'?'none':'block';}
async function copyJson(){updateJsonBox(); await navigator.clipboard.writeText(jsonBox.value); setStatus('Copied JSON to clipboard.');}
function downloadJson(){updateJsonBox(); const blob=new Blob([jsonBox.value+'\n'],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='gauntlet.json'; a.click(); URL.revokeObjectURL(a.href); setStatus('Downloaded gauntlet.json.');}
load();
</script>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>The Gauntlet Overlay</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:100%;min-height:100%;background:transparent;color:#f8ead2;font-family:Georgia,'Times New Roman',serif;overflow:hidden}
body{display:grid;place-items:center;padding:18px}
.ledger{width:min(1120px,96vw);min-height:142px;max-height:350px;border-radius:18px;background:linear-gradient(90deg,#2b1b12,#49301c 48%,#2a1b13);box-shadow:0 26px 80px rgba(0,0,0,.75),inset 0 0 60px rgba(255,214,148,.08);padding:16px;border:1px solid rgba(255,222,162,.28);position:relative;overflow:hidden}
.ledger:before{content:"";position:absolute;inset:10px;border:1px solid rgba(255,226,170,.18);border-radius:12px;pointer-events:none}.paper{height:100%;border-radius:12px;background:radial-gradient(circle at 30% 5%,rgba(255,248,213,.18),transparent 25%),linear-gradient(120deg,#3d2819,#1f1711);padding:22px;position:relative;overflow:hidden}.paper:after{content:"";position:absolute;inset:0;background:repeating-linear-gradient(0deg,rgba(255,255,255,.025),rgba(255,255,255,.025) 1px,transparent 1px,transparent 5px);mix-blend-mode:screen;pointer-events:none}
.quests{position:relative;z-index:1;height:100%;display:flex;flex-direction:column}.top{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid rgba(255,226,170,.22);padding-bottom:10px;margin-bottom:12px;gap:16px}.top h2{font-size:30px;letter-spacing:.01em;white-space:nowrap}.rule{font-family:Segoe UI,Arial,sans-serif;color:#d9bd88;font-size:13px;text-align:right}.list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;flex:1}.quest{min-height:66px;display:grid;grid-template-columns:46px minmax(0,1fr) auto;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:rgba(0,0,0,.20);border:1px solid rgba(255,226,170,.16);font-family:Segoe UI,Arial,sans-serif;transition:opacity .2s ease, transform .2s ease, box-shadow .2s ease}.quest.active{background:linear-gradient(90deg,rgba(182,43,43,.60),rgba(192,139,56,.24));border-color:#e1b15f;box-shadow:0 0 24px rgba(218,153,61,.20),inset 0 0 22px rgba(255,218,141,.08);transform:translateY(-1px)}.icon{width:42px;height:42px;border-radius:10px;background:#2f2117;display:grid;place-items:center;font-size:22px;box-shadow:inset 0 0 12px rgba(255,222,162,.08)}.name{font-weight:850;color:#fff3d8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:16px}.sub{font-size:12px;color:#d7c098;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tag{font-size:11px;text-transform:uppercase;letter-spacing:.12em;border:1px solid rgba(255,226,170,.22);padding:5px 8px;border-radius:999px;color:#d8bf91;white-space:nowrap}.active .tag{color:#1b110b;background:#f0c36d;border-color:#ffe0a0;font-weight:900}.cleared{opacity:.62}.cleared .tag{background:#28351f;color:#bbdea4}.failed .tag{background:#452020;color:#ffb2a8}.paused .tag{background:#2d3145;color:#bcc9ff}.empty-state{padding:22px;font-family:Segoe UI,Arial,sans-serif;color:#d9bd88;text-align:center}
@media (max-width:760px){body{padding:10px}.top{align-items:flex-start;flex-direction:column}.rule{text-align:left}.list{grid-template-columns:1fr}.ledger{max-height:95vh}.top h2{font-size:25px}}
</style>
</head>
<body>
<div class="ledger"><div class="paper"><main class="quests"><div class="top"><h2>The Gauntlet Ledger</h2><div class="rule" id="summary">Loading gauntlet...</div></div><div class="list" id="list"></div></main></div></div>
<script>
const list = document.getElementById('list');
const summary = document.getElementById('summary');
const labels = {active:'Active', queued:'Queued', cleared:'Cleared', paused:'Paused', failed:'Failed'};
const statusSub = {active:'Current game', queued:'In the gauntlet', cleared:'Cleared', paused:'Paused', failed:'Failed'};
function escapeHtml(s){return String(s ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));}
function render(data){
const games = Array.isArray(data.games) ? data.games.filter(g => g && g.title) : [];
list.innerHTML = '';
if(!games.length){list.innerHTML = '<div class="empty-state">No games added yet. Use the editor to build your gauntlet.</div>'; summary.textContent = 'Waiting for games'; return;}
const active = games.find(g => g.status === 'active');
const cleared = games.filter(g => g.status === 'cleared').length;
summary.textContent = `${cleared}/${games.length} cleared${active ? ' · Active: ' + active.title : ' · No active game'}`;
for(const game of games){
const status = labels[game.status] ? game.status : 'queued';
const subtitle = game.subtitle || statusSub[status] || '';
const div = document.createElement('div');
div.className = `quest ${status}`;
div.innerHTML = `<div class="icon">${escapeHtml(game.icon || '◆')}</div><div><div class="name">${escapeHtml(game.title)}</div><div class="sub">${escapeHtml(subtitle)}</div></div><div class="tag">${labels[status]}</div>`;
list.appendChild(div);
}
}
async function load(){
try{const res = await fetch('gauntlet.json?cache=' + Date.now(), {cache:'no-store'}); render(await res.json());}
catch(err){summary.textContent = 'Could not load gauntlet.json';}
}
load(); setInterval(load, 2000);
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
@echo off
setlocal
REM Resets the Gauntlet: first game active, all other games queued.
powershell -NoProfile -ExecutionPolicy Bypass -Command "try { Invoke-RestMethod -Method Post -Uri 'http://localhost:8770/api/reset' | Out-Null; Write-Host 'Gauntlet reset to the beginning.' } catch { Write-Host 'ERROR: Could not reach the Gauntlet server. Start start-gauntlet.bat first.'; exit 1 }"
endlocal
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Tiny local server for the Gauntlet OBS overlay.
Serves overlay.html/editor.html and adds local control endpoints:
POST /api/gauntlet -> validates/writes gauntlet.json
POST /api/advance -> mark active game cleared, activate next queued game
POST /api/reset -> first game active, remaining games queued
"""
from __future__ import annotations
import argparse
import json
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
VALID_STATUSES = {"active", "queued", "cleared", "paused", "failed"}
MAX_GAMES = 10
def clean_text(value: Any, max_len: int = 120) -> str:
return str(value or "").strip()[:max_len]
def normalize_game(game: Any, fallback_status: str = "queued") -> dict[str, str] | None:
if not isinstance(game, dict):
return None
title = clean_text(game.get("title"), 80)
subtitle = clean_text(game.get("subtitle"), 120)
icon = clean_text(game.get("icon"), 8)
status = clean_text(game.get("status"), 20).lower()
if status not in VALID_STATUSES:
status = fallback_status
if not title:
return None
return {"title": title, "subtitle": subtitle, "icon": icon or "◆", "status": status}
def normalize_gauntlet(data: Any) -> dict[str, list[dict[str, str]]]:
games = data.get("games") if isinstance(data, dict) else data
if not isinstance(games, list):
raise ValueError("Expected JSON object with a games array, or a games array.")
normalized: list[dict[str, str]] = []
for item in games[:MAX_GAMES]:
game = normalize_game(item)
if game:
normalized.append(game)
active_seen = False
for game in normalized:
if game["status"] == "active":
if active_seen:
game["status"] = "queued"
else:
active_seen = True
if normalized and not active_seen:
first_not_cleared = next((g for g in normalized if g["status"] != "cleared"), normalized[0])
first_not_cleared["status"] = "active"
return {"games": normalized}
def load_gauntlet(directory: str) -> dict[str, list[dict[str, str]]]:
path = Path(directory).resolve() / "gauntlet.json"
if not path.exists():
return {"games": []}
return normalize_gauntlet(json.loads(path.read_text(encoding="utf-8")))
def save_gauntlet(directory: str, data: dict[str, list[dict[str, str]]]) -> None:
path = Path(directory).resolve() / "gauntlet.json"
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def advance_gauntlet(data: dict[str, list[dict[str, str]]]) -> dict[str, list[dict[str, str]]]:
games = data.get("games", [])
active_index = next((i for i, g in enumerate(games) if g.get("status") == "active"), None)
if active_index is None:
if games:
games[0]["status"] = "active"
return {"games": games}
games[active_index]["status"] = "cleared"
for i in range(active_index + 1, len(games)):
if games[i].get("status") != "cleared":
games[i]["status"] = "active"
break
return {"games": games}
def reset_gauntlet(data: dict[str, list[dict[str, str]]]) -> dict[str, list[dict[str, str]]]:
games = data.get("games", [])
for index, game in enumerate(games):
game["status"] = "active" if index == 0 else "queued"
return {"games": games}
class GauntletHandler(SimpleHTTPRequestHandler):
server_version = "GauntletOverlay/1.0"
def do_POST(self) -> None: # noqa: N802
route = self.path.split("?", 1)[0]
try:
if route == "/api/gauntlet":
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length).decode("utf-8")
data = normalize_gauntlet(json.loads(raw))
save_gauntlet(self.directory, data)
self.send_json({"ok": True, "message": "gauntlet.json saved", **data})
return
if route == "/api/advance":
data = advance_gauntlet(load_gauntlet(self.directory))
save_gauntlet(self.directory, data)
self.send_json({"ok": True, "message": "advanced to next game", **data})
return
if route == "/api/reset":
data = reset_gauntlet(load_gauntlet(self.directory))
save_gauntlet(self.directory, data)
self.send_json({"ok": True, "message": "gauntlet reset", **data})
return
except Exception as exc:
self.send_json({"ok": False, "error": str(exc)}, status=400)
return
self.send_json({"ok": False, "error": "Not found"}, status=404)
def send_json(self, payload: dict[str, Any], status: int = 200) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def main() -> None:
parser = argparse.ArgumentParser(description="Run the Gauntlet OBS overlay server.")
parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Default: 127.0.0.1")
parser.add_argument("--port", type=int, default=8770, help="Port to bind. Default: 8770")
parser.add_argument("--directory", default=str(Path(__file__).resolve().parent), help="Overlay folder to serve.")
args = parser.parse_args()
handler = lambda *handler_args, **kwargs: GauntletHandler(*handler_args, directory=args.directory, **kwargs) # noqa: E731
with ThreadingHTTPServer((args.host, args.port), handler) as httpd:
print(f"Gauntlet Overlay server running at http://{args.host}:{args.port}/")
print(f"Editor: http://{args.host}:{args.port}/editor.html")
print(f"Overlay: http://{args.host}:{args.port}/overlay.html")
print("Controls: run advance-game.bat to clear the current game and activate the next one.")
print("Keep this window open while streaming. Press Ctrl+C to stop.")
httpd.serve_forever()
if __name__ == "__main__":
main()
@@ -0,0 +1,17 @@
## Variant: Arcade Marquee
### Design stance
High-energy stream overlay that treats the Gauntlet like an arcade event marquee.
### Key choices
- Layout: big active-game hero on the left, queue cards across the right.
- Typography: bold uppercase, compact metadata chips.
- Color: neon magenta/cyan on dark glass.
- Interaction: hover lift on game cards for review.
### Trade-offs
- Strong at: stream energy, quick active-state readability, sponsor/OBS-style polish.
- Weak at: quieter streams, long game titles, large queue lists.
### Best for
A main overlay shown between segments or in a webcam/sidebar area where the active game needs to pop.
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gauntlet Overlay Sketch — Arcade Marquee</title>
<style>
*{box-sizing:border-box;margin:0;padding:0} body{min-height:100vh;background:#05040a;color:#fff;font-family:Inter,Segoe UI,Arial,sans-serif;display:grid;place-items:center;padding:28px;overflow:hidden}
.frame{width:min(1600px,96vw);height:360px;position:relative;border-radius:34px;background:linear-gradient(135deg,rgba(33,13,64,.92),rgba(7,10,25,.86));border:2px solid rgba(255,255,255,.16);box-shadow:0 0 0 1px rgba(255,0,179,.22),0 28px 90px rgba(0,0,0,.65),0 0 80px rgba(0,224,255,.15) inset;overflow:hidden;padding:24px;display:grid;grid-template-columns:430px 1fr;gap:22px}
.frame:before{content:"";position:absolute;inset:-50%;background:radial-gradient(circle at 20% 30%,rgba(255,31,200,.32),transparent 24%),radial-gradient(circle at 78% 20%,rgba(0,235,255,.24),transparent 26%),linear-gradient(90deg,transparent 0 48%,rgba(255,255,255,.04) 49% 51%,transparent 52%);animation:drift 12s linear infinite;z-index:0}.frame>*{position:relative;z-index:1}@keyframes drift{to{transform:translateX(8%) rotate(6deg)}}
.active{border-radius:26px;padding:22px;background:linear-gradient(180deg,rgba(255,255,255,.16),rgba(255,255,255,.06));border:1px solid rgba(255,255,255,.22);box-shadow:0 0 35px rgba(255,0,204,.22);display:flex;flex-direction:column;justify-content:space-between}.eyebrow{font-size:13px;letter-spacing:.22em;text-transform:uppercase;color:#63f4ff;text-shadow:0 0 12px #00d9ff}.active h1{font-size:42px;line-height:.96;text-transform:uppercase;letter-spacing:-.04em;margin:12px 0 8px;text-shadow:0 0 18px rgba(255,43,205,.5)}.meta{display:flex;gap:8px;flex-wrap:wrap}.pill{font-size:13px;border-radius:999px;padding:7px 10px;background:rgba(0,0,0,.3);border:1px solid rgba(255,255,255,.16);color:#eee}.meter{height:12px;border-radius:99px;background:#17111f;overflow:hidden;border:1px solid rgba(255,255,255,.16);margin-top:12px}.fill{height:100%;width:42%;background:linear-gradient(90deg,#ff2bd1,#00eaff);box-shadow:0 0 18px #ff2bd1}
.queue{display:grid;grid-template-rows:auto 1fr;gap:14px}.bar{display:flex;justify-content:space-between;align-items:end}.title{font-size:24px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}.count{color:#aaa;font-size:14px}.games{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}.card{min-height:220px;border-radius:20px;padding:14px;background:rgba(8,12,28,.74);border:1px solid rgba(255,255,255,.13);position:relative;overflow:hidden;transition:.2s transform,.2s border-color}.card:hover{transform:translateY(-5px);border-color:#63f4ff}.card.active-card{background:linear-gradient(180deg,rgba(255,43,209,.35),rgba(0,234,255,.12));border-color:#ff43d6}.num{font-size:11px;color:#aaa;letter-spacing:.18em}.cover{height:92px;border-radius:15px;margin:10px 0;background:linear-gradient(135deg,var(--a),var(--b));display:grid;place-items:center;font-size:34px;box-shadow:0 12px 35px rgba(0,0,0,.35)}.game{font-size:17px;font-weight:850;line-height:1.05}.status{position:absolute;left:14px;bottom:14px;font-size:12px;color:#cfd6ff}.next{position:absolute;right:12px;top:12px;background:#0cf;color:#001018;border-radius:99px;padding:4px 7px;font-size:10px;font-weight:900}.active-card .next{background:#ff43d6;color:white}
</style>
</head>
<body>
<div class="frame">
<section class="active">
<div><div class="eyebrow">Now Running The Gauntlet</div><h1>Pokémon SoulSilver</h1><div class="meta"><span class="pill">Active Game</span><span class="pill">Nuzlocke</span><span class="pill">Run 01</span></div></div>
<div><div class="eyebrow">Gauntlet Progress</div><div class="meter"><div class="fill"></div></div></div>
</section>
<section class="queue">
<div class="bar"><div class="title">Game Queue</div><div class="count">3 cleared · 4 waiting</div></div>
<div class="games">
<article class="card active-card"><span class="next">ACTIVE</span><div class="num">#04</div><div class="cover" style="--a:#e6262f;--b:#f5ce3e">⚡</div><div class="game">Pokémon SoulSilver</div><div class="status">Johto badge chase</div></article>
<article class="card"><span class="next">NEXT</span><div class="num">#05</div><div class="cover" style="--a:#5b3df5;--b:#00e0ff">🌙</div><div class="game">Mega Man X</div><div class="status">Queued</div></article>
<article class="card"><div class="num">#06</div><div class="cover" style="--a:#1d9f58;--b:#b5ff5e">🗡️</div><div class="game">Zelda: Minish Cap</div><div class="status">Queued</div></article>
<article class="card"><div class="num">#07</div><div class="cover" style="--a:#ff7a18;--b:#af002d">🏎️</div><div class="game">Mario Kart DS</div><div class="status">Challenge run</div></article>
<article class="card"><div class="num">#08</div><div class="cover" style="--a:#3949ab;--b:#101425">👑</div><div class="game">Castlevania</div><div class="status">Final slot</div></article>
</div>
</section>
</div>
</body>
</html>
@@ -0,0 +1,17 @@
## Variant: RPG Quest Ledger
### Design stance
Frames the Gauntlet as a compact quest log where the active game is highlighted directly in the ledger list.
### Key choices
- Layout: two-column ledger rows without the separate current-trial panel.
- Typography: fantasy serif headings with readable UI-body details.
- Color: parchment, leather, gold, and dark red.
- Interaction: active row status styling shows state clearly.
### Trade-offs
- Strong at: theme, storytelling, cleared/queued status clarity.
- Weak at: less modern/techy, may need simplification for tiny OBS placement.
### Best for
A Gauntlet brand identity where the list feels like a challenge board or campaign log.
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gauntlet Overlay Sketch — RPG Quest Ledger</title>
<style>
*{box-sizing:border-box;margin:0;padding:0} body{min-height:100vh;background:#0d0b08;color:#f8ead2;font-family:Georgia,'Times New Roman',serif;display:grid;place-items:center;padding:30px;overflow:hidden}
.ledger{width:min(1120px,96vw);height:350px;border-radius:18px;background:linear-gradient(90deg,#2b1b12,#49301c 48%,#2a1b13);box-shadow:0 26px 80px rgba(0,0,0,.75),inset 0 0 60px rgba(255,214,148,.08);padding:16px;border:1px solid rgba(255,222,162,.28);position:relative;overflow:hidden}.ledger:before{content:"";position:absolute;inset:10px;border:1px solid rgba(255,226,170,.18);border-radius:12px;pointer-events:none}.paper{height:100%;border-radius:12px;background:radial-gradient(circle at 30% 5%,rgba(255,248,213,.18),transparent 25%),linear-gradient(120deg,#3d2819,#1f1711);padding:24px;position:relative}.paper:after{content:"";position:absolute;inset:0;background:repeating-linear-gradient(0deg,rgba(255,255,255,.025),rgba(255,255,255,.025) 1px,transparent 1px,transparent 5px);mix-blend-mode:screen;pointer-events:none}
.quests{position:relative;z-index:1;height:100%;display:flex;flex-direction:column}.top{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid rgba(255,226,170,.22);padding-bottom:10px;margin-bottom:12px}.top h2{font-size:30px}.rule{font-family:Segoe UI,Arial,sans-serif;color:#d9bd88;font-size:13px}.list{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;flex:1}.quest{height:70px;display:grid;grid-template-columns:46px 1fr auto;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:rgba(0,0,0,.20);border:1px solid rgba(255,226,170,.16);font-family:Segoe UI,Arial,sans-serif}.quest.active{background:linear-gradient(90deg,rgba(182,43,43,.55),rgba(192,139,56,.2));border-color:#e1b15f;box-shadow:0 0 22px rgba(218,153,61,.16)}.icon{width:42px;height:42px;border-radius:10px;background:#2f2117;display:grid;place-items:center;font-size:22px}.name{font-weight:800;color:#fff3d8}.sub{font-size:12px;color:#d7c098;margin-top:2px}.tag{font-size:11px;text-transform:uppercase;letter-spacing:.12em;border:1px solid rgba(255,226,170,.22);padding:5px 8px;border-radius:999px;color:#d8bf91}.active .tag{color:#1b110b;background:#f0c36d;border-color:#ffe0a0}.done{opacity:.62;text-decoration-thickness:2px}.done .tag{background:#28351f;color:#bbdea4}
</style>
</head>
<body>
<div class="ledger"><div class="paper">
<main class="quests"><div class="top"><h2>The Gauntlet Ledger</h2><div class="rule">Set one quest as ACTIVE · track cleared and queued games</div></div><div class="list">
<div class="quest done"><div class="icon">✅</div><div><div class="name">Super Mario World</div><div class="sub">Cleared · 96 exits optional</div></div><div class="tag">Cleared</div></div>
<div class="quest done"><div class="icon">✅</div><div><div class="name">Metroid Fusion</div><div class="sub">Cleared · any%</div></div><div class="tag">Cleared</div></div>
<div class="quest active"><div class="icon">⚡</div><div><div class="name">Pokémon SoulSilver</div><div class="sub">Active · Nuzlocke ruleset</div></div><div class="tag">Active</div></div>
<div class="quest"><div class="icon">🌙</div><div><div class="name">Mega Man X</div><div class="sub">Queued · boss order TBD</div></div><div class="tag">Next</div></div>
<div class="quest"><div class="icon">🗡️</div><div><div class="name">Zelda: Minish Cap</div><div class="sub">Queued · casual clear</div></div><div class="tag">Queued</div></div>
<div class="quest"><div class="icon">👑</div><div><div class="name">Castlevania</div><div class="sub">Queued · finale candidate</div></div><div class="tag">Queued</div></div>
</div></main>
</div></div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Gauntlet Overlay Sketches</title><style>*{box-sizing:border-box}body{margin:0;background:#10131c;color:#f8fafc;font-family:Segoe UI,Arial,sans-serif;padding:40px}h1{font-size:42px;margin:0 0 8px}p{color:#aab4c8}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px;margin-top:28px}.card{background:#171c29;border:1px solid #2b3448;border-radius:18px;padding:20px;color:inherit;text-decoration:none;transition:.2s}.card:hover{transform:translateY(-3px);border-color:#5ddcff}.thumb{height:180px;border-radius:14px;margin-bottom:16px;display:grid;place-items:center;font-size:52px}.a{background:linear-gradient(135deg,#35104e,#071628)}.b{background:linear-gradient(135deg,#4a2f1d,#16100c)}.open{color:#7ee7ff;font-weight:800}</style></head><body><h1>Gauntlet Overlay Design Sketches</h1><p>Two visual directions for the stream overlay. These are design-only mockups with sample games and an active state.</p><div class="grid"><a class="card" href="001-arcade-marquee/index.html"><div class="thumb a">🕹️</div><h2>Arcade Marquee</h2><p>Neon, energetic, stream-forward. Active game gets a big hero panel with a horizontal queue.</p><div class="open">Open prototype →</div></a><a class="card" href="002-rpg-quest-ledger/index.html"><div class="thumb b">📜</div><h2>RPG Quest Ledger</h2><p>Gauntlet as a quest book. Strong active game spotlight with cleared/queued quest rows.</p><div class="open">Open prototype →</div></a></div></body></html>
+37
View File
@@ -0,0 +1,37 @@
@echo off
setlocal
REM Gauntlet Overlay launcher
REM This starts the local server used by OBS, the editor, and batch controls.
cd /d "%~dp0"
echo Starting Gauntlet Overlay server...
echo.
echo Editor: http://localhost:8770/editor.html
echo Overlay: http://localhost:8770/overlay.html
echo.
echo Keep this window open while streaming.
echo Press Ctrl+C to stop the server.
echo.
where py >nul 2>nul
if %errorlevel%==0 (
py server.py
goto :end
)
where python >nul 2>nul
if %errorlevel%==0 (
python server.py
goto :end
)
echo ERROR: Python was not found.
echo Install Python from https://www.python.org/downloads/
echo During install, check "Add Python to PATH".
echo.
pause
:end
endlocal
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
.pytest_cache/
*.pyc
*.zip
data/
+11
View File
@@ -0,0 +1,11 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY . /app
EXPOSE 8765
CMD ["sh", "-c", "mkdir -p /data && [ -f /data/party.json ] || cp /app/defaults/party.json /data/party.json && ln -sf /data/party.json /app/party.json && exec python /app/server.py --host 0.0.0.0 --port 8765 --directory /app"]
+91
View File
@@ -0,0 +1,91 @@
Pokémon Party OBS Overlay
This is the simple Option 2 version: a static overlay page plus a party.json file.
Files:
overlay.html - the OBS/browser overlay
editor.html - editor for the six party slots
guide.html - visual setup guide you can open in your browser
server.py - local server that lets editor.html save party.json directly
party.json - your six party slots
sprites/ - put your Pokémon sprite PNG/GIF files here
How to run locally:
1. Open a terminal in this folder.
2. Run:
py server.py
On Mac/Linux, use:
python3 server.py
3. In OBS, add a Browser Source with:
http://localhost:8765/overlay.html
4. Open the visual guide if you want setup help:
http://localhost:8765/guide.html
5. Recommended OBS browser size:
Width: 1400
Height: 230
6. Keep the server terminal open while streaming.
Important:
Use server.py if you want the editor to save party.json directly.
Plain python -m http.server still works for viewing the overlay, but it cannot save files.
Changing your party:
Option A, recommended direct-save workflow:
1. Start the server with:
py server.py
2. Open this in your normal browser:
http://localhost:8765/editor.html
3. Fill in the six slots.
4. Click Save party.json directly.
5. OBS should update within 2 seconds.
Option B, backup static workflow:
1. Open this in your normal browser:
http://localhost:8765/editor.html
2. Fill in the six slots.
3. Click Copy JSON and paste it over the contents of party.json, or click Download party.json and replace the old file.
4. Save party.json. The overlay checks for changes every 2 seconds.
Option B, browser-storage preview workflow:
1. Open editor.html in the same browser/profile that will view the overlay.
2. Click Save to browser preview.
3. Use this overlay URL:
http://localhost:8765/overlay.html?source=browser
For OBS, Option A is more reliable because OBS may not share browser storage with your regular browser.
Each slot looks like this:
{
"species": "cyndaquil",
"nickname": "Toast",
"sprite": "sprites/cyndaquil.png",
"status": "alive"
}
Status options:
alive - normal
boxed - yellow tag
dead - grayscale sprite and crossed text
empty - empty slot
Sprite naming:
You can name files however you want, as long as party.json points to the right path.
Examples:
sprites/cyndaquil.png
sprites/155.png
sprites/toast.gif
Layout options:
Horizontal party bar:
http://localhost:8765/overlay.html
Vertical sidebar:
http://localhost:8765/overlay.html?layout=vertical
2x3 grid:
http://localhost:8765/overlay.html?layout=grid
Notes:
The page has a transparent background for OBS.
If a sprite file is missing, the overlay shows a Poké Ball placeholder instead of breaking.
+40
View File
@@ -0,0 +1,40 @@
{
"party": [
{
"species": "cyndaquil",
"nickname": "Toast",
"sprite": "sprites/cyndaquil.png",
"status": "alive"
},
{
"species": "pidgey",
"nickname": "Gale",
"sprite": "sprites/pidgey.png",
"status": "alive"
},
{
"species": "mareep",
"nickname": "Static",
"sprite": "sprites/mareep.png",
"status": "alive"
},
{
"species": "",
"nickname": "",
"sprite": "",
"status": "empty"
},
{
"species": "",
"nickname": "",
"sprite": "",
"status": "empty"
},
{
"species": "",
"nickname": "",
"sprite": "",
"status": "empty"
}
]
}
+401
View File
@@ -0,0 +1,401 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pokémon Party Editor</title>
<style>
:root {
--bg: #07111f;
--panel: #101d33;
--panel-2: #152642;
--line: rgba(255,255,255,.16);
--text: #f8fbff;
--muted: #b8c7df;
--accent: #f6d35b;
--blue: #79d2ff;
--green: #80ffaa;
--red: #ff8e8e;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(121,210,255,.18), transparent 32rem),
radial-gradient(circle at bottom right, rgba(246,211,91,.14), transparent 30rem),
var(--bg);
color: var(--text);
font-family: "Segoe UI", Arial, sans-serif;
padding: 28px;
}
h1 { margin: 0 0 8px; font-size: 30px; }
p { color: var(--muted); line-height: 1.5; }
.wrap {
max-width: 1180px;
margin: 0 auto;
}
.topbar,
.slot,
.output {
background: linear-gradient(180deg, rgba(255,255,255,.06), transparent), var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
box-shadow: 0 18px 45px rgba(0,0,0,.28);
}
.topbar {
padding: 18px;
margin: 20px 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
button {
border: 0;
border-radius: 12px;
padding: 11px 14px;
background: var(--blue);
color: #06101f;
font-weight: 800;
cursor: pointer;
}
button.secondary { background: #273b5d; color: var(--text); border: 1px solid var(--line); }
button.good { background: var(--green); }
button.warn { background: var(--accent); }
button.danger { background: var(--red); }
button:hover { filter: brightness(1.08); }
.hint {
flex-basis: 100%;
margin: 4px 0 0;
font-size: 14px;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.slot {
padding: 16px;
display: grid;
grid-template-columns: 90px 1fr;
gap: 14px;
align-items: center;
}
.preview {
width: 90px;
height: 90px;
display: grid;
place-items: center;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: 16px;
}
.preview img {
max-width: 78px;
max-height: 78px;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.ball {
width: 52px;
height: 52px;
border-radius: 50%;
background:
radial-gradient(circle at center, #fff 0 5px, #111 6px 9px, transparent 10px),
linear-gradient(#dc3144 0 46%, #161616 47% 53%, #f4f4f4 54% 100%);
border: 3px solid #111;
opacity: .55;
}
.fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 10px;
background: #081426;
color: var(--text);
padding: 10px;
font: inherit;
}
.slot-title {
grid-column: 1 / -1;
color: var(--accent);
font-weight: 900;
letter-spacing: .5px;
}
.wide { grid-column: 1 / -1; }
.output {
margin-top: 18px;
padding: 16px;
}
textarea {
min-height: 210px;
font-family: Consolas, "Courier New", monospace;
font-size: 13px;
}
.note {
border-left: 4px solid var(--accent);
padding: 10px 12px;
background: rgba(246,211,91,.08);
border-radius: 8px;
}
@media (max-width: 860px) {
body { padding: 14px; }
.grid { grid-template-columns: 1fr; }
.slot { grid-template-columns: 1fr; }
.preview { width: 100%; }
.fields { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="wrap">
<h1>Pokémon Party Editor</h1>
<p>Use this page to build your six-slot party and save it directly to party.json for the OBS overlay.</p>
<section class="topbar">
<button id="loadJson">Load current party.json</button>
<button id="saveFile" class="good">Save party.json directly</button>
<button id="saveBrowser" class="secondary">Save to browser preview</button>
<button id="copyJson" class="warn">Copy JSON</button>
<button id="downloadJson" class="secondary">Download party.json</button>
<button id="clearParty" class="danger">Clear all slots</button>
<div class="hint">Direct save requires running this folder with server.py, not plain python -m http.server. If direct save fails, Copy JSON and Download party.json still work as backups.</div>
</section>
<p class="note">Sprite path examples: sprites/cyndaquil.png, sprites/hgss/155.png, or a full PokeAPI URL. If you enter a National Dex number and leave Sprite blank, the helper buttons can fill it for you.</p>
<section id="slots" class="grid" aria-label="Party slots"></section>
<section class="output">
<h2>Generated party.json</h2>
<textarea id="jsonOutput" spellcheck="false"></textarea>
</section>
</main>
<script>
const STORAGE_KEY = 'pokemonPartyOverlay.party';
const emptySlot = () => ({ species: '', nickname: '', sprite: '', status: 'empty' });
const sample = [
{ species: 'cyndaquil', nickname: 'Toast', sprite: 'sprites/cyndaquil.png', status: 'alive' },
{ species: 'pidgey', nickname: 'Gale', sprite: 'sprites/pidgey.png', status: 'alive' },
{ species: 'mareep', nickname: 'Static', sprite: 'sprites/mareep.png', status: 'alive' },
emptySlot(), emptySlot(), emptySlot()
];
function normalizeParty(data) {
const party = Array.isArray(data) ? data : data?.party;
return Array.from({ length: 6 }, (_, i) => ({ ...emptySlot(), ...(party?.[i] || {}) }));
}
function niceSpecies(value) {
return String(value || '').trim().toLowerCase().replace(/\s+/g, '-');
}
function currentParty() {
return Array.from(document.querySelectorAll('.slot')).map(slot => {
const species = niceSpecies(slot.querySelector('[data-field="species"]').value);
const nickname = slot.querySelector('[data-field="nickname"]').value.trim();
const dex = slot.querySelector('[data-field="dex"]').value.trim();
let sprite = slot.querySelector('[data-field="sprite"]').value.trim();
const status = slot.querySelector('[data-field="status"]').value;
if (!sprite && species) sprite = `sprites/${species}.png`;
if (!species && !nickname && !sprite) return emptySlot();
return { species, nickname, sprite, status: status || 'alive' };
});
}
function partyJson() {
return JSON.stringify({ party: currentParty() }, null, 2);
}
function updateOutput() {
document.getElementById('jsonOutput').value = partyJson();
updatePreviews();
}
function updatePreviews() {
document.querySelectorAll('.slot').forEach(slot => {
const sprite = slot.querySelector('[data-field="sprite"]').value.trim();
const species = slot.querySelector('[data-field="species"]').value.trim();
const preview = slot.querySelector('.preview');
preview.innerHTML = '';
if (sprite || species) {
const img = document.createElement('img');
img.alt = species || 'Pokémon sprite';
img.src = sprite || `sprites/${niceSpecies(species)}.png`;
img.onerror = () => {
preview.innerHTML = '<div class="ball" title="Sprite not found"></div>';
};
preview.appendChild(img);
} else {
preview.innerHTML = '<div class="ball" title="Empty slot"></div>';
}
});
}
function setParty(party) {
normalizeParty(party).forEach((mon, index) => {
const slot = document.querySelector(`.slot[data-index="${index}"]`);
slot.querySelector('[data-field="species"]').value = mon.species || '';
slot.querySelector('[data-field="nickname"]').value = mon.nickname || '';
slot.querySelector('[data-field="sprite"]').value = mon.sprite || '';
slot.querySelector('[data-field="status"]').value = mon.status || (mon.species ? 'alive' : 'empty');
});
updateOutput();
}
function makeSlot(index) {
const slot = document.createElement('article');
slot.className = 'slot';
slot.dataset.index = index;
slot.innerHTML = `
<div class="preview"><div class="ball"></div></div>
<div class="fields">
<div class="slot-title">Slot ${index + 1}</div>
<label>Species
<input data-field="species" placeholder="cyndaquil" />
</label>
<label>Nickname
<input data-field="nickname" placeholder="Toast" />
</label>
<label>National Dex #
<input data-field="dex" inputmode="numeric" placeholder="155" />
</label>
<label>Status
<select data-field="status">
<option value="alive">alive</option>
<option value="dead">dead</option>
<option value="boxed">boxed</option>
<option value="empty">empty</option>
</select>
</label>
<label class="wide">Sprite path or URL
<input data-field="sprite" placeholder="sprites/cyndaquil.png" />
</label>
<button type="button" class="secondary" data-action="localHgss">Use local HGSS path</button>
<button type="button" class="secondary" data-action="pokeapiHgss">Use PokeAPI HGSS URL</button>
</div>
`;
slot.addEventListener('input', updateOutput);
slot.addEventListener('change', updateOutput);
slot.addEventListener('click', event => {
const action = event.target.dataset.action;
if (!action) return;
const dex = slot.querySelector('[data-field="dex"]').value.trim();
const sprite = slot.querySelector('[data-field="sprite"]');
if (!dex) {
alert('Enter the National Dex number first. Example: Cyndaquil is 155.');
return;
}
if (action === 'localHgss') sprite.value = `sprites/hgss/${dex}.png`;
if (action === 'pokeapiHgss') sprite.value = `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-iv/heartgold-soulsilver/${dex}.png`;
updateOutput();
});
return slot;
}
async function loadCurrentJson() {
try {
const response = await fetch(`party.json?cacheBust=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setParty(await response.json());
} catch (error) {
alert(`Could not load party.json. Make sure this folder is being served with python -m http.server.\n\n${error.message}`);
}
}
async function saveFileDirectly() {
try {
const response = await fetch('/api/party', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: partyJson()
});
const result = await response.json().catch(() => ({}));
if (!response.ok || !result.ok) throw new Error(result.error || `HTTP ${response.status}`);
alert('Saved directly to party.json. OBS should update within 2 seconds.');
} catch (error) {
alert(`Could not save directly. Start the overlay with server.py instead of python -m http.server.\n\nWindows command: py server.py\n\nBackup option: click Copy JSON or Download party.json.\n\n${error.message}`);
}
}
function saveBrowserPreview() {
localStorage.setItem(STORAGE_KEY, partyJson());
alert('Saved to this browser. To make OBS use this without replacing party.json, set the OBS URL to overlay.html?source=browser');
}
async function copyJson() {
await navigator.clipboard.writeText(partyJson());
alert('Copied. Paste this over the contents of party.json, then save.');
}
function downloadJson() {
const blob = new Blob([partyJson() + '\n'], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'party.json';
link.click();
URL.revokeObjectURL(url);
}
document.getElementById('loadJson').addEventListener('click', loadCurrentJson);
document.getElementById('saveFile').addEventListener('click', saveFileDirectly);
document.getElementById('saveBrowser').addEventListener('click', saveBrowserPreview);
document.getElementById('copyJson').addEventListener('click', copyJson);
document.getElementById('downloadJson').addEventListener('click', downloadJson);
document.getElementById('clearParty').addEventListener('click', () => setParty(Array.from({ length: 6 }, emptySlot)));
const slots = document.getElementById('slots');
for (let i = 0; i < 6; i++) slots.appendChild(makeSlot(i));
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try { setParty(JSON.parse(saved)); }
catch { setParty(sample); }
} else {
setParty(sample);
}
</script>
</body>
</html>
+365
View File
@@ -0,0 +1,365 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pokémon Party OBS Overlay Guide</title>
<style>
:root {
--bg: #101827;
--panel: #172238;
--panel-2: #1e2d4a;
--ink: #f7fbff;
--muted: #aebbd3;
--line: rgba(255,255,255,.14);
--gold: #ffd76a;
--red: #ff5f6d;
--blue: #60d5ff;
--green: #72ee9b;
--shadow: 0 24px 70px rgba(0,0,0,.34);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
font-family: "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background:
radial-gradient(circle at 18% 12%, rgba(96,213,255,.22), transparent 30rem),
radial-gradient(circle at 82% 4%, rgba(255,215,106,.18), transparent 28rem),
linear-gradient(135deg, #0b1120 0%, #101827 48%, #18243b 100%);
line-height: 1.55;
}
.page {
width: min(1180px, calc(100% - 40px));
margin: 0 auto;
padding: 44px 0 56px;
}
.hero {
position: relative;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 28px;
padding: clamp(28px, 5vw, 58px);
background:
linear-gradient(120deg, rgba(255,255,255,.10), rgba(255,255,255,.03)),
var(--panel);
box-shadow: var(--shadow);
}
.hero:after {
content: "";
position: absolute;
width: 260px;
height: 260px;
right: -80px;
top: -80px;
border-radius: 50%;
border: 34px solid rgba(255,255,255,.07);
box-shadow: inset 0 0 0 28px rgba(255,255,255,.04);
}
.kicker {
display: inline-flex;
gap: 10px;
align-items: center;
margin-bottom: 18px;
padding: 8px 13px;
border-radius: 999px;
background: rgba(255,215,106,.13);
color: var(--gold);
font-weight: 700;
letter-spacing: .02em;
}
h1, h2, h3 { line-height: 1.08; margin: 0; }
h1 {
max-width: 850px;
font-size: clamp(2.3rem, 6vw, 5rem);
letter-spacing: -.035em;
}
.hero p {
max-width: 720px;
margin: 22px 0 0;
color: var(--muted);
font-size: 1.17rem;
}
.quick-links {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 28px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 17px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,.18);
color: var(--ink);
text-decoration: none;
background: rgba(255,255,255,.08);
font-weight: 700;
}
.button.primary {
border-color: rgba(255,215,106,.5);
background: linear-gradient(180deg, #ffe58d, #f5b83b);
color: #1b1606;
}
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 18px;
margin-top: 22px;
}
.card {
grid-column: span 6;
border: 1px solid var(--line);
border-radius: 22px;
padding: 24px;
background: rgba(23,34,56,.82);
box-shadow: 0 16px 45px rgba(0,0,0,.22);
}
.card.full { grid-column: 1 / -1; }
.card.third { grid-column: span 4; }
h2 {
margin-bottom: 14px;
font-size: clamp(1.55rem, 3vw, 2.25rem);
letter-spacing: -.025em;
}
h3 {
margin-bottom: 8px;
font-size: 1.1rem;
color: var(--gold);
}
p { margin: 0 0 12px; color: var(--muted); }
p:last-child { margin-bottom: 0; }
ol, ul { margin: 10px 0 0; padding-left: 1.25rem; color: var(--muted); }
li { margin: 7px 0; }
code, pre {
font-family: Consolas, "Cascadia Mono", monospace;
font-size: .95rem;
}
code {
padding: 2px 6px;
border-radius: 7px;
background: rgba(255,255,255,.09);
color: #dff6ff;
}
pre {
overflow: auto;
margin: 14px 0 0;
padding: 16px;
border-radius: 16px;
border: 1px solid rgba(255,255,255,.12);
background: #07101f;
color: #e8f7ff;
}
.steps {
counter-reset: step;
display: grid;
gap: 14px;
margin-top: 8px;
}
.step {
display: grid;
grid-template-columns: 46px 1fr;
gap: 14px;
align-items: start;
padding: 16px;
border-radius: 18px;
background: rgba(255,255,255,.055);
border: 1px solid rgba(255,255,255,.10);
}
.step:before {
counter-increment: step;
content: counter(step);
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 13px;
background: var(--gold);
color: #1b1606;
font-weight: 900;
}
.callout {
margin-top: 14px;
padding: 16px;
border-radius: 18px;
background: rgba(96,213,255,.11);
border: 1px solid rgba(96,213,255,.28);
color: #daf7ff;
}
.status-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.pill {
padding: 7px 11px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,.16);
background: rgba(255,255,255,.07);
color: var(--muted);
font-size: .95rem;
}
.alive { color: var(--green); }
.boxed { color: var(--gold); }
.dead { color: var(--red); }
footer {
margin-top: 26px;
color: var(--muted);
text-align: center;
}
@media (max-width: 820px) {
.page { width: min(100% - 24px, 1180px); padding-top: 18px; }
.card, .card.third { grid-column: 1 / -1; }
.hero { border-radius: 22px; }
.step { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="page">
<section class="hero">
<div class="kicker">SoulSilver Nuzlocke stream tool</div>
<h1>OBS party overlay setup guide</h1>
<p>This guide walks you through running the Pokémon party overlay locally, editing your six active slots, adding HeartGold/SoulSilver sprites, and connecting everything to OBS.</p>
<nav class="quick-links" aria-label="Guide shortcuts">
<a class="button primary" href="editor.html">Open Editor</a>
<a class="button" href="overlay.html">Preview Overlay</a>
<a class="button" href="overlay.html?layout=grid">Grid Preview</a>
<a class="button" href="overlay.html?layout=vertical">Vertical Preview</a>
</nav>
</section>
<section class="grid" aria-label="Setup guide sections">
<article class="card full">
<h2>Fast start</h2>
<div class="steps">
<div class="step"><div><h3>Extract the folder</h3><p>Unzip <code>pokemon-party-overlay.zip</code> somewhere easy, like <code>C:\StreamingTools\pokemon-party-overlay</code>.</p></div></div>
<div class="step"><div><h3>Start the local server</h3><p>Double-click <code>start-overlay.bat</code>. Keep that window open while streaming.</p></div></div>
<div class="step"><div><h3>Open the editor</h3><p>Go to <code>http://localhost:8765/editor.html</code>, set your Pokémon, nicknames, sprite paths, and statuses, then click <strong>Save party.json directly</strong>.</p></div></div>
<div class="step"><div><h3>Add the OBS source</h3><p>In OBS, add a Browser Source pointing to <code>http://localhost:8765/overlay.html</code>. Recommended size: <code>1400 x 230</code>.</p></div></div>
</div>
</article>
<article class="card">
<h2>What each file does</h2>
<ul>
<li><code>start-overlay.bat</code> starts everything on Windows.</li>
<li><code>server.py</code> serves the pages and lets the editor save directly.</li>
<li><code>editor.html</code> is your control page for changing the party.</li>
<li><code>overlay.html</code> is the OBS Browser Source page.</li>
<li><code>party.json</code> stores the six slots.</li>
<li><code>sprites/</code> stores your local PNG or GIF sprites.</li>
</ul>
</article>
<article class="card">
<h2>OBS settings</h2>
<p>Add a new Browser Source and use:</p>
<pre>URL: http://localhost:8765/overlay.html
Width: 1400
Height: 230</pre>
<p class="callout">The overlay background is transparent, so you can place it above your game capture. If you use a different layout, resize the Browser Source to fit.</p>
</article>
<article class="card full">
<h2>Adding sprites</h2>
<p>The easiest local setup is to put HeartGold/SoulSilver sprites in <code>sprites/hgss/</code> and use National Dex numbers as filenames.</p>
<pre>pokemon-party-overlay/
sprites/
hgss/
152.png
155.png
158.png
25.png</pre>
<p>Then use paths like this in the editor:</p>
<pre>sprites/hgss/155.png</pre>
<p>Good source: <code>https://github.com/PokeAPI/sprites</code>. The HGSS folder is:</p>
<pre>sprites/pokemon/versions/generation-iv/heartgold-soulsilver/</pre>
</article>
<article class="card third">
<h2>Layouts</h2>
<p>Horizontal party bar:</p>
<pre>overlay.html</pre>
<p>Vertical sidebar:</p>
<pre>overlay.html?layout=vertical</pre>
<p>2x3 grid:</p>
<pre>overlay.html?layout=grid</pre>
</article>
<article class="card third">
<h2>Status tags</h2>
<p>Use these status values in the editor:</p>
<div class="status-row">
<span class="pill alive">alive</span>
<span class="pill boxed">boxed</span>
<span class="pill dead">dead</span>
<span class="pill">empty</span>
</div>
<p class="callout">Dead Pokémon show with grayscale styling, which is useful for Nuzlocke tracking.</p>
</article>
<article class="card third">
<h2>If something breaks</h2>
<ul>
<li>If OBS is blank, confirm <code>start-overlay.bat</code> is still open.</li>
<li>If saving fails, make sure you started with <code>server.py</code>, not <code>python -m http.server</code>.</li>
<li>If a sprite is missing, check the path and filename spelling.</li>
<li>If port 8765 is busy, close the old server window and start again.</li>
</ul>
</article>
<article class="card full">
<h2>Example party.json slot</h2>
<p>You normally edit this through the editor, but this is what one Pokémon slot looks like under the hood:</p>
<pre>{
"species": "cyndaquil",
"nickname": "Toast",
"sprite": "sprites/hgss/155.png",
"status": "alive"
}</pre>
</article>
</section>
<footer>
Keep the server window open during stream. Update the party in the editor; OBS refreshes automatically within a couple seconds.
</footer>
</main>
</body>
</html>
+325
View File
@@ -0,0 +1,325 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pokémon Party Overlay</title>
<style>
:root {
--slot-width: 190px;
--slot-height: 170px;
--accent: #f6d35b;
--accent-2: #79d2ff;
--panel: rgba(13, 23, 42, 0.78);
--panel-strong: rgba(6, 12, 28, 0.9);
--text: #fff7d6;
--muted: #bdd7ff;
--dead: #8b94a7;
--empty: rgba(255,255,255,0.11);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
width: 100%;
min-height: 100%;
background: transparent;
font-family: "Trebuchet MS", "Segoe UI", Arial, sans-serif;
color: var(--text);
overflow: hidden;
}
body {
padding: 18px;
}
.party {
display: grid;
grid-template-columns: repeat(6, var(--slot-width));
gap: 14px;
align-items: end;
width: max-content;
filter: drop-shadow(0 12px 18px rgba(0,0,0,.45));
}
.slot {
position: relative;
width: var(--slot-width);
min-height: var(--slot-height);
padding: 12px 12px 10px;
border-radius: 22px 22px 14px 14px;
background:
linear-gradient(135deg, rgba(255,255,255,.18), transparent 22%),
linear-gradient(180deg, var(--panel), var(--panel-strong));
border: 3px solid rgba(255,255,255,.32);
box-shadow:
inset 0 0 0 2px rgba(0,0,0,.24),
0 8px 0 rgba(0,0,0,.28);
overflow: hidden;
}
.slot::before {
content: "";
position: absolute;
inset: 7px;
border-radius: 17px 17px 10px 10px;
border: 1px solid rgba(121, 210, 255, .35);
pointer-events: none;
}
.slot.empty {
background:
repeating-linear-gradient(135deg, rgba(255,255,255,.08), rgba(255,255,255,.08) 10px, transparent 10px, transparent 20px),
linear-gradient(180deg, rgba(13,23,42,.45), rgba(6,12,28,.65));
}
.slot.dead .sprite {
filter: grayscale(1) opacity(.58);
transform: translateY(4px) scale(.94);
}
.slot.dead .nickname,
.slot.dead .species {
color: var(--dead);
text-decoration: line-through;
}
.sprite-wrap {
position: relative;
height: 104px;
display: grid;
place-items: center;
margin-top: 2px;
}
.sprite-wrap::after {
content: "";
position: absolute;
width: 112px;
height: 22px;
border-radius: 999px;
bottom: 4px;
background: radial-gradient(ellipse, rgba(0,0,0,.38), rgba(0,0,0,0) 72%);
}
.sprite {
position: relative;
z-index: 1;
image-rendering: pixelated;
image-rendering: crisp-edges;
max-width: 122px;
max-height: 96px;
object-fit: contain;
transform: translateY(0);
}
.pokeball {
width: 78px;
height: 78px;
border-radius: 50%;
background:
radial-gradient(circle at center, #fff 0 8px, #111 9px 13px, transparent 14px),
linear-gradient(#dc3144 0 46%, #161616 47% 53%, #f4f4f4 54% 100%);
opacity: .42;
border: 4px solid #111;
box-shadow: inset 0 -7px 0 rgba(0,0,0,.18);
}
.nickname {
position: relative;
z-index: 1;
margin-top: 4px;
font-weight: 900;
font-size: 25px;
line-height: 1.05;
text-align: center;
letter-spacing: .5px;
color: var(--text);
text-shadow: 0 3px 0 rgba(0,0,0,.58), 0 0 10px rgba(0,0,0,.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.species {
position: relative;
z-index: 1;
margin-top: 3px;
text-align: center;
font-size: 13px;
font-weight: 800;
letter-spacing: 1.4px;
text-transform: uppercase;
color: var(--muted);
opacity: .92;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.badge {
position: absolute;
top: 9px;
left: 10px;
z-index: 2;
display: inline-grid;
place-items: center;
min-width: 26px;
height: 26px;
padding: 0 7px;
border-radius: 999px;
background: linear-gradient(180deg, var(--accent), #cf8e24);
color: #241400;
font-size: 13px;
font-weight: 1000;
border: 2px solid rgba(255,255,255,.68);
box-shadow: 0 3px 0 rgba(0,0,0,.3);
}
.status {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
padding: 4px 8px;
border-radius: 999px;
background: rgba(0,0,0,.45);
color: #fff;
font-size: 11px;
font-weight: 900;
letter-spacing: .9px;
text-transform: uppercase;
border: 1px solid rgba(255,255,255,.25);
}
.status.alive { color: #8effa9; }
.status.dead { color: #ff8e8e; }
.status.boxed { color: #ffd37a; }
.party.vertical {
grid-template-columns: var(--slot-width);
}
.party.grid {
grid-template-columns: repeat(3, var(--slot-width));
}
</style>
</head>
<body>
<main id="party" class="party" aria-label="Current Pokémon party"></main>
<script>
const DEFAULT_PARTY = [
{ "species": "cyndaquil", "nickname": "Toast", "sprite": "sprites/cyndaquil.png", "status": "alive" },
{ "species": "totodile", "nickname": "Chomps", "sprite": "sprites/totodile.png", "status": "alive" },
{ "species": "pidgey", "nickname": "Gale", "sprite": "sprites/pidgey.png", "status": "alive" },
{ "species": "mareep", "nickname": "Static", "sprite": "sprites/mareep.png", "status": "alive" },
{ "species": "", "nickname": "", "sprite": "", "status": "empty" },
{ "species": "", "nickname": "", "sprite": "", "status": "empty" }
];
const params = new URLSearchParams(location.search);
const STORAGE_KEY = 'pokemonPartyOverlay.party';
const settings = {
refreshMs: 2000,
layout: params.get('layout') || 'horizontal',
source: params.get('source') || 'json'
};
function niceName(value) {
return String(value || '')
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase());
}
function normalizeParty(data) {
const party = Array.isArray(data) ? data : data.party;
return Array.from({ length: 6 }, (_, i) => party?.[i] || {});
}
function render(party) {
const root = document.getElementById('party');
root.className = `party ${settings.layout}`;
root.innerHTML = '';
normalizeParty(party).forEach((mon, index) => {
const status = (mon.status || (mon.species ? 'alive' : 'empty')).toLowerCase();
const hasMon = Boolean(mon.species || mon.nickname || mon.sprite);
const slot = document.createElement('section');
slot.className = `slot ${hasMon ? status : 'empty'}`;
const badge = document.createElement('div');
badge.className = 'badge';
badge.textContent = index + 1;
slot.appendChild(badge);
if (hasMon && status !== 'empty') {
const statusEl = document.createElement('div');
statusEl.className = `status ${status}`;
statusEl.textContent = status;
slot.appendChild(statusEl);
}
const spriteWrap = document.createElement('div');
spriteWrap.className = 'sprite-wrap';
if (mon.sprite) {
const img = document.createElement('img');
img.className = 'sprite';
img.alt = mon.species || mon.nickname || `Party slot ${index + 1}`;
img.src = mon.sprite;
img.onerror = () => {
img.remove();
const ball = document.createElement('div');
ball.className = 'pokeball';
spriteWrap.appendChild(ball);
};
spriteWrap.appendChild(img);
} else {
const ball = document.createElement('div');
ball.className = 'pokeball';
spriteWrap.appendChild(ball);
}
slot.appendChild(spriteWrap);
const nickname = document.createElement('div');
nickname.className = 'nickname';
nickname.textContent = mon.nickname || (hasMon ? niceName(mon.species) : 'Empty');
slot.appendChild(nickname);
const species = document.createElement('div');
species.className = 'species';
species.textContent = hasMon ? niceName(mon.species) : 'Party Slot';
slot.appendChild(species);
root.appendChild(slot);
});
}
async function loadParty() {
try {
if (settings.source === 'browser') {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) throw new Error('No party saved in browser storage yet. Open editor.html and click Save to browser preview.');
render(JSON.parse(saved));
return;
}
const response = await fetch(`party.json?cacheBust=${Date.now()}`, { cache: 'no-store' });
if (!response.ok) throw new Error(`party.json returned ${response.status}`);
const data = await response.json();
render(data);
} catch (error) {
console.warn('Using built-in sample party. Serve this folder over HTTP to load party.json.', error);
render(DEFAULT_PARTY);
}
}
loadParty();
setInterval(loadParty, settings.refreshMs);
</script>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Tiny local server for the Pokémon Party OBS overlay.
It serves overlay.html/editor.html like `python -m http.server`, plus adds:
POST /api/party -> validates JSON and writes party.json in this folder.
"""
from __future__ import annotations
import argparse
import json
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
EMPTY_SLOT = {"species": "", "nickname": "", "sprite": "", "status": "empty"}
VALID_STATUSES = {"alive", "dead", "boxed", "empty"}
def normalize_slot(slot: Any) -> dict[str, str]:
if not isinstance(slot, dict):
return dict(EMPTY_SLOT)
species = str(slot.get("species", "") or "").strip().lower().replace(" ", "-")
nickname = str(slot.get("nickname", "") or "").strip()
sprite = str(slot.get("sprite", "") or "").strip()
status = str(slot.get("status", "") or "").strip().lower()
if not status or status not in VALID_STATUSES:
status = "alive" if (species or nickname or sprite) else "empty"
if not (species or nickname or sprite):
return dict(EMPTY_SLOT)
return {
"species": species,
"nickname": nickname,
"sprite": sprite,
"status": status,
}
def normalize_party(data: Any) -> dict[str, list[dict[str, str]]]:
party = data.get("party") if isinstance(data, dict) else data
if not isinstance(party, list):
raise ValueError("Expected JSON object with a party array, or a party array.")
slots = [normalize_slot(slot) for slot in party[:6]]
while len(slots) < 6:
slots.append(dict(EMPTY_SLOT))
return {"party": slots}
class PartyOverlayHandler(SimpleHTTPRequestHandler):
server_version = "PokemonPartyOverlay/1.0"
def do_POST(self) -> None: # noqa: N802 - http.server method name
if self.path.split("?", 1)[0] != "/api/party":
self.send_json({"ok": False, "error": "Not found"}, status=404)
return
try:
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length).decode("utf-8")
data = json.loads(raw)
normalized = normalize_party(data)
party_path = Path(self.directory).resolve() / "party.json"
party_path.write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8")
except Exception as exc: # Keep this friendly for local streamer use.
self.send_json({"ok": False, "error": str(exc)}, status=400)
return
self.send_json({"ok": True, "message": "party.json saved"})
def send_json(self, payload: dict[str, Any], status: int = 200) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def main() -> None:
parser = argparse.ArgumentParser(description="Run the Pokémon Party OBS overlay server.")
parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Default: 127.0.0.1")
parser.add_argument("--port", type=int, default=8765, help="Port to bind. Default: 8765")
parser.add_argument("--directory", default=str(Path(__file__).resolve().parent), help="Overlay folder to serve.")
args = parser.parse_args()
handler = lambda *handler_args, **kwargs: PartyOverlayHandler( # noqa: E731
*handler_args,
directory=args.directory,
**kwargs,
)
with ThreadingHTTPServer((args.host, args.port), handler) as httpd:
print(f"Pokémon Party Overlay server running at http://{args.host}:{args.port}/")
print(f"Editor: http://{args.host}:{args.port}/editor.html")
print(f"Overlay: http://{args.host}:{args.port}/overlay.html")
print("Keep this window open while streaming. Press Ctrl+C to stop.")
httpd.serve_forever()
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
Drop sprite files in this folder.
Recommended formats:
PNG for still sprites
GIF for animated sprites
Then reference them from party.json like:
"sprite": "sprites/cyndaquil.png"
+37
View File
@@ -0,0 +1,37 @@
@echo off
setlocal
REM Pokemon Party Overlay launcher
REM This starts the local server used by OBS and the editor.
cd /d "%~dp0"
echo Starting Pokemon Party Overlay server...
echo.
echo Editor: http://localhost:8765/editor.html
echo Overlay: http://localhost:8765/overlay.html
echo.
echo Keep this window open while streaming.
echo Press Ctrl+C to stop the server.
echo.
where py >nul 2>nul
if %errorlevel%==0 (
py server.py
goto :end
)
where python >nul 2>nul
if %errorlevel%==0 (
python server.py
goto :end
)
echo ERROR: Python was not found.
echo Install Python from https://www.python.org/downloads/
echo During install, check "Add Python to PATH".
echo.
pause
:end
endlocal
+75
View File
@@ -0,0 +1,75 @@
import json
import sys
import tempfile
import threading
import unittest
from http.client import HTTPConnection
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
class PartyServerTests(unittest.TestCase):
def setUp(self):
from server import PartyOverlayHandler, ThreadingHTTPServer
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
(self.root / "party.json").write_text('{"party": []}\n', encoding="utf-8")
(self.root / "editor.html").write_text("editor", encoding="utf-8")
handler = lambda *args, **kwargs: PartyOverlayHandler(*args, directory=str(self.root), **kwargs)
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.thread.start()
self.host, self.port = self.httpd.server_address
def tearDown(self):
self.httpd.shutdown()
self.thread.join(timeout=2)
self.tmp.cleanup()
def request(self, method, path, body=None, headers=None):
conn = HTTPConnection(self.host, self.port, timeout=5)
conn.request(method, path, body=body, headers=headers or {})
response = conn.getresponse()
data = response.read().decode("utf-8")
conn.close()
return response.status, data, dict(response.getheaders())
def test_post_api_party_writes_normalized_party_json(self):
payload = {
"party": [
{"species": "cyndaquil", "nickname": "Toast", "sprite": "sprites/hgss/155.png", "status": "alive"}
]
}
status, body, _headers = self.request(
"POST",
"/api/party",
body=json.dumps(payload),
headers={"Content-Type": "application/json"},
)
self.assertEqual(status, 200)
self.assertEqual(json.loads(body)["ok"], True)
saved = json.loads((self.root / "party.json").read_text(encoding="utf-8"))
self.assertEqual(len(saved["party"]), 6)
self.assertEqual(saved["party"][0]["species"], "cyndaquil")
self.assertEqual(saved["party"][1]["status"], "empty")
def test_post_api_party_rejects_invalid_json_without_overwriting_file(self):
before = (self.root / "party.json").read_text(encoding="utf-8")
status, body, _headers = self.request(
"POST",
"/api/party",
body="not json",
headers={"Content-Type": "application/json"},
)
self.assertEqual(status, 400)
self.assertIn("error", json.loads(body))
self.assertEqual((self.root / "party.json").read_text(encoding="utf-8"), before)
if __name__ == "__main__":
unittest.main()