Skip to content

Instantly share code, notes, and snippets.

@capttwinky
Created December 11, 2025 20:10
Show Gist options
  • Select an option

  • Save capttwinky/ddfb0f96b02562d8ba31f87b32d83548 to your computer and use it in GitHub Desktop.

Select an option

Save capttwinky/ddfb0f96b02562d8ba31f87b32d83548 to your computer and use it in GitHub Desktop.

🌟 PCGTimer++ — A Fully Customizable, Self-Contained Synchronized PCG Spawn Countdown Timer

PCGTimer++ is a standalone HTML application that provides a synchronized countdown timer with:

  • ⏱ Accurate, drift-corrected countdown
  • 🔄 Automatic polite backend synchronization (low-load, randomized interval pings)
  • 🔊 Web-Audio alarm system (no external files required)
  • 🎨 Custom fonts, colors, glow effects, and background themes
  • ⚙️ Hidden configuration panel (press C to toggle)
  • 🚨 Acknowledge button that appears only while the alarm is active
  • 🔐 Browser-safe audio unlocking overlay
  • 💾 Automatic persistence via localStorage

It is designed for use in livestream overlays, event timers, game spawn tracking, or personal dashboards. No build tools, no dependencies, no frameworks — just open the HTML file in a modern browser.


🔧 Features

🔥 Backend Sync (Low Pressure Mode)

  • Fetches next_spawn from a remote endpoint.
  • Uses a fixed spawn interval (default: 15 minutes).
  • Syncs only once per spawn interval, at a random point in the middle of the cycle.
  • Eliminates “thundering herd” backend load spikes.

🎵 Alarm System

  • Multiple sound styles: beep, chime, alarm burst
  • Procedural WebAudio tones (does not require MP3/WAV files)
  • Runs up to a user-configured duration (default: 30 seconds)
  • Stops when acknowledged

🎨 Full UI Customization

  • Change font family (digital, rounded, serif, bold, etc.)
  • Pick fill, outline, and background colors
  • Background updates live — even under the unlock overlay
  • Glow and theme automatically update

⚙️ Configuration Panel

  • Press C to open or close
  • Small hint displayed when hidden
  • Panel disappears automatically during normal timer operation

🖱 Audio Unlock

Browsers require first-interaction permission to play sound. PCGTimer++ provides a fullscreen, theme-aware unlock overlay:

  • Click anywhere once → overlay disappears
  • All future alarms and previews work normally

💾 Persistent Settings

Automatically saved:

  • Font
  • Fill color
  • Outline color
  • Background color
  • Alarm type
  • Alarm duration

📦 How to Run Locally

There is no server required. Just:

  1. Download the file pcgtimer.html

  2. Open it in any modern browser

    • Chrome
    • Firefox
    • Edge
    • Safari
  3. Click once to unlock audio permissions

  4. Press C to configure

  5. Let the timer auto-sync and run on its own

That’s it — everything (HTML, CSS, JavaScript, Web Audio) is bundled inside the single file.


🛠 Customization

Feel free to fork or modify:

  • Spawn period
  • Sync windows
  • Alarm tones
  • Alarm duration
  • UI styling
  • Fonts and animations
  • Overlay style and blur strength

PCGTimer++ is designed to be readable, hackable, and portable.


🎉 License

Do whatever you want with it — stream it, mod it, remix it, or ship your own version.


<html lang="en">
<head>
<meta charset="UTF-8">
<title>Timer</title>
<style>
body {
margin: 0;
background: #111; /* default, can be overridden by JS */
color: #fff;
font-family: system-ui, sans-serif;
}
#countdown {
font-size: 200px;
color: #fdc331; /* default fill, can be overridden by JS */
text-align: center;
margin-top: 15vh;
text-shadow: 0 0 5px #000, 0 0 10px #000, 0 0 15px #000; /* default outline */
}
/* bottom-center hint */
#config-hint {
position: fixed;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
font-size: 14px;
color: #aaa;
opacity: 0.8;
pointer-events: none;
}
/* Config panel: hidden by default, appears top-right when active */
#config-panel {
position: fixed;
top: 20px;
right: 20px;
font-size: 14px;
background: rgba(0,0,0,0.85);
padding: 10px 14px;
border-radius: 8px;
color: #fff;
display: none; /* toggled via JS */
flex-direction: column;
gap: 8px;
z-index: 5000;
}
#config-panel-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
select, button, input[type="color"], input[type="number"] {
font-size: 14px;
}
/* Acknowledge button container, bottom-right */
#ack-container {
position: fixed;
bottom: 20px;
right: 20px;
font-size: 16px;
background: rgba(0,0,0,0.7);
padding: 8px 14px;
border-radius: 999px;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 4000;
}
#ack-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
#unlock-overlay {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0,0,0,0.8);
color: white;
font-size: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 9999;
text-align: center;
padding: 20px;
box-sizing: border-box;
}
</style>
</head>
<body>
<div id="unlock-overlay">
🔊 Click anywhere to enable sound<br>
<span style="font-size:18px;opacity:0.8;">(Required by your browser)</span>
</div>
<h1 id="countdown">13:37</h1>
<div id="config-hint">
Press <b>C</b> to configure
</div>
<!-- Config panel (shown when user presses C) -->
<div id="config-panel">
<div id="config-panel-row">
<strong>Configuration</strong>
<button type="button" onclick="hideConfig()">✕ Close</button>
</div>
<div id="config-panel-row">
<label for="sound-select">Sound:</label>
<select id="sound-select">
<option value="beep">Beep</option>
<option value="chime">Chime</option>
<option value="alarm" selected>Alarm</option>
</select>
<button type="button" onclick="previewSound()">▶ Preview</button>
</div>
<div id="config-panel-row">
<label for="font-select">Font:</label>
<select id="font-select">
<option value="digital" selected>Digital (monospace)</option>
<option value="rounded">Rounded UI</option>
<option value="system">System UI</option>
<option value="bold">Big Bold</option>
<option value="serif">Classic Serif</option>
</select>
</div>
<!-- Color pickers -->
<div id="config-panel-row">
<label for="fill-color">Fill:</label>
<input type="color" id="fill-color" value="#fdc331">
<label for="outline-color">Outline:</label>
<input type="color" id="outline-color" value="#000000">
<label for="bg-color">Background:</label>
<input type="color" id="bg-color" value="#111111">
</div>
<!-- NEW: Alarm duration -->
<div id="config-panel-row">
<label for="alarm-duration">Alarm duration (sec):</label>
<input type="number" id="alarm-duration" min="1" max="600" step="1" value="30" style="width:70px;">
</div>
<div style="font-size:12px;opacity:0.8;">
Tip: Press <b>C</b> again to close.
</div>
</div>
<!-- Acknowledge button (only shows while alarm active) -->
<div id="ack-container">
<button type="button" id="ack-btn" onclick="acknowledgeAlarm()" disabled>
Acknowledge
</button>
</div>
<script>
const backend_url = "https://poketwitch.bframework.de/";
let soundEnabled = false;
let audioCtx = null;
// alarm control
let alarmActive = false;
let alarmInterval = null;
let alarmStopTimeout = null;
// config visibility
let configVisible = false;
// configurable alarm duration (seconds)
let alarmDurationSeconds = 30;
function pad(n) {
return n.toString().padStart(2, '0');
}
function ensureAudioContext() {
if (!audioCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
audioCtx = new AC();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
// 🔊 Simple synth-based sounds, no files
function playAlarmBurst(kind) {
if (!soundEnabled) return;
ensureAudioContext();
const ctx = audioCtx;
const now = ctx.currentTime;
function makeBeep(freq, startOffset, duration, type = 'sine', volume = 0.2) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, now + startOffset);
osc.connect(gain);
gain.connect(ctx.destination);
const t0 = now + startOffset;
gain.gain.setValueAtTime(0, t0);
gain.gain.linearRampToValueAtTime(volume, t0 + 0.01);
gain.gain.linearRampToValueAtTime(0, t0 + duration);
osc.start(t0);
osc.stop(t0 + duration + 0.05);
}
if (kind === 'beep') {
makeBeep(880, 0.0, 0.15, 'square', 0.2);
} else if (kind === 'chime') {
makeBeep(660, 0.0, 0.25, 'sine', 0.15);
makeBeep(990, 0.22, 0.35, 'sine', 0.15);
} else if (kind === 'alarm') {
makeBeep(800, 0.0, 0.15, 'square', 0.25);
makeBeep(800, 0.25, 0.15, 'square', 0.25);
makeBeep(800, 0.50, 0.15, 'square', 0.25);
} else {
makeBeep(880, 0.0, 0.2, 'square', 0.2);
}
}
// 🚨 Start a repeating alarm that lasts up to alarmDurationSeconds
function startAlarm(kind) {
if (!soundEnabled) return;
if (alarmActive) return;
alarmActive = true;
const ackBtn = document.getElementById('ack-btn');
const ackContainer = document.getElementById('ack-container');
ackBtn.disabled = false;
ackContainer.style.opacity = "1";
ackContainer.style.pointerEvents = "auto";
// Play immediately
playAlarmBurst(kind);
// Repeat every second
alarmInterval = setInterval(() => {
playAlarmBurst(kind);
}, 1000);
// Hard stop after configured duration
const durationMs = Math.max(1, alarmDurationSeconds) * 1000;
alarmStopTimeout = setTimeout(() => {
stopAlarm();
}, durationMs);
}
// 🛑 Stop alarm (timeout or acknowledge)
function stopAlarm() {
if (!alarmActive) return;
alarmActive = false;
if (alarmInterval) {
clearInterval(alarmInterval);
alarmInterval = null;
}
if (alarmStopTimeout) {
clearTimeout(alarmStopTimeout);
alarmStopTimeout = null;
}
const ackBtn = document.getElementById('ack-btn');
const ackContainer = document.getElementById('ack-container');
ackBtn.disabled = true;
ackContainer.style.opacity = "0";
ackContainer.style.pointerEvents = "none";
}
// 🫰 User acknowledges alarm
function acknowledgeAlarm() {
stopAlarm();
}
// 🔓 Unlock audio on first click
document.getElementById("unlock-overlay").onclick = () => {
soundEnabled = true;
ensureAudioContext();
document.getElementById("unlock-overlay").style.display = "none";
};
// ▶ Preview the selected sound
function previewSound() {
if (!soundEnabled) {
alert("Click anywhere on the page first to enable sound.");
return;
}
const kind = document.getElementById("sound-select").value;
playAlarmBurst(kind);
}
// 🎨 Font logic
function applyFontChoice(choice) {
const el = document.getElementById("countdown");
// reset some properties
el.style.letterSpacing = "";
el.style.fontWeight = "";
if (choice === "digital") {
el.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace";
el.style.letterSpacing = "6px";
el.style.fontWeight = "600";
} else if (choice === "rounded") {
el.style.fontFamily = "ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
el.style.fontWeight = "600";
} else if (choice === "system") {
el.style.fontFamily = "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
el.style.fontWeight = "700";
} else if (choice === "bold") {
el.style.fontFamily = "'Arial Black', 'Segoe UI Black', Impact, system-ui, sans-serif";
el.style.fontWeight = "900";
} else if (choice === "serif") {
el.style.fontFamily = "ui-serif, 'Georgia', 'Times New Roman', serif";
el.style.fontWeight = "700";
}
try {
localStorage.setItem("timerFontChoice", choice);
} catch (e) {
// ignore storage errors
}
}
function initFontChoice() {
const select = document.getElementById("font-select");
let saved = null;
try {
saved = localStorage.getItem("timerFontChoice");
} catch (e) {
saved = null;
}
if (saved && [...select.options].some(o => o.value === saved)) {
select.value = saved;
}
applyFontChoice(select.value);
select.addEventListener("change", () => {
applyFontChoice(select.value);
});
}
// 🎨 Color picker logic
function applyColors(fill, outline, bg) {
const countdown = document.getElementById("countdown");
if (fill) {
countdown.style.color = fill;
}
if (outline) {
countdown.style.textShadow =
`0 0 5px ${outline}, 0 0 10px ${outline}, 0 0 15px ${outline}`;
}
if (bg) {
document.body.style.backgroundColor = bg;
}
// persist
try {
localStorage.setItem("timerFillColor", fill);
localStorage.setItem("timerOutlineColor", outline);
localStorage.setItem("timerBgColor", bg);
} catch (e) {
// ignore
}
}
function initColors() {
const fillInput = document.getElementById("fill-color");
const outlineInput = document.getElementById("outline-color");
const bgInput = document.getElementById("bg-color");
let fill = fillInput.value;
let outline = outlineInput.value;
let bg = bgInput.value;
try {
const savedFill = localStorage.getItem("timerFillColor");
const savedOutline = localStorage.getItem("timerOutlineColor");
const savedBg = localStorage.getItem("timerBgColor");
if (savedFill) fill = savedFill;
if (savedOutline) outline = savedOutline;
if (savedBg) bg = savedBg;
} catch (e) {
// ignore
}
// sync inputs with effective values
fillInput.value = fill;
outlineInput.value = outline;
bgInput.value = bg;
applyColors(fill, outline, bg);
function updateFromInputs() {
applyColors(fillInput.value, outlineInput.value, bgInput.value);
}
fillInput.addEventListener("input", updateFromInputs);
outlineInput.addEventListener("input", updateFromInputs);
}
// ⏱ Alarm duration config
function applyAlarmDurationFromInput() {
const input = document.getElementById("alarm-duration");
let v = parseInt(input.value, 10);
if (isNaN(v) || v < 1) v = 1;
if (v > 600) v = 600; // cap at 10 minutes just in case
input.value = v;
alarmDurationSeconds = v;
try {
localStorage.setItem("timerAlarmDurationSeconds", String(v));
} catch (e) {
// ignore
}
}
function initAlarmDuration() {
const input = document.getElementById("alarm-duration");
let v = 30;
try {
const saved = localStorage.getItem("timerAlarmDurationSeconds");
if (saved !== null) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed)) {
v = parsed;
}
}
} catch (e) {
// ignore
}
if (isNaN(v) || v < 1) v = 30;
if (v > 600) v = 600;
input.value = v;
alarmDurationSeconds = v;
input.addEventListener("change", applyAlarmDurationFromInput);
input.addEventListener("blur", applyAlarmDurationFromInput);
}
// ⚙️ Config panel show/hide
function showConfig() {
const panel = document.getElementById("config-panel");
const hint = document.getElementById("config-hint");
panel.style.display = "flex";
configVisible = true;
if (hint) hint.style.display = "none";
}
function hideConfig() {
const panel = document.getElementById("config-panel");
const hint = document.getElementById("config-hint");
panel.style.display = "none";
configVisible = false;
if (hint) hint.style.display = "block";
}
// Toggle config on 'c' key
document.addEventListener("keydown", (e) => {
if (e.key === "c" || e.key === "C") {
if (configVisible) {
hideConfig();
} else {
showConfig();
}
}
});
async function mainloop() {
try {
const response = await fetch(backend_url + 'info/events/last_spawn/');
const data = await response.json();
const start = Date.now();
const target = start + data.next_spawn * 1000;
function tick() {
const now = Date.now();
const remaining = Math.max(0, Math.floor((target - now) / 1000));
const min = Math.floor(remaining / 60);
const sec = Math.floor(remaining % 60);
document.getElementById("countdown").textContent =
`${pad(min)}:${pad(sec)}`;
if (remaining > 0) {
requestAnimationFrame(tick);
} else {
if (soundEnabled) {
const kind = document.getElementById("sound-select").value;
startAlarm(kind);
}
setTimeout(mainloop, 2000);
}
}
tick();
} catch (err) {
console.error("Error during timer loop:", err);
setTimeout(mainloop, 5000);
}
}
// init font, colors, duration & start
initFontChoice();
initColors();
initAlarmDuration();
mainloop();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment