Custom System
>
ENCOUNTER RECOVERY
+
Resume Last Encounter?
+
+
Resume where you left off, or begin with a clean encounter. Your
permanent Library, assets, presets, settings, and recovery snapshots
are not deleted when you choose New Encounter.
+
Resume Last Encounter?
>
New Encounter
+
Resume Last Encounter
@@ -490,14 +925,31 @@
Resume Last Encounter?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/programs/app/js/display.js b/programs/app/js/display.js
index 97c6c1d..7205de3 100644
--- a/programs/app/js/display.js
+++ b/programs/app/js/display.js
@@ -9,6 +9,10 @@ ENC.display = {
lastDmSeenAt: 0,
connectionTimer: null,
+ // Visual Timer state
+ timerRenderer: null,
+ timerController: null,
+
// Player Display privacy state
paused: false,
awaitingDmState: true,
@@ -19,6 +23,7 @@ ENC.display = {
async load() {
this.encounter = ENC.normalizeEncounter(await ENC.db.getActiveEncounter());
+
this.render();
},
@@ -31,11 +36,17 @@ ENC.display = {
return ENC.isTurnEligibleCombatant(combatant);
},
+ /* ========================================
+ CONNECTION STATUS
+ ======================================== */
+
setConnectionState(state) {
const indicator = document.getElementById("displayConnectionStatus");
+
if (!indicator) return;
indicator.classList.remove("connecting", "connected", "disconnected");
+
indicator.classList.add(state);
const labels = {
@@ -43,40 +54,223 @@ ENC.display = {
connected: "DM SYNC: CONNECTED",
disconnected: "DM SYNC: DISCONNECTED",
};
+
indicator.textContent = labels[state] || labels.disconnected;
},
markDmSeen() {
this.lastDmSeenAt = Date.now();
+
this.setConnectionState("connected");
},
/* ========================================
- PLAYER DISPLAY PRIVACY
-======================================== */
+ PLAYER DISPLAY PRIVACY
+ ======================================== */
setPaused(paused) {
this.paused = Boolean(paused);
const overlay = document.getElementById("playerDisplayPrivacyOverlay");
- if (!overlay) return;
+ if (overlay) {
+ overlay.classList.toggle("show", this.paused || this.awaitingDmState);
+ }
+
+ /*
+ * Also hide the visual timer while the
+ * Player Display privacy screen is active.
+ */
+ this.updateTimerLayer();
+ },
+
+ /* ========================================
+ VISUAL TIMER
+ ======================================== */
+
+ initializeTimer() {
+ if (!ENC.timerRenderer || typeof ENC.timerRenderer.create !== "function") {
+ throw new Error(
+ "Timer renderer did not load. Check js/modules/timer-renderer.js.",
+ );
+ }
+
+ if (!ENC.timer || typeof ENC.timer.create !== "function") {
+ throw new Error("Timer engine did not load. Check js/modules/timer.js.");
+ }
+
+ const timerContainer = document.getElementById("playerTimer");
+
+ if (!timerContainer) {
+ throw new Error("Player Display timer container was not found.");
+ }
+
+ /*
+ * Create one renderer for the Player Display.
+ *
+ * Overlay / Featured / Focus all reuse this
+ * same renderer. We simply change the outer
+ * layer classes.
+ */
+ this.timerRenderer = ENC.timerRenderer.create(timerContainer);
+
+ this.timerController = ENC.timer.create({
+ renderer: this.timerRenderer,
+
+ audience: "player",
+
+ initialState: {
+ id: "primary-timer",
+
+ name: "Countdown",
+
+ mode: "countdown",
+
+ skin: "hourglass",
+
+ durationMs: ENC.timer.DEFAULT_DURATION_MS || 300000,
+
+ remainingMs: ENC.timer.DEFAULT_DURATION_MS || 300000,
+
+ endAt: null,
+
+ status: "idle",
+
+ visibleToPlayers: false,
+
+ showNumericToDM: true,
+
+ showNumericToPlayers: false,
+
+ displayMode: "overlay",
+ },
+
+ /*
+ * The Player Display never owns timer
+ * state and never broadcasts changes.
+ *
+ * The DM Console is authoritative.
+ */
+ onComplete: () => {
+ this.updateTimerLayer();
+ },
+ });
+
+ this.updateTimerLayer();
+ },
+
+ /* ----------------------------------------
+ APPLY TIMER STATE FROM DM
+ ---------------------------------------- */
+
+ applyTimerState(timerState) {
+ if (!this.timerController || !timerState) {
+ return;
+ }
- overlay.classList.toggle("show", this.paused || this.awaitingDmState);
+ /*
+ * applyRemoteState() updates the Player
+ * timer without causing another broadcast.
+ *
+ * If the timer is running, the shared endAt
+ * value allows this window to independently
+ * calculate the correct remaining time.
+ */
+ this.timerController.applyRemoteState(timerState);
+
+ this.updateTimerLayer();
},
+ /* ----------------------------------------
+ SHOW / HIDE PLAYER TIMER
+ ---------------------------------------- */
+
+ updateTimerLayer() {
+ const layer = document.getElementById("playerTimerLayer");
+
+ if (!layer) {
+ return;
+ }
+
+ const state = this.timerController?.getState() || null;
+
+ /*
+ * Timer visibility requires all three:
+ *
+ * 1. DM selected Show Timer to Players.
+ * 2. Player Display itself is not hidden.
+ * 3. Initial DM state has been received.
+ */
+ const visible =
+ Boolean(state?.visibleToPlayers) && !this.paused && !this.awaitingDmState;
+
+ const mode = state?.displayMode || "overlay";
+
+ /*
+ * Remove all prior mode classes.
+ *
+ * The exact timer visual remains mounted
+ * inside #playerTimer.
+ */
+ layer.className = "";
+
+ /* --------------------------------------
+ FOCUS MODE
+ -------------------------------------- */
+
+ if (mode === "focus") {
+ layer.classList.add("player-timer-focus");
+
+ if (visible) {
+ layer.classList.add("show");
+ }
+ } else {
+ /* --------------------------------------
+ OVERLAY / FEATURED MODE
+ -------------------------------------- */
+ layer.classList.add("player-timer-overlay");
+
+ if (mode === "featured") {
+ layer.classList.add("timer-mode-featured");
+ }
+
+ if (!visible) {
+ layer.classList.add("timer-overlay-hidden");
+ }
+ }
+
+ /*
+ * The hidden property prevents an invisible
+ * timer from intercepting mouse events or
+ * occupying accessible UI.
+ */
+ layer.hidden = !visible;
+
+ layer.setAttribute("aria-hidden", visible ? "false" : "true");
+ },
+
+ /* ========================================
+ REQUEST DM PRESENCE
+ ======================================== */
+
requestDmPresence() {
- ENC.sync.broadcast("display-hello", { sentAt: Date.now() });
+ ENC.sync.broadcast("display-hello", {
+ sentAt: Date.now(),
+ });
},
startConnectionMonitor() {
this.setConnectionState("connecting");
+
this.requestDmPresence();
clearInterval(this.connectionTimer);
+
this.connectionTimer = setInterval(() => {
this.requestDmPresence();
+
const age = this.lastDmSeenAt ? Date.now() - this.lastDmSeenAt : Infinity;
+
if (age > ENC.DM_PRESENCE_TIMEOUT_MS) {
this.setConnectionState("disconnected");
}
@@ -158,46 +352,53 @@ ENC.display = {
======================================== */
render() {
+ /*
+ * Do not render encounter changes behind
+ * the DM-controlled privacy screen.
+ */
if (this.paused || this.awaitingDmState) {
return;
}
+
/* ----------------------------------------
- No active encounter
- ---------------------------------------- */
+ No active encounter
+ ---------------------------------------- */
if (!this.encounter) {
this.renderStandby();
+
return;
}
/* ----------------------------------------
- Make sure combatants array exists
- ---------------------------------------- */
+ Make sure combatants array exists
+ ---------------------------------------- */
if (!Array.isArray(this.encounter.combatants)) {
this.encounter.combatants = [];
}
/* ----------------------------------------
- Sort initiative
- ---------------------------------------- */
+ Sort initiative
+ ---------------------------------------- */
ENC.sortCombatants(this.encounter.combatants);
/* ----------------------------------------
- Determine player-visible state
- ---------------------------------------- */
+ Determine player-visible state
+ ---------------------------------------- */
const visibleCombatants = ENC.getPlayerVisibleCombatants(
this.encounter.combatants,
);
+
const hasVisibleCombatants = visibleCombatants.length > 0;
this.updateIdleLogo(hasVisibleCombatants);
/* ----------------------------------------
- Encounter header
- ---------------------------------------- */
+ Encounter header
+ ---------------------------------------- */
document.getElementById("displayEncounterName").textContent =
this.encounter.name || "New Encounter";
@@ -211,8 +412,8 @@ ENC.display = {
this.encounter.round || 1;
/* ----------------------------------------
- Encounter background
- ---------------------------------------- */
+ Encounter background
+ ---------------------------------------- */
const background = document.getElementById("displayBackground");
@@ -223,16 +424,16 @@ ENC.display = {
}
/* ----------------------------------------
- Initiative track
- ---------------------------------------- */
+ Initiative track
+ ---------------------------------------- */
const track = document.getElementById("initiativeTrack");
track.innerHTML = "";
/* ----------------------------------------
- No combatants yet
- ---------------------------------------- */
+ No combatants yet
+ ---------------------------------------- */
if (!hasVisibleCombatants) {
document.getElementById("currentName").textContent =
@@ -244,21 +445,21 @@ ENC.display = {
}
/* ----------------------------------------
- Find current combatant
- ---------------------------------------- */
+ Find current combatant
+ ---------------------------------------- */
const currentIndex = this.encounter.combatants.findIndex(
(combatant) => combatant.id === this.encounter.currentId,
);
/* ========================================
- BUILD INITIATIVE CARDS
- ======================================== */
+ BUILD INITIATIVE CARDS
+ ======================================== */
for (const combatant of visibleCombatants) {
/* ------------------------------------
- Card
- ------------------------------------ */
+ Card
+ ------------------------------------ */
const card = document.createElement("article");
@@ -266,8 +467,8 @@ ENC.display = {
`initiative-card ` + `${combatant.type} ` + `${combatant.combatState}`;
/* ------------------------------------
- Current turn highlighting
- ------------------------------------ */
+ Current turn highlighting
+ ------------------------------------ */
if (
combatant.id === this.encounter.currentId &&
@@ -277,8 +478,8 @@ ENC.display = {
}
/* ------------------------------------
- Portrait
- ------------------------------------ */
+ Portrait
+ ------------------------------------ */
const portrait = document.createElement("div");
@@ -297,8 +498,8 @@ ENC.display = {
}
/* ------------------------------------
- Name
- ------------------------------------ */
+ Name
+ ------------------------------------ */
const name = document.createElement("div");
@@ -307,8 +508,8 @@ ENC.display = {
name.textContent = combatant.name;
/* ------------------------------------
- Initiative
- ------------------------------------ */
+ Initiative
+ ------------------------------------ */
const initiative = document.createElement("div");
@@ -323,8 +524,8 @@ ENC.display = {
initiative.append(document.createTextNode(combatant.initiative));
/* ------------------------------------
- Status badges
- ------------------------------------ */
+ Status badges
+ ------------------------------------ */
const badges = document.createElement("div");
@@ -380,8 +581,8 @@ ENC.display = {
}
/* ------------------------------------
- Assemble card
- ------------------------------------ */
+ Assemble card
+ ------------------------------------ */
card.append(portrait, name, initiative, badges);
@@ -389,11 +590,12 @@ ENC.display = {
}
/* ========================================
- CURRENT TURN
- ======================================== */
+ CURRENT TURN
+ ======================================== */
const current =
currentIndex >= 0 ? this.encounter.combatants[currentIndex] : null;
+
const currentIsVisible =
ENC.isPlayerVisibleCombatant(current) && this.isTurnEligible(current);
@@ -404,8 +606,8 @@ ENC.display = {
: "Waiting for combat…";
/* ========================================
- NEXT TURN
- ======================================== */
+ NEXT TURN
+ ======================================== */
const next =
currentIndex >= 0 ? this.nextActive(this.encounter.currentId) : null;
@@ -413,8 +615,8 @@ ENC.display = {
document.getElementById("nextName").textContent = next ? next.name : "—";
/* ========================================
- CENTER CURRENT CARD
- ======================================== */
+ CENTER CURRENT CARD
+ ======================================== */
setTimeout(() => {
document.querySelector(".initiative-card.current")?.scrollIntoView({
@@ -431,20 +633,21 @@ ENC.display = {
bind() {
/* ----------------------------------------
- DM -> Player synchronization
- ---------------------------------------- */
+ DM -> Player synchronization
+ ---------------------------------------- */
ENC.sync.onMessage((message) => {
- // ========================================
+ // =====================================
// DM PRESENCE / INITIAL DISPLAY STATE
- // ========================================
+ // =====================================
if (message.type === "dm-presence") {
this.markDmSeen();
/*
- * We now know whether the DM wants the
- * Player Display visible or hidden.
+ * We now know whether the DM wants
+ * the Player Display visible or
+ * hidden.
*/
this.awaitingDmState = false;
@@ -454,6 +657,18 @@ ENC.display = {
this.setPaused(Boolean(message.playerDisplayPaused));
+ /*
+ * main.js will include the current
+ * timer state in dm-presence.
+ *
+ * This allows a Player Display opened
+ * halfway through an active countdown
+ * to immediately catch up.
+ */
+ if (message.timer) {
+ this.applyTimerState(message.timer);
+ }
+
if (!this.paused) {
this.render();
}
@@ -461,9 +676,9 @@ ENC.display = {
return;
}
- // ========================================
+ // =====================================
// HIDE / SHOW PLAYER DISPLAY
- // ========================================
+ // =====================================
if (message.type === "player-display-visibility") {
this.markDmSeen();
@@ -471,8 +686,9 @@ ENC.display = {
this.awaitingDmState = false;
/*
- * main.js includes the newest encounter
- * when changing display visibility.
+ * main.js includes the newest
+ * encounter when changing display
+ * visibility.
*/
if (message.encounter) {
this.encounter = ENC.normalizeEncounter(message.encounter);
@@ -480,6 +696,15 @@ ENC.display = {
this.setPaused(Boolean(message.paused));
+ /*
+ * Refresh the timer from the DM's
+ * authoritative state whenever the
+ * Player Display is hidden or shown.
+ */
+ if (message.timer) {
+ this.applyTimerState(message.timer);
+ }
+
/*
* When Show Player Display is pressed,
* immediately render the newest state.
@@ -491,9 +716,23 @@ ENC.display = {
return;
}
- // ========================================
+ // =====================================
+ // VISUAL TIMER UPDATES
+ // =====================================
+
+ if (message.type === "timer-state") {
+ this.markDmSeen();
+
+ if (message.timer) {
+ this.applyTimerState(message.timer);
+ }
+
+ return;
+ }
+
+ // =====================================
// NORMAL ENCOUNTER UPDATES
- // ========================================
+ // =====================================
if (message.type === "encounter-updated") {
this.markDmSeen();
@@ -505,8 +744,8 @@ ENC.display = {
this.encounter = ENC.normalizeEncounter(message.encounter || {});
/*
- * But never render private changes while
- * the privacy screen is active.
+ * But never render private changes
+ * while the privacy screen is active.
*/
if (!this.paused && !this.awaitingDmState) {
this.render();
@@ -519,8 +758,8 @@ ENC.display = {
this.startConnectionMonitor();
/* ----------------------------------------
- Fullscreen
- ---------------------------------------- */
+ Fullscreen
+ ---------------------------------------- */
document
.getElementById("fullscreenBtn")
@@ -540,12 +779,20 @@ ENC.display = {
document.addEventListener("DOMContentLoaded", async () => {
const versionElement = document.getElementById("playerAppVersion");
+
if (versionElement) {
- versionElement.textContent = `${ENC.APP_STAGE.toUpperCase()} · v${ENC.APP_VERSION}`;
+ versionElement.textContent =
+ `${ENC.APP_STAGE.toUpperCase()} · ` + `v${ENC.APP_VERSION}`;
}
await ENC.db.open();
+ /*
+ * Timer modules must already be loaded by
+ * display.html before display.js executes.
+ */
+ ENC.display.initializeTimer();
+
ENC.display.bind();
await ENC.display.load();
diff --git a/programs/app/js/main.js b/programs/app/js/main.js
index ed6d005..35954c9 100644
--- a/programs/app/js/main.js
+++ b/programs/app/js/main.js
@@ -10,6 +10,8 @@ ENC.app = {
playerDisplayConnected: false,
playerDisplayWindow: null,
playerDisplayWatchTimer: null,
+ timerPreviewRenderer: null,
+ timerController: null,
toast(message) {
const toast = document.getElementById("toast");
@@ -28,11 +30,55 @@ ENC.app = {
announceDmPresence(includeEncounter = false) {
ENC.sync.broadcast("dm-presence", {
sentAt: Date.now(),
+
playerDisplayPaused: this.playerDisplayPaused,
+
encounter:
includeEncounter && ENC.combat?.encounter
? ENC.deepClone(ENC.combat.encounter)
: null,
+
+ /*
+ * Include the current timer state so a
+ * Player Display opened after the timer
+ * has already started immediately joins
+ * at the correct point.
+ */
+ timer: this.getTimerSyncState(),
+ });
+ },
+
+ getTimerSyncState() {
+ if (!this.timerController) {
+ return null;
+ }
+
+ const state = this.timerController.getState();
+
+ if (!state) {
+ return null;
+ }
+
+ /*
+ * Timer state only contains plain data,
+ * but clone it so the synchronization
+ * layer never shares a mutable reference
+ * with the DM timer controller.
+ */
+ return ENC.deepClone(state);
+ },
+
+ broadcastTimerState(timerState = null) {
+ const state = timerState || this.getTimerSyncState();
+
+ if (!state) {
+ return;
+ }
+
+ ENC.sync.broadcast("timer-state", {
+ sentAt: Date.now(),
+
+ timer: ENC.deepClone(state),
});
},
@@ -83,6 +129,432 @@ ENC.app = {
}, 1000);
},
+ // ========================================
+ // VISUAL TIMER
+ // ========================================
+
+ getTimerDurationFromInputs() {
+ const minutesInput = document.getElementById("timerMinutes");
+ const secondsInput = document.getElementById("timerSeconds");
+
+ if (!minutesInput || !secondsInput) {
+ return 0;
+ }
+
+ const rawMinutes = Number(minutesInput.value);
+ const rawSeconds = Number(secondsInput.value);
+
+ const minutes = Number.isFinite(rawMinutes)
+ ? Math.max(0, Math.floor(rawMinutes))
+ : 0;
+
+ const seconds = Number.isFinite(rawSeconds)
+ ? Math.min(59, Math.max(0, Math.floor(rawSeconds)))
+ : 0;
+
+ minutesInput.value = String(minutes);
+ secondsInput.value = String(seconds);
+
+ return (minutes * 60 + seconds) * 1000;
+ },
+
+ setTimerDurationInputs(milliseconds) {
+ const minutesInput = document.getElementById("timerMinutes");
+ const secondsInput = document.getElementById("timerSeconds");
+
+ if (!minutesInput || !secondsInput) {
+ return;
+ }
+
+ const totalSeconds = Math.max(
+ 0,
+ Math.ceil(Number(milliseconds || 0) / 1000),
+ );
+
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+
+ minutesInput.value = String(minutes);
+ secondsInput.value = String(seconds);
+ },
+
+ formatTimerStatus(state) {
+ if (!state) {
+ return "Unavailable";
+ }
+
+ if (state.status === "running") {
+ return "Running";
+ }
+
+ if (state.status === "paused") {
+ return "Paused";
+ }
+
+ if (state.status === "complete") {
+ return "Complete";
+ }
+
+ if (state.status === "idle" && state.remainingMs < state.durationMs) {
+ return "Stopped";
+ }
+
+ return "Ready";
+ },
+
+ updateTimerUI() {
+ if (!this.timerController) {
+ return;
+ }
+
+ const state = this.timerController.getState();
+
+ if (!state) {
+ return;
+ }
+
+ const activeName = document.getElementById("timerActiveName");
+ const statusText = document.getElementById("timerStatusText");
+
+ if (activeName) {
+ activeName.textContent = state.name || "Countdown";
+ }
+
+ if (statusText) {
+ statusText.textContent = this.formatTimerStatus(state);
+ }
+
+ const startButton = document.getElementById("timerStartBtn");
+ const pauseButton = document.getElementById("timerPauseBtn");
+ const resumeButton = document.getElementById("timerResumeBtn");
+ const resetButton = document.getElementById("timerResetBtn");
+ const stopButton = document.getElementById("timerStopBtn");
+
+ if (startButton) {
+ startButton.disabled =
+ state.status === "running" || state.status === "paused";
+ }
+
+ if (pauseButton) {
+ pauseButton.disabled = state.status !== "running";
+ }
+
+ if (resumeButton) {
+ resumeButton.disabled = state.status !== "paused";
+ }
+
+ if (resetButton) {
+ resetButton.disabled = false;
+ }
+
+ if (stopButton) {
+ stopButton.disabled =
+ state.status === "idle" || state.status === "complete";
+ }
+
+ const durationLocked =
+ state.status === "running" || state.status === "paused";
+
+ const minutesInput = document.getElementById("timerMinutes");
+ const secondsInput = document.getElementById("timerSeconds");
+
+ if (minutesInput) {
+ minutesInput.disabled = durationLocked;
+ }
+
+ if (secondsInput) {
+ secondsInput.disabled = durationLocked;
+ }
+
+ /*
+ * Keep the configured duration fields aligned with the timer
+ * engine. Adding time can expand durationMs when the new
+ * remaining time exceeds the original configured duration.
+ */
+ if (!durationLocked) {
+ this.setTimerDurationInputs(state.durationMs);
+ }
+ },
+
+ initializeTimer() {
+ if (!ENC.timerRenderer || typeof ENC.timerRenderer.create !== "function") {
+ throw new Error(
+ "Timer renderer did not load. Check js/modules/timer-renderer.js.",
+ );
+ }
+
+ if (!ENC.timer || typeof ENC.timer.create !== "function") {
+ throw new Error("Timer engine did not load. Check js/modules/timer.js.");
+ }
+
+ const previewContainer = document.getElementById("timerPreview");
+
+ if (!previewContainer) {
+ throw new Error("Timer preview container was not found.");
+ }
+
+ const initialDuration =
+ this.getTimerDurationFromInputs() ||
+ ENC.timer.DEFAULT_DURATION_MS ||
+ 300000;
+
+ this.timerPreviewRenderer = ENC.timerRenderer.create(previewContainer);
+
+ this.timerController = ENC.timer.create({
+ renderer: this.timerPreviewRenderer,
+ audience: "dm",
+
+ initialState: {
+ id: "primary-timer",
+ name: document.getElementById("timerName")?.value.trim() || "Countdown",
+ mode: "countdown",
+ skin: document.getElementById("timerSkin")?.value || "hourglass",
+ durationMs: initialDuration,
+ remainingMs: initialDuration,
+ status: "idle",
+ visibleToPlayers:
+ document.getElementById("timerShowPlayers")?.checked === true,
+ showNumericToDM:
+ document.getElementById("timerShowNumericDM")?.checked !== false,
+ showNumericToPlayers:
+ document.getElementById("timerShowNumericPlayers")?.checked === true,
+ displayMode:
+ document.getElementById("timerDisplayMode")?.value || "overlay",
+ },
+
+ onStateChange: (timerState, reason) => {
+ /*
+ * Keep the DM controls/status synchronized
+ * with the timer controller.
+ */
+ this.updateTimerUI();
+
+ /*
+ * Send state changes to the Player Display.
+ *
+ * We do NOT transmit animation frames.
+ * The Player Display animates independently
+ * using the shared endAt timestamp.
+ */
+ this.broadcastTimerState(timerState);
+
+ console.debug("Timer state changed:", reason, timerState);
+ },
+
+ onComplete: (timerState) => {
+ this.updateTimerUI();
+
+ /*
+ * Ensure the Player Display receives the
+ * final completed state immediately.
+ */
+ this.broadcastTimerState(timerState);
+
+ this.toast("Timer complete.");
+ },
+ });
+
+ this.updateTimerUI();
+ },
+
+ bindTimerControls() {
+ if (!this.timerController) {
+ return;
+ }
+
+ const timerName = document.getElementById("timerName");
+ const timerSkin = document.getElementById("timerSkin");
+ const timerDisplayMode = document.getElementById("timerDisplayMode");
+
+ const showPlayers = document.getElementById("timerShowPlayers");
+ const showNumericPlayers = document.getElementById(
+ "timerShowNumericPlayers",
+ );
+ const showNumericDM = document.getElementById("timerShowNumericDM");
+
+ const minutesInput = document.getElementById("timerMinutes");
+ const secondsInput = document.getElementById("timerSeconds");
+
+ const startButton = document.getElementById("timerStartBtn");
+ const pauseButton = document.getElementById("timerPauseBtn");
+ const resumeButton = document.getElementById("timerResumeBtn");
+ const resetButton = document.getElementById("timerResetBtn");
+ const stopButton = document.getElementById("timerStopBtn");
+
+ const subtractMinuteButton = document.getElementById(
+ "timerSubtractMinuteBtn",
+ );
+ const subtractTenButton = document.getElementById("timerSubtractTenBtn");
+ const addTenButton = document.getElementById("timerAddTenBtn");
+ const addMinuteButton = document.getElementById("timerAddMinuteBtn");
+
+ const applyDurationIfEditable = () => {
+ const state = this.timerController.getState();
+
+ if (!state) {
+ return false;
+ }
+
+ if (state.status === "running" || state.status === "paused") {
+ return false;
+ }
+
+ const durationMs = this.getTimerDurationFromInputs();
+
+ if (durationMs <= 0) {
+ this.toast("Timer duration must be greater than zero.");
+ this.setTimerDurationInputs(state.durationMs);
+ return false;
+ }
+
+ if (durationMs !== state.durationMs) {
+ this.timerController.setDuration(durationMs);
+ }
+
+ return true;
+ };
+
+ if (timerName) {
+ timerName.addEventListener("input", () => {
+ this.timerController.setName(timerName.value);
+ });
+ }
+
+ if (timerSkin) {
+ timerSkin.addEventListener("change", () => {
+ this.timerController.setSkin(timerSkin.value);
+ });
+ }
+
+ if (timerDisplayMode) {
+ timerDisplayMode.addEventListener("change", () => {
+ this.timerController.setDisplayMode(timerDisplayMode.value);
+ });
+ }
+
+ if (showPlayers) {
+ showPlayers.addEventListener("change", () => {
+ this.timerController.setVisibleToPlayers(showPlayers.checked);
+ });
+ }
+
+ if (showNumericPlayers) {
+ showNumericPlayers.addEventListener("change", () => {
+ this.timerController.setShowNumericToPlayers(
+ showNumericPlayers.checked,
+ );
+ });
+ }
+
+ if (showNumericDM) {
+ showNumericDM.addEventListener("change", () => {
+ this.timerController.setShowNumericToDM(showNumericDM.checked);
+ });
+ }
+
+ if (minutesInput) {
+ minutesInput.addEventListener("change", applyDurationIfEditable);
+ }
+
+ if (secondsInput) {
+ secondsInput.addEventListener("change", applyDurationIfEditable);
+ }
+
+ if (startButton) {
+ startButton.addEventListener("click", () => {
+ const state = this.timerController.getState();
+
+ if (!state) {
+ return;
+ }
+
+ if (state.status === "paused") {
+ return;
+ }
+
+ /*
+ * If the configured duration fields changed while the
+ * timer was idle/complete, apply them before starting.
+ *
+ * A stopped timer preserves its remaining time when the
+ * duration fields still match the configured duration.
+ */
+ const requestedDuration = this.getTimerDurationFromInputs();
+
+ if (requestedDuration <= 0) {
+ this.toast("Timer duration must be greater than zero.");
+ return;
+ }
+
+ if (
+ state.status === "complete" ||
+ requestedDuration !== state.durationMs
+ ) {
+ this.timerController.setDuration(requestedDuration);
+ }
+
+ this.timerController.start();
+ this.updateTimerUI();
+ });
+ }
+
+ if (pauseButton) {
+ pauseButton.addEventListener("click", () => {
+ this.timerController.pause();
+ this.updateTimerUI();
+ });
+ }
+
+ if (resumeButton) {
+ resumeButton.addEventListener("click", () => {
+ this.timerController.resume();
+ this.updateTimerUI();
+ });
+ }
+
+ if (resetButton) {
+ resetButton.addEventListener("click", () => {
+ this.timerController.reset();
+ this.updateTimerUI();
+ });
+ }
+
+ if (stopButton) {
+ stopButton.addEventListener("click", () => {
+ this.timerController.stop();
+ this.updateTimerUI();
+ });
+ }
+
+ if (subtractMinuteButton) {
+ subtractMinuteButton.addEventListener("click", () => {
+ this.timerController.subtractTime(60_000);
+ this.updateTimerUI();
+ });
+ }
+
+ if (subtractTenButton) {
+ subtractTenButton.addEventListener("click", () => {
+ this.timerController.subtractTime(10_000);
+ this.updateTimerUI();
+ });
+ }
+
+ if (addTenButton) {
+ addTenButton.addEventListener("click", () => {
+ this.timerController.addTime(10_000);
+ this.updateTimerUI();
+ });
+ }
+
+ if (addMinuteButton) {
+ addMinuteButton.addEventListener("click", () => {
+ this.timerController.addTime(60_000);
+ this.updateTimerUI();
+ });
+ }
+ },
+
bindAppControls() {
// ========================================
// PLAYER DISPLAY
@@ -137,6 +609,8 @@ ENC.app = {
encounter: ENC.combat?.encounter
? ENC.deepClone(ENC.combat.encounter)
: null,
+
+ timer: this.getTimerSyncState(),
});
this.updatePlayerDisplayButton();
@@ -620,6 +1094,10 @@ ENC.app = {
this.bindNavigation();
+ this.initializeTimer();
+
+ this.bindTimerControls();
+
this.bindAppControls();
ENC.libraryUI.bind();
diff --git a/programs/app/js/modules/timer-renderer.js b/programs/app/js/modules/timer-renderer.js
new file mode 100644
index 0000000..1ae96ed
--- /dev/null
+++ b/programs/app/js/modules/timer-renderer.js
@@ -0,0 +1,819 @@
+/*
+ * Copyright (C) 2026 Zygons
+ * SPDX-License-Identifier: MIT
+ * This file is part of enCounter. See LICENSE for the full license terms.
+ */
+
+/*
+ * ============================================================
+ * enCounter Timer Renderer
+ * ============================================================
+ *
+ * Responsibilities:
+ *
+ * - Render timer skins.
+ * - Update visual progress.
+ * - Update optional numeric countdown.
+ * - Apply running / paused / warning / critical / complete states.
+ * - Render:
+ * Fantasy Hourglass
+ * Candle
+ * Sci-Fi Pixel Depletion
+ *
+ * This module DOES NOT keep track of time.
+ *
+ * timer.js is responsible for:
+ *
+ * - starting timers
+ * - pausing timers
+ * - resuming timers
+ * - resetting timers
+ * - calculating remaining milliseconds
+ * - calculating remainingRatio
+ *
+ * remainingRatio:
+ *
+ * 1.00 = all time remaining
+ * 0.50 = halfway
+ * 0.00 = complete
+ *
+ * ============================================================
+ */
+
+window.ENC = window.ENC || {};
+
+(function (ENC) {
+ "use strict";
+
+ const TIMER_RENDERER_VERSION = "1.0";
+
+ const DEFAULT_SKIN = "hourglass";
+
+ const SUPPORTED_SKINS = new Set(["hourglass", "candle", "scifi-pixels"]);
+
+ const SCI_FI_PIXEL_COUNT = 64;
+
+ /*
+ * Warning thresholds.
+ *
+ * 25% remaining -> warning
+ * 10% remaining -> critical
+ */
+ const WARNING_RATIO = 0.25;
+ const CRITICAL_RATIO = 0.1;
+
+ /*
+ * Keep track of renderer instances.
+ *
+ * This allows the DM Console and Player Display to each have
+ * their own renderer while using the same rendering engine.
+ */
+ const renderers = new Set();
+
+ /* ============================================================
+ UTILITY FUNCTIONS
+ ============================================================ */
+
+ function clamp(value, min, max) {
+ const number = Number(value);
+
+ if (!Number.isFinite(number)) {
+ return min;
+ }
+
+ return Math.min(max, Math.max(min, number));
+ }
+
+ function normalizeRatio(value) {
+ return clamp(value, 0, 1);
+ }
+
+ function normalizeSkin(skin) {
+ if (SUPPORTED_SKINS.has(skin)) {
+ return skin;
+ }
+
+ return DEFAULT_SKIN;
+ }
+
+ function normalizeStatus(status) {
+ switch (status) {
+ case "running":
+ case "paused":
+ case "complete":
+ case "idle":
+ return status;
+
+ default:
+ return "idle";
+ }
+ }
+
+ function formatTime(milliseconds) {
+ let value = Number(milliseconds);
+
+ if (!Number.isFinite(value) || value < 0) {
+ value = 0;
+ }
+
+ /*
+ * Use Math.ceil so a timer with 0.4 seconds remaining
+ * still displays 00:01 rather than prematurely showing
+ * 00:00.
+ */
+ const totalSeconds = Math.ceil(value / 1000);
+
+ const hours = Math.floor(totalSeconds / 3600);
+
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+
+ const seconds = totalSeconds % 60;
+
+ const paddedSeconds = String(seconds).padStart(2, "0");
+
+ if (hours > 0) {
+ return (
+ String(hours).padStart(2, "0") +
+ ":" +
+ String(minutes).padStart(2, "0") +
+ ":" +
+ paddedSeconds
+ );
+ }
+
+ return String(minutes).padStart(2, "0") + ":" + paddedSeconds;
+ }
+
+ function createElement(tag, className, text) {
+ const element = document.createElement(tag);
+
+ if (className) {
+ element.className = className;
+ }
+
+ if (text !== undefined && text !== null) {
+ element.textContent = text;
+ }
+
+ return element;
+ }
+
+ /*
+ * Deterministic shuffle for Sci-Fi pixels.
+ *
+ * We want pixels to disappear in a visually irregular order,
+ * but they must always disappear in the SAME order so the
+ * visual is directly tied to remaining time.
+ *
+ * This is intentionally deterministic rather than random.
+ */
+ function createPixelDecayOrder(count) {
+ const values = [];
+
+ for (let i = 0; i < count; i += 1) {
+ values.push(i);
+ }
+
+ let seed = 0x5eed1234;
+
+ function nextRandom() {
+ seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
+
+ return seed / 4294967296;
+ }
+
+ for (let i = values.length - 1; i > 0; i -= 1) {
+ const j = Math.floor(nextRandom() * (i + 1));
+
+ const temp = values[i];
+
+ values[i] = values[j];
+ values[j] = temp;
+ }
+
+ return values;
+ }
+
+ const SCI_FI_DECAY_ORDER = createPixelDecayOrder(SCI_FI_PIXEL_COUNT);
+
+ /* ============================================================
+ BASE TIMER STRUCTURE
+ ============================================================ */
+
+ function createBaseStructure(container) {
+ container.innerHTML = "";
+
+ const root = createElement("div", "timer-display timer-visible");
+
+ root.setAttribute("aria-label", "enCounter timer");
+
+ /*
+ * Timer label
+ */
+ const label = createElement("div", "timer-label");
+
+ /*
+ * Skin container
+ *
+ * The selected timer skin will be inserted here.
+ */
+ const skinHost = createElement("div", "timer-skin-host");
+
+ /*
+ * Optional numerical countdown.
+ */
+ const numeric = createElement(
+ "div",
+ "timer-numeric timer-numeric-hidden",
+ "00:00",
+ );
+
+ numeric.setAttribute("aria-live", "off");
+
+ root.appendChild(label);
+ root.appendChild(skinHost);
+ root.appendChild(numeric);
+
+ container.appendChild(root);
+
+ return {
+ root,
+ label,
+ skinHost,
+ numeric,
+ };
+ }
+
+ /* ============================================================
+ HOURGLASS SKIN
+ ============================================================ */
+
+ function createHourglassSkin() {
+ const hourglass = createElement("div", "timer-hourglass");
+
+ /*
+ * Outer wooden / fantasy frame.
+ */
+ const frame = createElement("div", "timer-hourglass-frame");
+
+ /*
+ * Glass chamber.
+ */
+ const glass = createElement("div", "timer-hourglass-glass");
+
+ /*
+ * Upper sand.
+ */
+ const topSand = createElement("div", "timer-hourglass-sand-top");
+
+ /*
+ * Lower collected sand.
+ */
+ const bottomSand = createElement("div", "timer-hourglass-sand-bottom");
+
+ /*
+ * Falling sand stream.
+ */
+ const stream = createElement("div", "timer-hourglass-stream");
+
+ /*
+ * Decorative sand particles.
+ */
+ const particles = createElement("div", "timer-hourglass-particles");
+
+ for (let i = 0; i < 3; i += 1) {
+ particles.appendChild(createElement("span", "timer-hourglass-particle"));
+ }
+
+ glass.appendChild(topSand);
+ glass.appendChild(bottomSand);
+ glass.appendChild(stream);
+ glass.appendChild(particles);
+
+ hourglass.appendChild(frame);
+ hourglass.appendChild(glass);
+
+ return {
+ element: hourglass,
+
+ refs: {
+ glass,
+ topSand,
+ bottomSand,
+ stream,
+ particles,
+ },
+ };
+ }
+
+ function updateHourglassSkin(skin, timer, remainingRatio) {
+ const status = normalizeStatus(timer.status);
+
+ skin.element.classList.toggle("timer-idle", status === "idle");
+
+ skin.element.classList.toggle("timer-paused", status === "paused");
+
+ skin.element.classList.toggle(
+ "timer-complete",
+ status === "complete" || remainingRatio <= 0,
+ );
+ }
+
+ /* ============================================================
+ CANDLE SKIN
+ ============================================================ */
+
+ function createCandleSkin() {
+ const candle = createElement("div", "timer-candle");
+
+ const base = createElement("div", "timer-candle-base");
+
+ /*
+ * Candle body.
+ *
+ * Flame, wick and glow are children of the body so they
+ * move downward naturally as the candle height decreases.
+ */
+ const body = createElement("div", "timer-candle-body");
+
+ const wick = createElement("div", "timer-candle-wick");
+
+ const glow = createElement("div", "timer-candle-glow");
+
+ const flameWrap = createElement("div", "timer-candle-flame-wrap");
+
+ const flame = createElement("div", "timer-candle-flame");
+
+ const smoke = createElement("div", "timer-candle-smoke");
+
+ flameWrap.appendChild(flame);
+
+ body.appendChild(glow);
+ body.appendChild(flameWrap);
+ body.appendChild(wick);
+ body.appendChild(smoke);
+
+ candle.appendChild(base);
+ candle.appendChild(body);
+
+ return {
+ element: candle,
+
+ refs: {
+ base,
+ body,
+ wick,
+ glow,
+ flameWrap,
+ flame,
+ smoke,
+ },
+ };
+ }
+
+ function updateCandleSkin(skin, timer, remainingRatio) {
+ /*
+ * CSS handles the actual height using:
+ *
+ * --timer-remaining
+ *
+ * This method is currently responsible only for
+ * skin-specific state if needed later.
+ */
+
+ const complete = timer.status === "complete" || remainingRatio <= 0;
+
+ skin.element.classList.toggle("timer-complete", complete);
+ }
+
+ /* ============================================================
+ SCI-FI PIXEL SKIN
+ ============================================================ */
+
+ function createSciFiSkin() {
+ const scifi = createElement("div", "timer-scifi");
+
+ /*
+ * Decorative animated scanning line.
+ */
+ const scan = createElement("div", "timer-scifi-scan");
+
+ const heading = createElement(
+ "div",
+ "timer-scifi-heading",
+ "TEMPORAL SEQUENCE",
+ );
+
+ const grid = createElement("div", "timer-scifi-grid");
+
+ grid.setAttribute("aria-hidden", "true");
+
+ const pixels = [];
+
+ for (let i = 0; i < SCI_FI_PIXEL_COUNT; i += 1) {
+ const pixel = createElement("span", "timer-scifi-pixel");
+
+ pixel.dataset.pixelIndex = String(i);
+
+ grid.appendChild(pixel);
+
+ pixels.push(pixel);
+ }
+
+ const footer = createElement("div", "timer-scifi-footer");
+
+ const footerLeft = createElement("span", "", "SEQ ACTIVE");
+
+ const footerRight = createElement("span", "", "ENC-TMR");
+
+ footer.appendChild(footerLeft);
+ footer.appendChild(footerRight);
+
+ scifi.appendChild(scan);
+ scifi.appendChild(heading);
+ scifi.appendChild(grid);
+ scifi.appendChild(footer);
+
+ return {
+ element: scifi,
+
+ refs: {
+ scan,
+ heading,
+ grid,
+ pixels,
+ footer,
+ footerLeft,
+ footerRight,
+ },
+
+ /*
+ * Store previous number of active pixels.
+ *
+ * This allows us to briefly animate a pixel when it
+ * changes from active to depleted.
+ */
+ previousActiveCount: SCI_FI_PIXEL_COUNT,
+ };
+ }
+
+ function updateSciFiSkin(skin, timer, remainingRatio) {
+ const pixels = skin.refs.pixels;
+
+ /*
+ * Convert the exact timer percentage into the number of
+ * illuminated pixels.
+ *
+ * Example with 64 pixels:
+ *
+ * 1.00 -> 64
+ * 0.75 -> 48
+ * 0.50 -> 32
+ * 0.25 -> 16
+ * 0.00 -> 0
+ */
+ const activeCount = Math.ceil(SCI_FI_PIXEL_COUNT * remainingRatio);
+
+ const activeIndexes = new Set(SCI_FI_DECAY_ORDER.slice(0, activeCount));
+
+ for (let i = 0; i < pixels.length; i += 1) {
+ const pixel = pixels[i];
+
+ const shouldBeActive = activeIndexes.has(i);
+
+ const wasActive = !pixel.classList.contains("off");
+
+ if (shouldBeActive) {
+ pixel.classList.remove("off", "decaying");
+ } else {
+ /*
+ * If this pixel just transitioned from ON to OFF,
+ * briefly apply the decay animation.
+ */
+ if (wasActive) {
+ pixel.classList.remove("decaying");
+
+ /*
+ * Force a reflow so repeatedly applying the
+ * animation works reliably.
+ */
+ void pixel.offsetWidth;
+
+ pixel.classList.add("decaying");
+
+ window.setTimeout(() => {
+ pixel.classList.remove("decaying");
+ }, 300);
+ }
+
+ pixel.classList.add("off");
+ }
+ }
+
+ skin.previousActiveCount = activeCount;
+
+ /*
+ * Footer status.
+ */
+ if (timer.status === "complete" || remainingRatio <= 0) {
+ skin.refs.footerLeft.textContent = "SEQUENCE ENDED";
+ } else if (timer.status === "paused") {
+ skin.refs.footerLeft.textContent = "SEQUENCE HOLD";
+ } else if (timer.status === "running") {
+ skin.refs.footerLeft.textContent = "SEQ ACTIVE";
+ } else {
+ skin.refs.footerLeft.textContent = "SEQ READY";
+ }
+ }
+
+ /* ============================================================
+ SKIN FACTORY
+ ============================================================ */
+
+ function createSkin(skinName) {
+ const normalizedSkin = normalizeSkin(skinName);
+
+ switch (normalizedSkin) {
+ case "candle":
+ return {
+ name: "candle",
+ ...createCandleSkin(),
+ };
+
+ case "scifi-pixels":
+ return {
+ name: "scifi-pixels",
+ ...createSciFiSkin(),
+ };
+
+ case "hourglass":
+ default:
+ return {
+ name: "hourglass",
+ ...createHourglassSkin(),
+ };
+ }
+ }
+
+ /* ============================================================
+ WARNING / CRITICAL STATE
+ ============================================================ */
+
+ function updateWarningState(root, timer, remainingRatio) {
+ root.classList.remove("timer-warning", "timer-critical");
+
+ if (timer.status === "complete" || remainingRatio <= 0) {
+ return;
+ }
+
+ if (timer.status !== "running" && timer.status !== "paused") {
+ return;
+ }
+
+ if (remainingRatio <= CRITICAL_RATIO) {
+ root.classList.add("timer-critical");
+
+ return;
+ }
+
+ if (remainingRatio <= WARNING_RATIO) {
+ root.classList.add("timer-warning");
+ }
+ }
+
+ /* ============================================================
+ TIMER ROOT STATE
+ ============================================================ */
+
+ function updateRootState(root, timer, remainingRatio) {
+ const status = normalizeStatus(timer.status);
+
+ root.classList.toggle("timer-paused", status === "paused");
+
+ root.classList.toggle(
+ "timer-complete",
+ status === "complete" || remainingRatio <= 0,
+ );
+
+ root.classList.toggle("timer-hidden", timer.visible === false);
+
+ root.classList.toggle("timer-visible", timer.visible !== false);
+
+ /*
+ * Update the CSS custom properties that drive the
+ * time-accurate visual animations.
+ */
+ root.style.setProperty("--timer-remaining", String(remainingRatio));
+
+ root.style.setProperty("--timer-elapsed", String(1 - remainingRatio));
+
+ updateWarningState(root, timer, remainingRatio);
+ }
+
+ /* ============================================================
+ CREATE RENDERER INSTANCE
+ ============================================================ */
+
+ function createRenderer(container) {
+ /*
+ * Allow either:
+ *
+ * ENC.timerRenderer.create("#timerPreview")
+ *
+ * or:
+ *
+ * ENC.timerRenderer.create(element)
+ */
+ let resolvedContainer = container;
+
+ if (typeof container === "string") {
+ resolvedContainer = document.querySelector(container);
+ }
+
+ if (!(resolvedContainer instanceof Element)) {
+ throw new Error("Timer renderer requires a valid container element.");
+ }
+
+ const structure = createBaseStructure(resolvedContainer);
+
+ let currentSkin = null;
+
+ let destroyed = false;
+
+ function installSkin(skinName) {
+ const normalizedSkin = normalizeSkin(skinName);
+
+ if (currentSkin && currentSkin.name === normalizedSkin) {
+ return currentSkin;
+ }
+
+ structure.skinHost.innerHTML = "";
+
+ currentSkin = createSkin(normalizedSkin);
+
+ structure.skinHost.appendChild(currentSkin.element);
+
+ return currentSkin;
+ }
+
+ function updateSkin(timer, remainingRatio) {
+ if (!currentSkin) {
+ return;
+ }
+
+ switch (currentSkin.name) {
+ case "hourglass":
+ updateHourglassSkin(currentSkin, timer, remainingRatio);
+ break;
+
+ case "candle":
+ updateCandleSkin(currentSkin, timer, remainingRatio);
+ break;
+
+ case "scifi-pixels":
+ updateSciFiSkin(currentSkin, timer, remainingRatio);
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ function render(timer = {}, remainingRatio = 1) {
+ if (destroyed) {
+ return;
+ }
+
+ const ratio = normalizeRatio(remainingRatio);
+
+ const skinName = normalizeSkin(timer.skin);
+
+ installSkin(skinName);
+
+ /*
+ * Label
+ */
+ structure.label.textContent = timer.name || timer.label || "";
+
+ structure.label.hidden = structure.label.textContent.trim() === "";
+
+ /*
+ * Numeric timer
+ */
+ const showNumeric =
+ timer.showNumeric === true || timer.showNumericToPlayers === true;
+
+ structure.numeric.classList.toggle("timer-numeric-hidden", !showNumeric);
+
+ if (showNumeric) {
+ const remainingMs = Number.isFinite(Number(timer.remainingMs))
+ ? Number(timer.remainingMs)
+ : Number(timer.durationMs || 0) * ratio;
+
+ structure.numeric.textContent = formatTime(remainingMs);
+ }
+
+ /*
+ * Root timer state.
+ */
+ updateRootState(structure.root, timer, ratio);
+
+ /*
+ * Skin-specific changes.
+ */
+ updateSkin(timer, ratio);
+ }
+
+ function clear() {
+ if (destroyed) {
+ return;
+ }
+
+ structure.label.textContent = "";
+
+ structure.numeric.textContent = "00:00";
+
+ structure.root.classList.remove(
+ "timer-warning",
+ "timer-critical",
+ "timer-paused",
+ "timer-complete",
+ );
+
+ structure.root.style.setProperty("--timer-remaining", "1");
+
+ structure.root.style.setProperty("--timer-elapsed", "0");
+
+ if (currentSkin) {
+ structure.skinHost.innerHTML = "";
+ currentSkin = null;
+ }
+ }
+
+ function setVisible(visible) {
+ if (destroyed) {
+ return;
+ }
+
+ structure.root.classList.toggle("timer-hidden", visible === false);
+
+ structure.root.classList.toggle("timer-visible", visible !== false);
+ }
+
+ function destroy() {
+ if (destroyed) {
+ return;
+ }
+
+ destroyed = true;
+
+ renderers.delete(api);
+
+ resolvedContainer.innerHTML = "";
+
+ currentSkin = null;
+ }
+
+ const api = {
+ render,
+ clear,
+ setVisible,
+ destroy,
+
+ get element() {
+ return structure.root;
+ },
+
+ get skin() {
+ return currentSkin ? currentSkin.name : null;
+ },
+ };
+
+ renderers.add(api);
+
+ return api;
+ }
+
+ /* ============================================================
+ PUBLIC API
+ ============================================================ */
+
+ ENC.timerRenderer = {
+ VERSION: TIMER_RENDERER_VERSION,
+
+ skins: Object.freeze(["hourglass", "candle", "scifi-pixels"]),
+
+ create: createRenderer,
+
+ formatTime,
+
+ normalizeRatio,
+
+ /*
+ * Primarily useful for debugging.
+ */
+ getRendererCount() {
+ return renderers.size;
+ },
+ };
+})(window.ENC);
diff --git a/programs/app/js/modules/timer.js b/programs/app/js/modules/timer.js
new file mode 100644
index 0000000..67cf931
--- /dev/null
+++ b/programs/app/js/modules/timer.js
@@ -0,0 +1,1161 @@
+/*
+ * Copyright (C) 2026 Zygons
+ * SPDX-License-Identifier: MIT
+ * This file is part of enCounter. See LICENSE for the full license terms.
+ */
+
+/*
+ * ============================================================
+ * enCounter Visual Timer Engine
+ * ============================================================
+ *
+ * Responsibilities:
+ *
+ * - Maintain timer state.
+ * - Start countdowns.
+ * - Pause countdowns.
+ * - Resume countdowns.
+ * - Reset countdowns.
+ * - Stop countdowns.
+ * - Add or subtract time.
+ * - Calculate accurate remaining time.
+ * - Drive timer-renderer.js.
+ * - Export/import timer state for Player Display synchronization.
+ *
+ * IMPORTANT:
+ *
+ * This module does NOT count downward by subtracting a fixed
+ * amount every second.
+ *
+ * Instead, a running timer stores an absolute ending timestamp:
+ *
+ * endAt = Date.now() + remainingMs
+ *
+ * Every rendered frame calculates:
+ *
+ * remainingMs = endAt - Date.now()
+ *
+ * This prevents timer drift and allows the Player Display to
+ * independently render the same timer accurately.
+ *
+ * ============================================================
+ */
+
+window.ENC = window.ENC || {};
+
+(function (ENC) {
+ "use strict";
+
+ const TIMER_VERSION = "1.0";
+
+ const DEFAULT_DURATION_MS = 5 * 60 * 1000;
+
+ const DEFAULT_STATE = Object.freeze({
+ id: "primary-timer",
+
+ name: "Countdown",
+
+ /*
+ * Only countdown is active in the first implementation.
+ *
+ * Future values may include:
+ *
+ * countup
+ * rounds
+ */
+ mode: "countdown",
+
+ /*
+ * Timer skin names must match timer-renderer.js.
+ */
+ skin: "hourglass",
+
+ /*
+ * Full configured duration.
+ */
+ durationMs: DEFAULT_DURATION_MS,
+
+ /*
+ * Amount of time currently remaining.
+ *
+ * While running this value is refreshed from endAt.
+ */
+ remainingMs: DEFAULT_DURATION_MS,
+
+ /*
+ * Absolute JavaScript timestamp for timer completion.
+ *
+ * null when idle, paused, stopped or complete.
+ */
+ endAt: null,
+
+ /*
+ * idle
+ * running
+ * paused
+ * complete
+ */
+ status: "idle",
+
+ /*
+ * Whether the timer is visible on the Player Display.
+ */
+ visibleToPlayers: false,
+
+ /*
+ * DM may always choose to see exact time in the preview.
+ */
+ showNumericToDM: true,
+
+ /*
+ * Whether players see exact numerical time.
+ */
+ showNumericToPlayers: false,
+
+ /*
+ * Initial display mode.
+ *
+ * overlay
+ * featured
+ * focus
+ */
+ displayMode: "overlay",
+ });
+
+ const VALID_SKINS = new Set(["hourglass", "candle", "scifi-pixels"]);
+
+ const VALID_STATUSES = new Set(["idle", "running", "paused", "complete"]);
+
+ const VALID_DISPLAY_MODES = new Set(["overlay", "featured", "focus"]);
+
+ /*
+ * Registry primarily useful for debugging and future cleanup.
+ */
+ const controllers = new Set();
+
+ /* ============================================================
+ GENERAL UTILITIES
+ ============================================================ */
+
+ function clamp(value, min, max) {
+ const number = Number(value);
+
+ if (!Number.isFinite(number)) {
+ return min;
+ }
+
+ return Math.min(max, Math.max(min, number));
+ }
+
+ function normalizeMilliseconds(value) {
+ const number = Number(value);
+
+ if (!Number.isFinite(number)) {
+ return 0;
+ }
+
+ return Math.max(0, Math.round(number));
+ }
+
+ function normalizeSkin(value) {
+ if (VALID_SKINS.has(value)) {
+ return value;
+ }
+
+ return DEFAULT_STATE.skin;
+ }
+
+ function normalizeStatus(value) {
+ if (VALID_STATUSES.has(value)) {
+ return value;
+ }
+
+ return "idle";
+ }
+
+ function normalizeDisplayMode(value) {
+ if (VALID_DISPLAY_MODES.has(value)) {
+ return value;
+ }
+
+ return DEFAULT_STATE.displayMode;
+ }
+
+ function createTimerId() {
+ if (window.crypto && typeof window.crypto.randomUUID === "function") {
+ return window.crypto.randomUUID();
+ }
+
+ return (
+ "timer-" + Date.now() + "-" + Math.random().toString(36).slice(2, 10)
+ );
+ }
+
+ function cloneState(state) {
+ return {
+ id: state.id,
+ name: state.name,
+ mode: state.mode,
+ skin: state.skin,
+
+ durationMs: state.durationMs,
+ remainingMs: state.remainingMs,
+
+ endAt: state.endAt,
+
+ status: state.status,
+
+ visibleToPlayers: state.visibleToPlayers,
+
+ showNumericToDM: state.showNumericToDM,
+
+ showNumericToPlayers: state.showNumericToPlayers,
+
+ displayMode: state.displayMode,
+ };
+ }
+
+ function sanitizeState(input = {}) {
+ let durationMs = normalizeMilliseconds(input.durationMs);
+
+ if (durationMs <= 0) {
+ durationMs = DEFAULT_STATE.durationMs;
+ }
+
+ let remainingMs;
+
+ if (input.remainingMs === undefined || input.remainingMs === null) {
+ remainingMs = durationMs;
+ } else {
+ remainingMs = normalizeMilliseconds(input.remainingMs);
+ }
+
+ /*
+ * A timer can have more remaining time than its original
+ * duration if the DM added time.
+ *
+ * If that occurs, expand duration to match.
+ */
+ if (remainingMs > durationMs) {
+ durationMs = remainingMs;
+ }
+
+ const status = normalizeStatus(input.status);
+
+ let endAt = null;
+
+ if (status === "running" && Number.isFinite(Number(input.endAt))) {
+ endAt = Number(input.endAt);
+ }
+
+ return {
+ id:
+ typeof input.id === "string" && input.id.trim()
+ ? input.id.trim()
+ : createTimerId(),
+
+ name:
+ typeof input.name === "string" ? input.name.trim() : DEFAULT_STATE.name,
+
+ mode: "countdown",
+
+ skin: normalizeSkin(input.skin),
+
+ durationMs,
+ remainingMs,
+
+ endAt,
+
+ status,
+
+ visibleToPlayers: input.visibleToPlayers === true,
+
+ showNumericToDM: input.showNumericToDM !== false,
+
+ showNumericToPlayers: input.showNumericToPlayers === true,
+
+ displayMode: normalizeDisplayMode(input.displayMode),
+ };
+ }
+
+ /* ============================================================
+ TIMER CONTROLLER
+ ============================================================ */
+
+ function createTimer(options = {}) {
+ /*
+ * Optional renderer created by timer-renderer.js.
+ *
+ * Example:
+ *
+ * const renderer =
+ * ENC.timerRenderer.create("#timerPreview");
+ *
+ * const timer =
+ * ENC.timer.create({
+ * renderer: renderer
+ * });
+ */
+ let renderer = options.renderer || null;
+
+ /*
+ * audience determines what the renderer should see.
+ *
+ * dm
+ * player
+ *
+ * DM preview is always visible.
+ *
+ * Player rendering respects visibleToPlayers.
+ */
+ const audience = options.audience === "player" ? "player" : "dm";
+
+ /*
+ * Optional integration hooks.
+ *
+ * We are deliberately NOT hard-wiring BroadcastChannel
+ * here yet.
+ *
+ * main.js / display.js can connect these hooks to the
+ * existing enCounter synchronization system.
+ */
+ const onStateChange =
+ typeof options.onStateChange === "function"
+ ? options.onStateChange
+ : null;
+
+ const onComplete =
+ typeof options.onComplete === "function" ? options.onComplete : null;
+
+ let state = sanitizeState({
+ ...DEFAULT_STATE,
+ ...(options.initialState || {}),
+ });
+
+ let animationFrameId = null;
+
+ let destroyed = false;
+
+ let completionNotified = false;
+
+ /* ========================================================
+ CURRENT TIME CALCULATION
+ ======================================================== */
+
+ function calculateRemainingMs() {
+ if (state.status !== "running") {
+ return state.remainingMs;
+ }
+
+ if (!Number.isFinite(state.endAt)) {
+ return state.remainingMs;
+ }
+
+ return Math.max(0, state.endAt - Date.now());
+ }
+
+ function syncRemainingFromClock() {
+ if (state.status !== "running") {
+ return state.remainingMs;
+ }
+
+ state.remainingMs = calculateRemainingMs();
+
+ return state.remainingMs;
+ }
+
+ function calculateRemainingRatio() {
+ const duration = Math.max(1, state.durationMs);
+
+ const remaining =
+ state.status === "running" ? calculateRemainingMs() : state.remainingMs;
+
+ return clamp(remaining / duration, 0, 1);
+ }
+
+ /* ========================================================
+ RENDER STATE
+ ======================================================== */
+
+ function createRenderState() {
+ syncRemainingFromClock();
+
+ /*
+ * The renderer uses the generic "visible" and
+ * "showNumeric" properties.
+ *
+ * Resolve those based on whether this controller is
+ * rendering the DM Console or Player Display.
+ */
+ return {
+ ...cloneState(state),
+
+ visible: audience === "player" ? state.visibleToPlayers : true,
+
+ showNumeric:
+ audience === "player"
+ ? state.showNumericToPlayers
+ : state.showNumericToDM,
+ };
+ }
+
+ function render() {
+ if (destroyed) {
+ return;
+ }
+
+ if (!renderer || typeof renderer.render !== "function") {
+ return;
+ }
+
+ const ratio = calculateRemainingRatio();
+
+ renderer.render(createRenderState(), ratio);
+ }
+
+ /* ========================================================
+ NOTIFICATIONS / SYNC HOOK
+ ======================================================== */
+
+ function notifyStateChange(reason) {
+ if (destroyed) {
+ return;
+ }
+
+ /*
+ * Make sure a running timer exports the most recent
+ * remaining time.
+ */
+ syncRemainingFromClock();
+
+ if (onStateChange) {
+ onStateChange(cloneState(state), reason);
+ }
+ }
+
+ /* ========================================================
+ ANIMATION LOOP
+ ======================================================== */
+
+ function cancelAnimationLoop() {
+ if (animationFrameId !== null) {
+ window.cancelAnimationFrame(animationFrameId);
+
+ animationFrameId = null;
+ }
+ }
+
+ function finishTimer() {
+ cancelAnimationLoop();
+
+ state.remainingMs = 0;
+ state.endAt = null;
+ state.status = "complete";
+
+ render();
+
+ notifyStateChange("complete");
+
+ if (!completionNotified && onComplete) {
+ completionNotified = true;
+
+ onComplete(cloneState(state));
+ }
+ }
+
+ function animationLoop() {
+ if (destroyed) {
+ return;
+ }
+
+ if (state.status !== "running") {
+ animationFrameId = null;
+ return;
+ }
+
+ const remaining = syncRemainingFromClock();
+
+ if (remaining <= 0) {
+ finishTimer();
+ return;
+ }
+
+ render();
+
+ animationFrameId = window.requestAnimationFrame(animationLoop);
+ }
+
+ function startAnimationLoop() {
+ cancelAnimationLoop();
+
+ if (state.status !== "running") {
+ return;
+ }
+
+ animationFrameId = window.requestAnimationFrame(animationLoop);
+ }
+
+ /* ========================================================
+ START
+ ======================================================== */
+
+ function start() {
+ if (destroyed) {
+ return false;
+ }
+
+ if (state.status === "running") {
+ return true;
+ }
+
+ /*
+ * Starting a completed timer begins it again from the
+ * configured duration.
+ */
+ if (state.status === "complete" || state.remainingMs <= 0) {
+ state.remainingMs = state.durationMs;
+ }
+
+ completionNotified = false;
+
+ state.endAt = Date.now() + state.remainingMs;
+
+ state.status = "running";
+
+ render();
+
+ notifyStateChange("start");
+
+ startAnimationLoop();
+
+ return true;
+ }
+
+ /* ========================================================
+ PAUSE
+ ======================================================== */
+
+ function pause() {
+ if (destroyed) {
+ return false;
+ }
+
+ if (state.status !== "running") {
+ return false;
+ }
+
+ state.remainingMs = calculateRemainingMs();
+
+ state.endAt = null;
+
+ state.status = "paused";
+
+ cancelAnimationLoop();
+
+ render();
+
+ notifyStateChange("pause");
+
+ return true;
+ }
+
+ /* ========================================================
+ RESUME
+ ======================================================== */
+
+ function resume() {
+ if (destroyed) {
+ return false;
+ }
+
+ if (state.status !== "paused") {
+ return false;
+ }
+
+ if (state.remainingMs <= 0) {
+ finishTimer();
+ return false;
+ }
+
+ completionNotified = false;
+
+ state.endAt = Date.now() + state.remainingMs;
+
+ state.status = "running";
+
+ render();
+
+ notifyStateChange("resume");
+
+ startAnimationLoop();
+
+ return true;
+ }
+
+ /* ========================================================
+ RESET
+ ======================================================== */
+
+ function reset() {
+ if (destroyed) {
+ return false;
+ }
+
+ cancelAnimationLoop();
+
+ state.remainingMs = state.durationMs;
+
+ state.endAt = null;
+
+ state.status = "idle";
+
+ completionNotified = false;
+
+ render();
+
+ notifyStateChange("reset");
+
+ return true;
+ }
+
+ /* ========================================================
+ STOP
+ ======================================================== */
+
+ function stop() {
+ if (destroyed) {
+ return false;
+ }
+
+ if (state.status === "running") {
+ state.remainingMs = calculateRemainingMs();
+ }
+
+ cancelAnimationLoop();
+
+ state.endAt = null;
+
+ /*
+ * Stop preserves the current remaining time.
+ *
+ * Reset restores the full configured duration.
+ */
+ state.status = "idle";
+
+ completionNotified = false;
+
+ render();
+
+ notifyStateChange("stop");
+
+ return true;
+ }
+
+ /* ========================================================
+ DURATION
+ ======================================================== */
+
+ function setDuration(milliseconds) {
+ if (destroyed) {
+ return false;
+ }
+
+ const newDuration = normalizeMilliseconds(milliseconds);
+
+ if (newDuration <= 0) {
+ return false;
+ }
+
+ cancelAnimationLoop();
+
+ state.durationMs = newDuration;
+
+ state.remainingMs = newDuration;
+
+ state.endAt = null;
+
+ state.status = "idle";
+
+ completionNotified = false;
+
+ render();
+
+ notifyStateChange("duration");
+
+ return true;
+ }
+
+ /* ========================================================
+ ADD / SUBTRACT TIME
+ ======================================================== */
+
+ function adjustTime(deltaMilliseconds) {
+ if (destroyed) {
+ return false;
+ }
+
+ const delta = Number(deltaMilliseconds);
+
+ if (!Number.isFinite(delta)) {
+ return false;
+ }
+
+ if (state.status === "running") {
+ state.remainingMs = calculateRemainingMs();
+ }
+
+ let newRemaining = state.remainingMs + delta;
+
+ newRemaining = Math.max(0, newRemaining);
+
+ /*
+ * If the DM adds enough time that the remaining value
+ * exceeds the original configured duration, expand the
+ * duration.
+ *
+ * Example:
+ *
+ * 10:00 timer
+ * currently 10:00
+ * DM presses +1 minute
+ *
+ * new duration = 11:00
+ * new remaining = 11:00
+ */
+ if (newRemaining > state.durationMs) {
+ state.durationMs = newRemaining;
+ }
+
+ state.remainingMs = newRemaining;
+
+ if (newRemaining <= 0) {
+ finishTimer();
+ return true;
+ }
+
+ if (state.status === "running") {
+ state.endAt = Date.now() + newRemaining;
+ } else {
+ state.endAt = null;
+
+ if (state.status === "complete") {
+ state.status = "paused";
+ }
+ }
+
+ completionNotified = false;
+
+ render();
+
+ notifyStateChange(delta >= 0 ? "add-time" : "subtract-time");
+
+ if (state.status === "running") {
+ startAnimationLoop();
+ }
+
+ return true;
+ }
+
+ function addTime(milliseconds) {
+ return adjustTime(Math.abs(Number(milliseconds) || 0));
+ }
+
+ function subtractTime(milliseconds) {
+ return adjustTime(-Math.abs(Number(milliseconds) || 0));
+ }
+
+ /* ========================================================
+ TIMER NAME
+ ======================================================== */
+
+ function setName(name) {
+ if (destroyed) {
+ return false;
+ }
+
+ state.name = typeof name === "string" ? name.trim() : "";
+
+ render();
+
+ notifyStateChange("name");
+
+ return true;
+ }
+
+ /* ========================================================
+ TIMER SKIN
+ ======================================================== */
+
+ function setSkin(skin) {
+ if (destroyed) {
+ return false;
+ }
+
+ if (!VALID_SKINS.has(skin)) {
+ return false;
+ }
+
+ state.skin = skin;
+
+ render();
+
+ notifyStateChange("skin");
+
+ return true;
+ }
+
+ /* ========================================================
+ PLAYER VISIBILITY
+ ======================================================== */
+
+ function setVisibleToPlayers(visible) {
+ if (destroyed) {
+ return false;
+ }
+
+ state.visibleToPlayers = visible === true;
+
+ render();
+
+ notifyStateChange("visibility");
+
+ return true;
+ }
+
+ function showToPlayers() {
+ return setVisibleToPlayers(true);
+ }
+
+ function hideFromPlayers() {
+ return setVisibleToPlayers(false);
+ }
+
+ /* ========================================================
+ NUMERIC DISPLAY
+ ======================================================== */
+
+ function setShowNumericToPlayers(show) {
+ if (destroyed) {
+ return false;
+ }
+
+ state.showNumericToPlayers = show === true;
+
+ render();
+
+ notifyStateChange("player-numeric");
+
+ return true;
+ }
+
+ function setShowNumericToDM(show) {
+ if (destroyed) {
+ return false;
+ }
+
+ state.showNumericToDM = show !== false;
+
+ render();
+
+ notifyStateChange("dm-numeric");
+
+ return true;
+ }
+
+ /* ========================================================
+ DISPLAY MODE
+ ======================================================== */
+
+ function setDisplayMode(mode) {
+ if (destroyed) {
+ return false;
+ }
+
+ if (!VALID_DISPLAY_MODES.has(mode)) {
+ return false;
+ }
+
+ state.displayMode = mode;
+
+ render();
+
+ notifyStateChange("display-mode");
+
+ return true;
+ }
+
+ /* ========================================================
+ RENDERER
+ ======================================================== */
+
+ function setRenderer(newRenderer) {
+ if (destroyed) {
+ return false;
+ }
+
+ renderer = newRenderer || null;
+
+ render();
+
+ return true;
+ }
+
+ /* ========================================================
+ STATE EXPORT
+ ======================================================== */
+
+ function getState() {
+ if (destroyed) {
+ return null;
+ }
+
+ syncRemainingFromClock();
+
+ return cloneState(state);
+ }
+
+ function getRemainingMs() {
+ if (destroyed) {
+ return 0;
+ }
+
+ return calculateRemainingMs();
+ }
+
+ function getRemainingRatio() {
+ if (destroyed) {
+ return 0;
+ }
+
+ return calculateRemainingRatio();
+ }
+
+ /* ========================================================
+ IMPORT / REMOTE STATE
+ ======================================================== */
+
+ function setState(incomingState, options = {}) {
+ if (destroyed) {
+ return false;
+ }
+
+ cancelAnimationLoop();
+
+ state = sanitizeState(incomingState);
+
+ /*
+ * A remote running timer should normally include
+ * endAt.
+ *
+ * If it does not, rebuild it from remainingMs.
+ */
+ if (state.status === "running" && !Number.isFinite(state.endAt)) {
+ state.endAt = Date.now() + state.remainingMs;
+ }
+
+ /*
+ * If the remote end time has already passed, show the
+ * completed state immediately.
+ */
+ if (state.status === "running" && state.endAt <= Date.now()) {
+ state.remainingMs = 0;
+ state.endAt = null;
+ state.status = "complete";
+ }
+
+ completionNotified = state.status === "complete";
+
+ render();
+
+ /*
+ * Normally remote synchronization should not be
+ * rebroadcast, otherwise DM and Player Display could
+ * bounce the same timer update back and forth.
+ */
+ if (options.notify === true) {
+ notifyStateChange(options.reason || "set-state");
+ }
+
+ if (state.status === "running") {
+ startAnimationLoop();
+ }
+
+ return true;
+ }
+
+ /*
+ * Convenience alias specifically intended for data that
+ * arrived from another display/window.
+ */
+ function applyRemoteState(incomingState) {
+ return setState(incomingState, {
+ notify: false,
+ });
+ }
+
+ /* ========================================================
+ PAGE VISIBILITY
+ ======================================================== */
+
+ /*
+ * requestAnimationFrame may be throttled or completely
+ * paused when a browser tab/window is not visible.
+ *
+ * That is okay because the timer uses an absolute endAt.
+ *
+ * When the document becomes visible again, immediately
+ * recalculate from Date.now().
+ */
+ function handleVisibilityChange() {
+ if (destroyed) {
+ return;
+ }
+
+ if (state.status !== "running") {
+ return;
+ }
+
+ const remaining = syncRemainingFromClock();
+
+ if (remaining <= 0) {
+ finishTimer();
+ return;
+ }
+
+ render();
+
+ if (!document.hidden) {
+ startAnimationLoop();
+ }
+ }
+
+ document.addEventListener("visibilitychange", handleVisibilityChange);
+
+ /* ========================================================
+ DESTROY
+ ======================================================== */
+
+ function destroy() {
+ if (destroyed) {
+ return;
+ }
+
+ destroyed = true;
+
+ cancelAnimationLoop();
+
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
+
+ controllers.delete(api);
+
+ renderer = null;
+ }
+
+ /* ========================================================
+ PUBLIC CONTROLLER API
+ ======================================================== */
+
+ const api = {
+ start,
+ pause,
+ resume,
+ reset,
+ stop,
+
+ setDuration,
+
+ adjustTime,
+ addTime,
+ subtractTime,
+
+ setName,
+ setSkin,
+
+ setVisibleToPlayers,
+ showToPlayers,
+ hideFromPlayers,
+
+ setShowNumericToPlayers,
+ setShowNumericToDM,
+
+ setDisplayMode,
+
+ setRenderer,
+
+ getState,
+ getRemainingMs,
+ getRemainingRatio,
+
+ setState,
+ applyRemoteState,
+
+ render,
+
+ destroy,
+
+ get status() {
+ return state.status;
+ },
+
+ get running() {
+ return state.status === "running";
+ },
+
+ get paused() {
+ return state.status === "paused";
+ },
+
+ get complete() {
+ return state.status === "complete";
+ },
+ };
+
+ controllers.add(api);
+
+ /*
+ * Render the initial state immediately.
+ */
+ render();
+
+ /*
+ * If an imported initial state is already running,
+ * continue rendering from its absolute end timestamp.
+ */
+ if (state.status === "running") {
+ if (!Number.isFinite(state.endAt)) {
+ state.endAt = Date.now() + state.remainingMs;
+ }
+
+ startAnimationLoop();
+ }
+
+ return api;
+ }
+
+ /* ============================================================
+ PUBLIC ENC API
+ ============================================================ */
+
+ ENC.timer = {
+ VERSION: TIMER_VERSION,
+
+ DEFAULT_DURATION_MS,
+
+ skins: Object.freeze(["hourglass", "candle", "scifi-pixels"]),
+
+ displayModes: Object.freeze(["overlay", "featured", "focus"]),
+
+ create: createTimer,
+
+ createDefaultState() {
+ return sanitizeState({
+ ...DEFAULT_STATE,
+ id: createTimerId(),
+ });
+ },
+
+ getControllerCount() {
+ return controllers.size;
+ },
+ };
+})(window.ENC);
From 4d2767559c4db2fab1d6a3eb9f296962d7164528 Mon Sep 17 00:00:00 2001
From: Zygons <30783491+zygons@users.noreply.github.com>
Date: Mon, 7 Sep 2026 11:36:23 -0400
Subject: [PATCH 2/3] New Features - Timers and Soundscape
The following commit will include the new on screen timers feature and the new soundscape feature. Soundscapes will allow for audio files to play during scenes and combat and also includes sound effect
---
.gitignore | 4 +-
Build enCounter Linux.sh | 5 +-
Build enCounter Windows.bat | 5 +-
CHANGELOG.md | 18 ++
README.md | 9 +
assets/sounds/ambience/.gitkeep | 0
assets/sounds/custom/.gitkeep | 0
assets/sounds/music/.gitkeep | 0
assets/sounds/sfx/.gitkeep | 0
docs/AUDIO_AND_ENCOUNTER_WORKFLOW.md | 65 +++++++
programs/app/css/base.css | 160 +++++++++++++++++
programs/app/css/display.css | 25 +++
programs/app/index.html | 145 +++++++++++++++-
programs/app/js/core/config.js | 132 +++++++++++++-
programs/app/js/core/db.js | 78 +++++++++
programs/app/js/display.js | 76 +++++++-
programs/app/js/main.js | 107 ++++++++----
programs/app/js/modules/assets.js | 21 ++-
programs/app/js/modules/audio.js | 240 ++++++++++++++++++++++++++
programs/app/js/modules/combat.js | 39 +++--
programs/app/js/modules/encounters.js | 236 +++++++++++++++++++++++++
programs/app/js/modules/soundscape.js | 182 +++++++++++++++++++
programs/launcher.py | 86 +++++++--
tests/core.test.js | 49 ++++++
24 files changed, 1590 insertions(+), 92 deletions(-)
create mode 100644 assets/sounds/ambience/.gitkeep
create mode 100644 assets/sounds/custom/.gitkeep
create mode 100644 assets/sounds/music/.gitkeep
create mode 100644 assets/sounds/sfx/.gitkeep
create mode 100644 docs/AUDIO_AND_ENCOUNTER_WORKFLOW.md
create mode 100644 programs/app/js/modules/audio.js
create mode 100644 programs/app/js/modules/encounters.js
create mode 100644 programs/app/js/modules/soundscape.js
diff --git a/.gitignore b/.gitignore
index 7954a4b..6fe2ad1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,7 +27,9 @@ assets/backgrounds/**/*
assets/portraits/**/*
assets/icons/**/*
assets/tokens/*
-assets/sounds/*
+assets/sounds/**/*
+!assets/sounds/**/
+!assets/sounds/**/.gitkeep
!assets/**/.gitkeep
# Keep application branding
diff --git a/Build enCounter Linux.sh b/Build enCounter Linux.sh
index 67dfae3..6579bfe 100644
--- a/Build enCounter Linux.sh
+++ b/Build enCounter Linux.sh
@@ -75,7 +75,10 @@ for dir in \
icons/combat \
icons/systems \
tokens \
- sounds; do
+ sounds/music \
+ sounds/ambience \
+ sounds/sfx \
+ sounds/custom; do
mkdir -p "${RELEASE_DIR}/assets/${dir}"
done
diff --git a/Build enCounter Windows.bat b/Build enCounter Windows.bat
index 2bc95b2..858be2d 100644
--- a/Build enCounter Windows.bat
+++ b/Build enCounter Windows.bat
@@ -109,7 +109,10 @@ for %%D in (
"icons\combat"
"icons\systems"
"tokens"
- "sounds"
+ "sounds\music"
+ "sounds\ambience"
+ "sounds\sfx"
+ "sounds\custom"
) do mkdir "%RELEASE_DIR%\assets\%%~D" >nul 2>nul
mkdir "%RELEASE_DIR%\data\backups" >nul 2>nul
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29d191c..ce52119 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,24 @@
All notable changes to enCounter will be documented here.
+## Unreleased
+
+### Added
+- Saved Encounter Library workflow with Save, Save As, Load, Duplicate, and Delete while keeping the loaded encounter as a working copy.
+- Encounter phases for prepared, scene/roleplay, combat, and complete states.
+- Player Display Scene mode that shows encounter artwork without initiative/combat information while still allowing visual timers.
+- Encounter Soundscape system with scene music, layered ambience, combat music/ambience, fade transitions, master volume, prepared one-shot cues, and scene-to-combat switching.
+- Audio asset folders and import support for MP3, OGG, WAV, and FLAC files.
+- Separate Scene Image and Combat Background selections for prepared encounters.
+- Live Prepared Cue buttons on the Encounter tab, populated automatically from the loaded encounter's Soundscape and usable during both Scene and Combat phases.
+
+### Changed
+- Player Display presentation is now modeled as Stand By, Scene, or Combat instead of only visible/hidden.
+- Start Combat can automatically transition scene audio to combat audio; End Combat can restore prior scene audio when appropriate.
+- Encounter data normalization now preserves soundscape, display, phase, and saved-template metadata.
+- Soundscape editor wording now makes clear that Soundscapes are encounter-bound and load automatically with Saved Encounters.
+
+
## 0.1.0-alpha.1 - 2026-08-20
### Added
diff --git a/README.md b/README.md
index 353e93d..a0f5d20 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,10 @@
- Hidden enemies with **Hide from Players / Reveal to Players**
- Reusable Player, NPC, Enemy, and Creature Library
- Portrait and encounter background support
+- Saved Encounter Library with reusable prepared encounters and working-copy loading
+- Scene / roleplay Player Display mode with full-screen encounter artwork and optional visual timers
+- Encounter Soundscapes with scene ambience, combat music, transitions, and prepared sound cues
+- Separate scene and combat artwork per encounter
- Generic TTRPG, SWSE, D&D 5E, and Custom system profiles
- Local autosave and recovery snapshots
- Backup, export, and import tools
@@ -54,6 +58,11 @@

+
+### Encounter Soundscape live cues
+
+Soundscapes are saved as part of Saved Encounters. Loading an encounter automatically restores its scene audio, combat audio, and prepared sound cues. Configured cues are available as live buttons on the Encounter tab during both Scene and Combat phases.
+
## Alpha notice
This is pre-release software. Features, storage structures, and behavior may change before version 1.0.
diff --git a/assets/sounds/ambience/.gitkeep b/assets/sounds/ambience/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/assets/sounds/custom/.gitkeep b/assets/sounds/custom/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/assets/sounds/music/.gitkeep b/assets/sounds/music/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/assets/sounds/sfx/.gitkeep b/assets/sounds/sfx/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/AUDIO_AND_ENCOUNTER_WORKFLOW.md b/docs/AUDIO_AND_ENCOUNTER_WORKFLOW.md
new file mode 100644
index 0000000..0f4a621
--- /dev/null
+++ b/docs/AUDIO_AND_ENCOUNTER_WORKFLOW.md
@@ -0,0 +1,65 @@
+# enCounter — Saved Encounters, Scene Display, and Soundscapes
+
+This development update separates a prepared encounter from active combat.
+
+## Encounter workflow
+
+1. Build an encounter in the DM Console: name, system, participants, Scene Image, Combat Background, Soundscape, and timer settings.
+2. Use **Save** or **Save As…** in the Saved Encounter bar to store the prepared encounter.
+3. Load a saved encounter later. enCounter creates an active working copy; game-night changes do not overwrite the saved template unless the DM explicitly presses **Save**.
+4. Use **Show Scene** to display only the encounter's Scene Image on the Player Display. Initiative, combatants, round information, current turn, and next turn remain hidden.
+5. Use **Scene Audio** to start the roleplay/exploration soundscape independently of the Player Display.
+6. Use **Start Combat** to switch the Player Display to combat mode and, when enabled, transition the soundscape to combat audio.
+7. Use **End Combat** to return the Player Display to Scene mode. If Scene audio was playing before combat and return-to-scene is enabled, it resumes automatically.
+8. Use **Hide Player Display** at any time to return the players to STAND BY without stopping DM-side audio.
+
+## Player Display modes
+
+- **Stand By** — enCounter logo / STAND BY only. Timers are hidden.
+- **Scene** — full-screen Scene Image. No initiative or combat UI. Player-visible timers may still appear.
+- **Combat** — existing initiative/combat Player Display using the Combat Background. Player-visible timers may still appear.
+
+## Soundscape
+
+The Soundscape belongs to the loaded encounter and is saved with it.
+
+### Scene audio
+
+- Optional scene music.
+- Up to three ambience layers in the current UI.
+- Each ambience layer can **Stop**, **Continue**, or **Lower Volume** when combat begins.
+
+### Combat audio
+
+- Optional combat music.
+- Up to two additional combat ambience layers in the current UI.
+
+### Prepared cues
+
+Three one-shot cue slots are available in the current Soundscape editor for alarms, explosions, doors, horns, creature sounds, and similar effects. After the Soundscape is applied to the encounter, any configured cue appears automatically as a live **Prepared Cues** button on the Encounter tab. These live cue buttons remain available in both Scene and Combat phases, so the DM does not need to leave the Encounter screen during play.
+
+Soundscapes are not loaded separately. They are part of the Saved Encounter. Loading a Saved Encounter automatically loads its scene audio, combat audio, cue assignments, volumes, transition settings, and other Soundscape configuration into the working encounter.
+
+### Audio library
+
+Supported import formats:
+
+- MP3
+- OGG
+- WAV
+- FLAC
+
+Folders:
+
+```text
+assets/sounds/music/
+assets/sounds/ambience/
+assets/sounds/sfx/
+assets/sounds/custom/
+```
+
+Audio plays from the DM computer. The Player Display remains visual-only, avoiding cross-window audio duplication and browser autoplay/synchronization issues.
+
+## Timers outside combat
+
+Player-visible timers are independent of initiative. A timer may be shown while the Player Display is in Scene mode, making it suitable for puzzles, arrivals, evacuations, security response, environmental events, and other roleplaying sequences.
diff --git a/programs/app/css/base.css b/programs/app/css/base.css
index 9af7077..f70ff67 100644
--- a/programs/app/css/base.css
+++ b/programs/app/css/base.css
@@ -680,3 +680,163 @@ hr {
color: var(--text);
text-decoration: underline;
}
+
+/* ========================================
+ SAVED ENCOUNTERS / LIVE ENCOUNTER CONTROL
+======================================== */
+
+.encounter-library-bar,
+.encounter-live-bar {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ flex-wrap: wrap;
+ padding: 12px;
+ margin: 12px 0;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ background: rgba(7, 20, 28, 0.72);
+}
+
+.encounter-library-bar > select {
+ width: min(380px, 100%);
+}
+
+.encounter-phase-block {
+ min-width: 112px;
+ padding: 7px 10px;
+ border: 1px solid #345567;
+ border-radius: 7px;
+ background: #08141c;
+}
+
+.encounter-phase-block strong {
+ display: block;
+ margin-top: 3px;
+ color: #d7eef7;
+ letter-spacing: 0.08em;
+}
+
+.soundscape-status {
+ display: inline-block;
+ padding: 5px 9px;
+ border: 1px solid #3e6173;
+ border-radius: 999px;
+ color: #b9d3dd;
+ background: #0a1820;
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.8px;
+}
+
+.encounter-cue-panel {
+ display: grid;
+ gap: 10px;
+ padding: 12px;
+ margin: 12px 0;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ background: rgba(7, 20, 28, 0.72);
+}
+
+.encounter-cue-heading {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+
+.encounter-cue-heading strong {
+ display: block;
+ margin-top: 2px;
+ color: #d7eef7;
+ font-size: 14px;
+}
+
+.encounter-cue-buttons {
+ display: flex;
+ gap: 9px;
+ flex-wrap: wrap;
+ min-height: 34px;
+ align-items: center;
+}
+
+.encounter-cue-button {
+ min-width: 124px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+}
+
+.encounter-cue-button::before {
+ content: "▶";
+ font-size: 10px;
+}
+
+.encounter-cue-button.is-playing {
+ border-color: #6fc6e8;
+ box-shadow: 0 0 0 1px rgba(111, 198, 232, 0.24);
+}
+
+/* ========================================
+ SOUNDSCAPE EDITOR
+======================================== */
+
+.soundscape-settings-grid {
+ display: grid;
+ grid-template-columns: minmax(180px, 1fr) 150px minmax(240px, 1fr) minmax(240px, 1fr);
+ gap: 12px;
+ align-items: end;
+}
+
+.soundscape-columns {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+ margin-top: 16px;
+}
+
+.audio-track-row {
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) 105px;
+ gap: 10px;
+ align-items: end;
+ padding: 10px 0;
+ border-bottom: 1px solid rgba(55, 83, 99, 0.45);
+}
+
+.audio-track-row.audio-track-wide {
+ grid-template-columns: minmax(210px, 1fr) 90px 145px 90px;
+}
+
+.cue-grid {
+ display: grid;
+ gap: 8px;
+}
+
+.cue-row {
+ display: grid;
+ grid-template-columns: minmax(130px, 0.5fr) minmax(260px, 1.5fr) 90px auto;
+ gap: 9px;
+ align-items: center;
+}
+
+.asset-card audio {
+ width: 100%;
+ min-width: 180px;
+}
+
+@media (max-width: 1000px) {
+ .soundscape-columns,
+ .soundscape-settings-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .audio-track-row,
+ .audio-track-row.audio-track-wide,
+ .cue-row {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/programs/app/css/display.css b/programs/app/css/display.css
index a4fa3fc..bb7452f 100644
--- a/programs/app/css/display.css
+++ b/programs/app/css/display.css
@@ -479,3 +479,28 @@
color: #d7e7ee;
text-decoration: underline;
}
+
+/* ========================================
+ SCENE / ROLEPLAY DISPLAY MODE
+======================================== */
+
+.player-body[data-display-mode="scene"] .player-shell {
+ display: none;
+}
+
+.player-body[data-display-mode="scene"] .display-shade {
+ background: linear-gradient(
+ to bottom,
+ rgba(0, 0, 0, 0.10),
+ rgba(0, 0, 0, 0.03) 55%,
+ rgba(0, 0, 0, 0.20)
+ );
+}
+
+.player-body[data-display-mode="scene"] .display-background {
+ transform: none;
+}
+
+.player-body[data-display-mode="combat"] .player-shell {
+ display: flex;
+}
diff --git a/programs/app/index.html b/programs/app/index.html
index c2e8821..42630fa 100644
--- a/programs/app/index.html
+++ b/programs/app/index.html
@@ -116,6 +116,10 @@
Timer
+
+ Soundscape
+
+
Data
@@ -138,9 +142,9 @@
-
ENCOUNTER TOOLS
+
ENCOUNTER CONTROL
-
Run Combat
+
Prepare & Run Encounter
@@ -158,17 +162,72 @@
Run Combat
+
+
+
+
+
Saved Encounter
+
Unsaved working encounter
+
+
+ Load saved encounter…
+
+
Load
+
Save
+
Save As…
+
Duplicate
+
Delete
+
+
+
+
+
+
+ PHASE
+ PREPARED
+
+
Show Scene
+
▶ Scene Audio
+
⚔ Start Combat
+
End Combat
+
Hide Player Display
+
■ Stop Audio
+
AUDIO STOPPED
+
+
+
+
+
+
+
+ LIVE SOUND EFFECTS
+ Prepared Cues
+
+
Cues saved with this encounter can be triggered during Scene or Combat.
+
+
+ No prepared sound cues for this encounter.
+
+
+
- Background
+ Combat Background
— None —
+
+ Scene Image
+
+ — None —
+
+
+
Import Background
@@ -450,6 +509,7 @@ Asset Library
Portraits
Icons
Tokens
+ Audio
@@ -468,18 +528,22 @@
Asset Library
Portraits / Custom
Tokens
-
Icons / Conditions
+
+ Audio / Music
+ Audio / Ambience
+ Audio / Sound Effects
+ Audio / Custom
- Choose Image
+ Choose Asset File
@@ -698,6 +762,72 @@ Timer
+
+
+
+
+
+
+
ENCOUNTER AUDIO
+
Soundscape
+
Prepare scene ambience, combat audio, and one-shot cues for the currently loaded encounter. The Soundscape is saved with the encounter and loads automatically when that Saved Encounter is loaded. Audio plays from the DM computer; the Player Display remains visual-only.
+
+
+ AUDIO STOPPED
+ ▶ Scene
+ ⚔ Combat
+ ■ Stop
+ Apply to Encounter
+
+
+
+
+ Master Volume
+ Fade (seconds)
+ Switch to combat audio when combat starts
+ Return to scene audio when combat ends
+
+
+
+
+
+
+
+
INITIATIVE / BATTLE
+
Combat Audio
+
Combat Music — None — Volume %
+
Combat Ambience
+
Combat Ambience 1 — None — Volume %
Combat Ambience 2 — None — Volume %
+
+
+
+
+
ONE-SHOT SOUNDS
Prepared Cues
+
+
+
+
+
AUDIO LIBRARY
Import Audio
+
+ Category Music Ambience Sound Effects Custom
+ Audio File
+ Import Audio
+
+
Supported: MP3, OGG, WAV, FLAC. Long music/ambience files are best stored as MP3 or OGG.
+
+
+
@@ -942,6 +1072,9 @@
Resume Last Encounter?
+
+
+
diff --git a/programs/app/js/core/config.js b/programs/app/js/core/config.js
index 0928f4a..a94c9d6 100644
--- a/programs/app/js/core/config.js
+++ b/programs/app/js/core/config.js
@@ -189,6 +189,100 @@ ENC.sanitizeAssetUrl = function (value) {
}
};
+
+ENC.sanitizeAudioUrl = function (value) {
+ const text = String(value || "").trim();
+ if (!text) return "";
+
+ try {
+ const url = new URL(text, window.location.origin);
+ if (url.origin !== window.location.origin) return "";
+ if (!url.pathname.startsWith("/assets/sounds/")) return "";
+ if (!/\.(mp3|ogg|wav|flac)$/i.test(url.pathname)) return "";
+ return url.pathname;
+ } catch {
+ return "";
+ }
+};
+
+ENC.ENCOUNTER_PHASES = new Set(["prepared", "scene", "combat", "complete"]);
+ENC.PLAYER_DISPLAY_MODES = new Set(["standby", "scene", "combat"]);
+
+ENC.normalizeAudioTrack = function (track = {}, defaults = {}) {
+ const source = track && typeof track === "object" ? track : {};
+ const defaultVolume = ENC.finiteNumber(defaults.volume, 0.5);
+ const volume = Math.min(1, Math.max(0, ENC.finiteNumber(source.volume, defaultVolume)));
+ const combatBehavior = ["stop", "continue", "duck"].includes(source.combatBehavior)
+ ? source.combatBehavior
+ : String(defaults.combatBehavior || "stop");
+ const combatVolume = Math.min(1, Math.max(0, ENC.finiteNumber(source.combatVolume, 0.15)));
+
+ return {
+ id: String(source.id || ENC.makeId("audio")),
+ name: String(source.name || defaults.name || "Audio Track").trim() || "Audio Track",
+ asset: ENC.sanitizeAudioUrl(source.asset),
+ volume,
+ loop: source.loop !== false,
+ combatBehavior,
+ combatVolume,
+ };
+};
+
+ENC.createDefaultSoundscape = function () {
+ return {
+ enabled: true,
+ masterVolume: 0.8,
+ fadeMs: 2500,
+ autoSwitchToCombat: true,
+ returnToSceneAfterCombat: true,
+ scene: {
+ music: ENC.normalizeAudioTrack({ name: "Scene Music", loop: true, volume: 0.35 }),
+ ambience: [],
+ },
+ combat: {
+ music: ENC.normalizeAudioTrack({ name: "Combat Music", loop: true, volume: 0.65 }),
+ ambience: [],
+ },
+ cues: [],
+ };
+};
+
+ENC.normalizeSoundscape = function (soundscape = {}) {
+ const source = soundscape && typeof soundscape === "object" ? soundscape : {};
+ const defaults = ENC.createDefaultSoundscape();
+ const scene = source.scene && typeof source.scene === "object" ? source.scene : {};
+ const combat = source.combat && typeof source.combat === "object" ? source.combat : {};
+
+ const normalizeList = (items, defaultBehavior = "stop") =>
+ (Array.isArray(items) ? items : [])
+ .slice(0, 8)
+ .map((item, index) => ENC.normalizeAudioTrack(item, {
+ name: `Ambience ${index + 1}`,
+ volume: 0.4,
+ combatBehavior: defaultBehavior,
+ }));
+
+ return {
+ enabled: source.enabled !== false,
+ masterVolume: Math.min(1, Math.max(0, ENC.finiteNumber(source.masterVolume, defaults.masterVolume))),
+ fadeMs: Math.min(15000, Math.max(0, Math.trunc(ENC.finiteNumber(source.fadeMs, defaults.fadeMs)))),
+ autoSwitchToCombat: source.autoSwitchToCombat !== false,
+ returnToSceneAfterCombat: source.returnToSceneAfterCombat !== false,
+ scene: {
+ music: ENC.normalizeAudioTrack(scene.music || {}, { name: "Scene Music", volume: 0.35, combatBehavior: "stop" }),
+ ambience: normalizeList(scene.ambience, "duck"),
+ },
+ combat: {
+ music: ENC.normalizeAudioTrack(combat.music || {}, { name: "Combat Music", volume: 0.65, combatBehavior: "continue" }),
+ ambience: normalizeList(combat.ambience, "continue"),
+ },
+ cues: (Array.isArray(source.cues) ? source.cues : []).slice(0, 20).map((cue, index) => {
+ const normalized = ENC.normalizeAudioTrack(cue, { name: `Cue ${index + 1}`, volume: 0.8 });
+ return { ...normalized, loop: false };
+ }),
+ };
+};
+
ENC.normalizeSettings = function (settings = {}) {
const source = settings && typeof settings === "object" ? settings : {};
const validModes = new Set(["simple", "standard", "advanced", "custom"]);
@@ -226,12 +320,20 @@ ENC.createDefaultEncounter = function () {
return {
id: "active",
kind: "active",
+ sourceEncounterId: null,
name: "New Encounter",
systemId: "generic",
customProfile: ENC.deepClone(ENC.DEFAULT_SETTINGS.customProfile),
+ phase: "prepared",
+ playerDisplayMode: "standby",
round: 1,
currentId: null,
background: "",
+ display: {
+ sceneImage: "",
+ combatImage: "",
+ },
+ soundscape: ENC.createDefaultSoundscape(),
combatants: [],
updatedAt: new Date().toISOString(),
};
@@ -244,6 +346,11 @@ ENC.isMeaningfulEncounter = function (encounter) {
Number(encounter.round || 1) > 1 ||
Boolean(encounter.currentId) ||
Boolean(encounter.background) ||
+ Boolean(encounter.display?.sceneImage) ||
+ Boolean(encounter.display?.combatImage) ||
+ Boolean(encounter.soundscape?.scene?.music?.asset) ||
+ Boolean(encounter.soundscape?.combat?.music?.asset) ||
+ Boolean(encounter.soundscape?.scene?.ambience?.some?.((track) => track.asset)) ||
String(encounter.name || "").trim().toLowerCase() !== "new encounter",
);
};
@@ -378,7 +485,7 @@ ENC.normalizeCombatant = function (
ENC.normalizeEncounter = function (encounter = {}) {
const source = encounter && typeof encounter === "object" ? encounter : {};
- const kind = source.kind === "preset" ? "preset" : "active";
+ const kind = ["active", "preset", "saved"].includes(source.kind) ? source.kind : "active";
const systemId = ENC.normalizeSystemId(source.systemId || "generic");
const customProfile = ENC.normalizeCustomProfile(source.customProfile);
const combatants = Array.isArray(source.combatants)
@@ -394,15 +501,32 @@ ENC.normalizeEncounter = function (encounter = {}) {
: null;
const rawRound = Number(source.round);
+ const legacyBackground = ENC.sanitizeAssetUrl(source.background);
+ const displaySource = source.display && typeof source.display === "object" ? source.display : {};
+ const combatImage = ENC.sanitizeAssetUrl(displaySource.combatImage || legacyBackground);
+ const sceneImage = ENC.sanitizeAssetUrl(displaySource.sceneImage || legacyBackground);
+ const phase = ENC.ENCOUNTER_PHASES.has(source.phase) ? source.phase : "prepared";
+ const playerDisplayMode = ENC.PLAYER_DISPLAY_MODES.has(source.playerDisplayMode)
+ ? source.playerDisplayMode
+ : "standby";
+
return {
- id: String(source.id || (kind === "active" ? "active" : ENC.makeId("preset"))),
+ id: String(source.id || (kind === "active" ? "active" : ENC.makeId(kind === "saved" ? "enc" : "preset"))),
kind,
- name: String(source.name || (kind === "active" ? "New Encounter" : "Encounter Preset")).trim() || "Encounter",
+ sourceEncounterId: source.sourceEncounterId ? String(source.sourceEncounterId) : null,
+ name: String(source.name || (kind === "active" ? "New Encounter" : "Saved Encounter")).trim() || "Encounter",
systemId,
customProfile,
+ phase,
+ playerDisplayMode,
round: Number.isFinite(rawRound) ? Math.max(1, Math.trunc(rawRound)) : 1,
currentId,
- background: ENC.sanitizeAssetUrl(source.background),
+ background: combatImage,
+ display: {
+ sceneImage,
+ combatImage,
+ },
+ soundscape: ENC.normalizeSoundscape(source.soundscape),
combatants,
updatedAt: String(source.updatedAt || new Date().toISOString()),
};
diff --git a/programs/app/js/core/db.js b/programs/app/js/core/db.js
index 428a3ff..f2027ff 100644
--- a/programs/app/js/core/db.js
+++ b/programs/app/js/core/db.js
@@ -137,6 +137,84 @@ ENC.db = {
return this.put("encounters", ENC.deepClone(normalized));
},
+ async saveEncounterTemplate(encounter, name = null, existingId = null) {
+ const source = ENC.normalizeEncounter(ENC.deepClone(encounter));
+ const templateId = existingId || source.sourceEncounterId || ENC.makeId("enc");
+ const template = ENC.normalizeEncounter({
+ ...source,
+ id: templateId,
+ kind: "saved",
+ sourceEncounterId: null,
+ name: String(name || source.name || "Saved Encounter").trim() || "Saved Encounter",
+ phase: "prepared",
+ playerDisplayMode: "standby",
+ currentId: null,
+ round: 1,
+ combatants: (source.combatants || []).map((combatant) => ({
+ ...combatant,
+ hp: combatant.maxHp ?? combatant.hp ?? "",
+ conditionTrack: "Normal",
+ visibleStatus: "",
+ combatState: "active",
+ delayed: false,
+ ready: false,
+ })),
+ updatedAt: new Date().toISOString(),
+ });
+ template.id = templateId;
+ template.kind = "saved";
+ template.sourceEncounterId = null;
+ await this.put("encounters", template);
+ return template;
+ },
+
+ async getSavedEncounters() {
+ const all = await this.getAll("encounters");
+ return all
+ .filter((item) => item?.kind === "saved")
+ .map((item) => ENC.normalizeEncounter(item))
+ .sort((a, b) => String(a.name).localeCompare(String(b.name)));
+ },
+
+ async loadSavedEncounter(id) {
+ const saved = await this.get("encounters", id);
+ if (!saved || saved.kind !== "saved") return null;
+
+ const working = ENC.normalizeEncounter({
+ ...ENC.deepClone(saved),
+ id: "active",
+ kind: "active",
+ sourceEncounterId: saved.id,
+ phase: "prepared",
+ playerDisplayMode: "standby",
+ currentId: null,
+ round: 1,
+ combatants: (saved.combatants || []).map((combatant) => ({
+ ...combatant,
+ id: ENC.makeId("cmb"),
+ hp: combatant.maxHp ?? combatant.hp ?? "",
+ conditionTrack: "Normal",
+ visibleStatus: "",
+ combatState: "active",
+ hidden: Boolean(combatant.hidden),
+ delayed: false,
+ ready: false,
+ createdOrder: Date.now() + Math.random(),
+ })),
+ });
+ working.id = "active";
+ working.kind = "active";
+ working.sourceEncounterId = saved.id;
+ await this.saveActiveEncounter(working);
+ return working;
+ },
+
+ async duplicateSavedEncounter(id) {
+ const saved = await this.get("encounters", id);
+ if (!saved || saved.kind !== "saved") return null;
+ return this.saveEncounterTemplate(saved, `${saved.name} Copy`, null);
+ },
+
async savePreset(encounter, name) {
const preset = ENC.normalizeEncounter({
...ENC.deepClone(encounter),
diff --git a/programs/app/js/display.js b/programs/app/js/display.js
index 7205de3..de85345 100644
--- a/programs/app/js/display.js
+++ b/programs/app/js/display.js
@@ -13,8 +13,9 @@ ENC.display = {
timerRenderer: null,
timerController: null,
- // Player Display privacy state
- paused: false,
+ // Player Display presentation state
+ displayMode: "standby",
+ paused: true,
awaitingDmState: true,
/* ========================================
@@ -68,8 +69,23 @@ ENC.display = {
PLAYER DISPLAY PRIVACY
======================================== */
+ setDisplayMode(mode) {
+ this.displayMode = ENC.PLAYER_DISPLAY_MODES.has(mode) ? mode : "standby";
+ this.paused = this.displayMode === "standby";
+ document.body.dataset.displayMode = this.displayMode;
+
+ const overlay = document.getElementById("playerDisplayPrivacyOverlay");
+ if (overlay) overlay.classList.toggle("show", this.paused || this.awaitingDmState);
+
+ this.updateTimerLayer();
+ if (!this.paused && !this.awaitingDmState) this.render();
+ },
+
setPaused(paused) {
this.paused = Boolean(paused);
+ if (this.paused) this.displayMode = "standby";
+ else if (this.displayMode === "standby") this.displayMode = this.encounter?.phase === "combat" ? "combat" : "scene";
+ document.body.dataset.displayMode = this.displayMode;
const overlay = document.getElementById("playerDisplayPrivacyOverlay");
@@ -347,6 +363,25 @@ ENC.display = {
this.updateIdleLogo(false);
},
+ /* ========================================
+ SCENE / ROLEPLAY DISPLAY
+ ======================================== */
+
+ renderScene() {
+ if (!this.encounter) {
+ this.renderStandby();
+ return;
+ }
+
+ document.body.dataset.displayMode = "scene";
+ const background = document.getElementById("displayBackground");
+ const sceneImage = this.encounter.display?.sceneImage || this.encounter.background || "";
+ if (background) background.style.backgroundImage = sceneImage ? `url("${sceneImage}")` : "";
+ const name = document.getElementById("displayEncounterName");
+ if (name) name.textContent = this.encounter.name || "Scene";
+ this.updateIdleLogo(Boolean(sceneImage));
+ },
+
/* ========================================
MAIN RENDER
======================================== */
@@ -360,6 +395,13 @@ ENC.display = {
return;
}
+ if (this.displayMode === "scene") {
+ this.renderScene();
+ return;
+ }
+
+ document.body.dataset.displayMode = "combat";
+
/* ----------------------------------------
No active encounter
---------------------------------------- */
@@ -417,8 +459,9 @@ ENC.display = {
const background = document.getElementById("displayBackground");
- if (this.encounter.background) {
- background.style.backgroundImage = `url("${this.encounter.background}")`;
+ const combatImage = this.encounter.display?.combatImage || this.encounter.background || "";
+ if (combatImage) {
+ background.style.backgroundImage = `url("${combatImage}")`;
} else {
background.style.backgroundImage = "";
}
@@ -655,7 +698,14 @@ ENC.display = {
this.encounter = ENC.normalizeEncounter(message.encounter);
}
- this.setPaused(Boolean(message.playerDisplayPaused));
+ this.setDisplayMode(
+ message.playerDisplayMode ||
+ (message.playerDisplayPaused
+ ? "standby"
+ : this.encounter?.phase === "combat"
+ ? "combat"
+ : "scene"),
+ );
/*
* main.js will include the current
@@ -677,7 +727,20 @@ ENC.display = {
}
// =====================================
- // HIDE / SHOW PLAYER DISPLAY
+ // PLAYER DISPLAY MODE
+ // =====================================
+
+ if (message.type === "player-display-mode") {
+ this.markDmSeen();
+ this.awaitingDmState = false;
+ if (message.encounter) this.encounter = ENC.normalizeEncounter(message.encounter);
+ if (message.timer) this.applyTimerState(message.timer);
+ this.setDisplayMode(message.mode || "standby");
+ return;
+ }
+
+ // =====================================
+ // HIDE / SHOW PLAYER DISPLAY (LEGACY)
// =====================================
if (message.type === "player-display-visibility") {
@@ -786,6 +849,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}
await ENC.db.open();
+ document.body.dataset.displayMode = "standby";
/*
* Timer modules must already be loaded by
diff --git a/programs/app/js/main.js b/programs/app/js/main.js
index 35954c9..c6a8d58 100644
--- a/programs/app/js/main.js
+++ b/programs/app/js/main.js
@@ -6,7 +6,8 @@
ENC.app = {
toastTimer: null,
- playerDisplayPaused: false,
+ playerDisplayPaused: true,
+ playerDisplayMode: "standby",
playerDisplayConnected: false,
playerDisplayWindow: null,
playerDisplayWatchTimer: null,
@@ -31,7 +32,8 @@ ENC.app = {
ENC.sync.broadcast("dm-presence", {
sentAt: Date.now(),
- playerDisplayPaused: this.playerDisplayPaused,
+ playerDisplayPaused: this.playerDisplayMode === "standby",
+ playerDisplayMode: this.playerDisplayMode,
encounter:
includeEncounter && ENC.combat?.encounter
@@ -82,6 +84,42 @@ ENC.app = {
});
},
+ broadcastDisplayState() {
+ ENC.sync.broadcast("player-display-mode", {
+ sentAt: Date.now(),
+ mode: this.playerDisplayMode,
+ encounter: ENC.combat?.encounter ? ENC.deepClone(ENC.combat.encounter) : null,
+ timer: this.getTimerSyncState(),
+ });
+ },
+
+ setPlayerDisplayMode(mode) {
+ const next = ENC.PLAYER_DISPLAY_MODES.has(mode) ? mode : "standby";
+ this.playerDisplayMode = next;
+ this.playerDisplayPaused = next === "standby";
+ if (ENC.combat?.encounter) {
+ ENC.combat.encounter.playerDisplayMode = next;
+ ENC.db.saveActiveEncounter(ENC.combat.encounter).catch((error) => console.warn("Could not persist display mode", error));
+ }
+ this.broadcastDisplayState();
+ this.updatePlayerDisplayButton();
+ ENC.encounterUI?.renderPhase?.();
+ },
+
+ refreshEncounterMediaControls() {
+ if (!ENC.combat?.encounter) return;
+ ENC.assets.fillSelect(
+ document.getElementById("backgroundSelect"),
+ "backgrounds/",
+ ENC.combat.encounter.display?.combatImage || ENC.combat.encounter.background || "",
+ );
+ ENC.assets.fillSelect(
+ document.getElementById("sceneImageSelect"),
+ "backgrounds/",
+ ENC.combat.encounter.display?.sceneImage || ENC.combat.encounter.background || "",
+ );
+ },
+
updatePlayerDisplayButton() {
const button = document.getElementById("openDisplayBtn");
@@ -95,11 +133,9 @@ ENC.app = {
return;
}
- if (this.playerDisplayPaused) {
+ if (this.playerDisplayMode === "standby") {
button.textContent = "Show Player Display";
-
button.classList.add("player-display-paused");
-
return;
}
@@ -122,7 +158,6 @@ ENC.app = {
this.playerDisplayWatchTimer = null;
this.playerDisplayWindow = null;
this.playerDisplayConnected = false;
- this.playerDisplayPaused = false;
this.updatePlayerDisplayButton();
}
@@ -571,8 +606,6 @@ ENC.app = {
// ------------------------------------
if (!this.playerDisplayConnected) {
- this.playerDisplayPaused = false;
-
const displayWindow = window.open(
"/programs/app/display.html",
"enCounterPlayerDisplay",
@@ -596,30 +629,17 @@ ENC.app = {
}
// ------------------------------------
- // HIDE / SHOW EXISTING PLAYER DISPLAY
+ // HIDE / RESTORE EXISTING PLAYER DISPLAY
// ------------------------------------
- this.playerDisplayPaused = !this.playerDisplayPaused;
-
- ENC.sync.broadcast("player-display-visibility", {
- sentAt: Date.now(),
-
- paused: this.playerDisplayPaused,
-
- encounter: ENC.combat?.encounter
- ? ENC.deepClone(ENC.combat.encounter)
- : null,
-
- timer: this.getTimerSyncState(),
- });
-
- this.updatePlayerDisplayButton();
-
- this.toast(
- this.playerDisplayPaused
- ? "Player Display hidden. DM changes are private."
- : "Player Display restored with the latest encounter.",
- );
+ if (this.playerDisplayMode === "standby") {
+ const phase = ENC.combat?.encounter?.phase;
+ this.setPlayerDisplayMode(phase === "combat" ? "combat" : "scene");
+ this.toast("Player Display restored.");
+ } else {
+ this.setPlayerDisplayMode("standby");
+ this.toast("Player Display hidden. DM changes are private.");
+ }
});
}
@@ -972,7 +992,7 @@ ENC.app = {
const category = document.getElementById("assetUploadCategory").value;
if (!file) {
- return this.toast("Choose an image first.");
+ return this.toast("Choose an asset first.");
}
try {
@@ -988,11 +1008,7 @@ ENC.app = {
},
refreshAssetUI() {
- ENC.assets.fillSelect(
- document.getElementById("backgroundSelect"),
- "backgrounds/",
- ENC.combat.encounter?.background || "",
- );
+ this.refreshEncounterMediaControls();
ENC.libraryUI.fillPortraitSelect(
document.getElementById("libraryPortraitSelect").value,
@@ -1002,6 +1018,7 @@ ENC.app = {
document.getElementById("assetGrid"),
document.getElementById("assetFilter").value,
);
+ ENC.soundscapeUI?.refreshAudioSelects?.();
},
bindCustomSystem() {
@@ -1049,11 +1066,20 @@ ENC.app = {
await ENC.combat.load();
+ this.playerDisplayMode = ENC.PLAYER_DISPLAY_MODES.has(ENC.combat.encounter?.playerDisplayMode)
+ ? ENC.combat.encounter.playerDisplayMode
+ : "standby";
+ this.playerDisplayPaused = this.playerDisplayMode === "standby";
+ ENC.audio.setSoundscape(ENC.combat.encounter?.soundscape);
+
ENC.combat.render();
await ENC.assets.refresh();
this.refreshAssetUI();
+ await ENC.encounterUI?.refreshSaved?.();
+ ENC.encounterUI?.renderPhase?.();
+ ENC.soundscapeUI.render();
await this.renderSnapshots();
@@ -1092,6 +1118,12 @@ ENC.app = {
await ENC.combat.load();
+ this.playerDisplayMode = ENC.PLAYER_DISPLAY_MODES.has(ENC.combat.encounter?.playerDisplayMode)
+ ? ENC.combat.encounter.playerDisplayMode
+ : "standby";
+ this.playerDisplayPaused = this.playerDisplayMode === "standby";
+ ENC.audio.setSoundscape(ENC.combat.encounter?.soundscape);
+
this.bindNavigation();
this.initializeTimer();
@@ -1110,6 +1142,9 @@ ENC.app = {
this.bindCustomSystem();
+ await ENC.encounterUI.initialize();
+ ENC.soundscapeUI.initialize();
+
ENC.libraryUI.resetForm();
ENC.libraryUI.render();
diff --git a/programs/app/js/modules/assets.js b/programs/app/js/modules/assets.js
index 79a4587..c259323 100644
--- a/programs/app/js/modules/assets.js
+++ b/programs/app/js/modules/assets.js
@@ -37,9 +37,17 @@ ENC.assets = {
async upload(file, category) {
if (!file) throw new Error("Choose a file first.");
- const allowed = ["image/png", "image/jpeg", "image/webp"];
- if (!allowed.includes(file.type)) throw new Error("Only PNG, JPEG, and WebP images are supported.");
- if (file.size > 10 * 1024 * 1024) throw new Error("The image is larger than 10 MB.");
+ const isAudioCategory = String(category || "").startsWith("sounds/");
+ const imageTypes = ["image/png", "image/jpeg", "image/webp"];
+ const audioTypes = ["audio/mpeg", "audio/ogg", "audio/wav", "audio/x-wav", "audio/flac", "audio/x-flac"];
+ const allowed = isAudioCategory ? audioTypes : imageTypes;
+ if (!allowed.includes(file.type)) {
+ throw new Error(isAudioCategory
+ ? "Only MP3, OGG, WAV, and FLAC audio files are supported."
+ : "Only PNG, JPEG, and WebP images are supported.");
+ }
+ const maxBytes = isAudioCategory ? 50 * 1024 * 1024 : 10 * 1024 * 1024;
+ if (file.size > maxBytes) throw new Error(isAudioCategory ? "The audio file is larger than 50 MB." : "The image is larger than 10 MB.");
const params = new URLSearchParams({ category, name: file.name });
const response = await fetch(`/api/assets/upload?${params.toString()}`, {
@@ -67,11 +75,18 @@ ENC.assets = {
const card = document.createElement("article");
card.className = "asset-card";
const isImage = /\.(png|jpe?g|webp)$/i.test(item.name);
+ const isAudio = /\.(mp3|ogg|wav|flac)$/i.test(item.name);
if (isImage) {
const img = document.createElement("img");
img.src = item.url;
img.alt = "";
card.appendChild(img);
+ } else if (isAudio) {
+ const audio = document.createElement("audio");
+ audio.controls = true;
+ audio.preload = "metadata";
+ audio.src = item.url;
+ card.appendChild(audio);
}
const name = document.createElement("strong");
name.textContent = item.name;
diff --git a/programs/app/js/modules/audio.js b/programs/app/js/modules/audio.js
new file mode 100644
index 0000000..59db9c4
--- /dev/null
+++ b/programs/app/js/modules/audio.js
@@ -0,0 +1,240 @@
+/*
+ * Copyright (C) 2026 Zygons
+ * SPDX-License-Identifier: MIT
+ * This file is part of enCounter. See LICENSE for the full license terms.
+ */
+
+window.ENC = window.ENC || {};
+
+(function (ENC) {
+ "use strict";
+
+ const active = new Map();
+ const cuePlayers = new Set();
+ let mode = "stopped";
+ let resumeSceneAfterCombat = false;
+ let soundscape = ENC.createDefaultSoundscape();
+ let stateListener = null;
+
+ function emit(reason = "updated") {
+ if (typeof stateListener === "function") {
+ stateListener({ mode, soundscape: ENC.deepClone(soundscape), reason });
+ }
+ }
+
+ function effectiveVolume(track, multiplier = 1) {
+ return Math.min(
+ 1,
+ Math.max(0, Number(track?.volume || 0) * Number(soundscape.masterVolume || 0) * multiplier),
+ );
+ }
+
+ function createPlayer(key, track, targetVolume) {
+ if (!track?.asset) return null;
+ const audio = new Audio(track.asset);
+ audio.preload = "auto";
+ audio.loop = track.loop !== false;
+ audio.volume = 0;
+ active.set(key, { audio, track: ENC.deepClone(track), targetVolume });
+ return audio;
+ }
+
+ function fadeAudio(audio, from, to, durationMs, onDone = null) {
+ const duration = Math.max(0, Number(durationMs || 0));
+ const start = performance.now();
+ audio.volume = Math.min(1, Math.max(0, from));
+
+ if (!duration) {
+ audio.volume = Math.min(1, Math.max(0, to));
+ onDone?.();
+ return;
+ }
+
+ const tick = (now) => {
+ const ratio = Math.min(1, (now - start) / duration);
+ audio.volume = Math.min(1, Math.max(0, from + (to - from) * ratio));
+ if (ratio < 1) requestAnimationFrame(tick);
+ else onDone?.();
+ };
+ requestAnimationFrame(tick);
+ }
+
+ async function startTrack(key, track, volumeMultiplier = 1, fadeMs = null) {
+ if (!track?.asset) return null;
+ stopTrack(key, 0);
+ const targetVolume = effectiveVolume(track, volumeMultiplier);
+ const audio = createPlayer(key, track, targetVolume);
+ if (!audio) return null;
+
+ try {
+ await audio.play();
+ fadeAudio(audio, 0, targetVolume, fadeMs ?? soundscape.fadeMs);
+ return audio;
+ } catch (error) {
+ active.delete(key);
+ console.warn("Audio playback could not start:", track.asset, error);
+ throw new Error("Audio could not start. Click the audio control again and check the selected output device.");
+ }
+ }
+
+ function stopTrack(key, fadeMs = null) {
+ const entry = active.get(key);
+ if (!entry) return;
+ active.delete(key);
+ const { audio } = entry;
+ const finish = () => {
+ audio.pause();
+ audio.currentTime = 0;
+ };
+ fadeAudio(audio, audio.volume, 0, fadeMs ?? soundscape.fadeMs, finish);
+ }
+
+ function stopGroup(prefix, fadeMs = null) {
+ for (const key of [...active.keys()]) {
+ if (key.startsWith(prefix)) stopTrack(key, fadeMs);
+ }
+ }
+
+ function setTrackVolume(key, volume, fadeMs = null) {
+ const entry = active.get(key);
+ if (!entry) return;
+ entry.targetVolume = Math.min(1, Math.max(0, volume));
+ fadeAudio(entry.audio, entry.audio.volume, entry.targetVolume, fadeMs ?? soundscape.fadeMs);
+ }
+
+ async function playScene() {
+ stopGroup("combat:");
+ stopGroup("scene:");
+
+ const tasks = [];
+ if (soundscape.scene.music?.asset) {
+ tasks.push(startTrack("scene:music", soundscape.scene.music));
+ }
+ soundscape.scene.ambience.forEach((track, index) => {
+ if (track.asset) tasks.push(startTrack(`scene:amb:${index}`, track));
+ });
+ await Promise.allSettled(tasks);
+ mode = "scene";
+ emit("scene-started");
+ }
+
+ async function playCombat() {
+ resumeSceneAfterCombat = mode === "scene";
+ // Scene music always yields to combat music.
+ stopTrack("scene:music");
+
+ soundscape.scene.ambience.forEach((track, index) => {
+ const key = `scene:amb:${index}`;
+ if (!active.has(key)) return;
+ if (track.combatBehavior === "continue") {
+ setTrackVolume(key, effectiveVolume(track));
+ } else if (track.combatBehavior === "duck") {
+ setTrackVolume(key, Math.min(1, Math.max(0, Number(track.combatVolume || 0.15) * soundscape.masterVolume)));
+ } else {
+ stopTrack(key);
+ }
+ });
+
+ stopGroup("combat:");
+ const tasks = [];
+ if (soundscape.combat.music?.asset) {
+ tasks.push(startTrack("combat:music", soundscape.combat.music));
+ }
+ soundscape.combat.ambience.forEach((track, index) => {
+ if (track.asset) tasks.push(startTrack(`combat:amb:${index}`, track));
+ });
+ await Promise.allSettled(tasks);
+ mode = "combat";
+ emit("combat-started");
+ }
+
+ async function returnToScene() {
+ stopGroup("combat:");
+ if (resumeSceneAfterCombat) {
+ await playScene();
+ } else {
+ stopAll();
+ }
+ resumeSceneAfterCombat = false;
+ }
+
+ function stopAll(fadeMs = null) {
+ for (const key of [...active.keys()]) stopTrack(key, fadeMs);
+ for (const audio of cuePlayers) {
+ audio.pause();
+ audio.currentTime = 0;
+ }
+ cuePlayers.clear();
+ mode = "stopped";
+ resumeSceneAfterCombat = false;
+ emit("stopped");
+ }
+
+ async function playCue(cue) {
+ const normalized = ENC.normalizeAudioTrack(cue, { name: "Cue", volume: 0.8 });
+ if (!normalized.asset) return;
+ const audio = new Audio(normalized.asset);
+ audio.preload = "auto";
+ audio.loop = false;
+ audio.volume = effectiveVolume(normalized);
+ cuePlayers.add(audio);
+ const cleanup = () => cuePlayers.delete(audio);
+ audio.addEventListener("ended", cleanup, { once: true });
+ try {
+ await audio.play();
+ } catch (error) {
+ cleanup();
+ throw new Error("Sound cue could not start.");
+ }
+ }
+
+ function setSoundscape(next) {
+ const wasPlaying = mode;
+ soundscape = ENC.normalizeSoundscape(next);
+ if (wasPlaying !== "stopped") {
+ // Keep current audio stable until DM explicitly restarts/changes mode.
+ for (const entry of active.values()) {
+ entry.audio.volume = Math.min(1, entry.audio.volume);
+ }
+ }
+ emit("configuration-updated");
+ }
+
+ function setMasterVolume(value) {
+ soundscape.masterVolume = Math.min(1, Math.max(0, Number(value || 0)));
+ for (const [key, entry] of active.entries()) {
+ let multiplier = 1;
+ if (mode === "combat" && key.startsWith("scene:amb:") && entry.track.combatBehavior === "duck") {
+ const index = Number(key.split(":").pop());
+ const configured = soundscape.scene.ambience[index];
+ const volume = Number(configured?.combatVolume || 0.15) * soundscape.masterVolume;
+ setTrackVolume(key, volume, 150);
+ continue;
+ }
+ setTrackVolume(key, effectiveVolume(entry.track, multiplier), 150);
+ }
+ emit("master-volume");
+ }
+
+ ENC.audio = {
+ get mode() {
+ return mode;
+ },
+ get soundscape() {
+ return ENC.deepClone(soundscape);
+ },
+ setSoundscape,
+ setMasterVolume,
+ playScene,
+ playCombat,
+ returnToScene,
+ stopAll,
+ playCue,
+ onStateChange(listener) {
+ stateListener = typeof listener === "function" ? listener : null;
+ },
+ getState() {
+ return { mode, soundscape: ENC.deepClone(soundscape) };
+ },
+ };
+})(window.ENC);
diff --git a/programs/app/js/modules/combat.js b/programs/app/js/modules/combat.js
index ba5e6ce..3438841 100644
--- a/programs/app/js/modules/combat.js
+++ b/programs/app/js/modules/combat.js
@@ -540,6 +540,7 @@ ENC.combat = {
.classList.toggle("hidden", this.encounter.combatants.length > 0);
this.renderLibrarySelect();
this.renderPresetControls();
+ ENC.encounterUI?.renderPhase?.();
},
renderLibrarySelect() {
@@ -626,11 +627,8 @@ ENC.combat = {
);
await this.save("preset loaded");
this.render();
- ENC.assets.fillSelect(
- document.getElementById("backgroundSelect"),
- "backgrounds/",
- this.encounter.background,
- );
+ ENC.app?.refreshEncounterMediaControls?.();
+ ENC.soundscapeUI?.render?.();
},
async newEncounter() {
@@ -660,19 +658,22 @@ ENC.combat = {
ENC.DEFAULT_SETTINGS.customProfile,
);
+ ENC.audio?.stopAll(250);
this.encounter = ENC.createDefaultEncounter();
this.encounter.systemId = systemId;
this.encounter.customProfile = customProfile;
+ this.encounter.phase = "prepared";
+ this.encounter.playerDisplayMode = "standby";
+ ENC.app.playerDisplayMode = "standby";
+ ENC.app.playerDisplayPaused = true;
await this.save();
ENC.app?.renderSnapshots?.();
this.render();
- ENC.assets.fillSelect(
- document.getElementById("backgroundSelect"),
- "backgrounds/",
- "",
- );
+ ENC.app?.refreshEncounterMediaControls?.();
+ ENC.soundscapeUI?.render?.();
+ ENC.app?.broadcastDisplayState?.();
ENC.app?.toast("New encounter ready.");
},
@@ -727,6 +728,16 @@ ENC.combat = {
.getElementById("backgroundSelect")
.addEventListener("change", async (event) => {
this.encounter.background = event.target.value;
+ this.encounter.display = this.encounter.display || {};
+ this.encounter.display.combatImage = event.target.value;
+ await this.save();
+ this.render();
+ });
+ document
+ .getElementById("sceneImageSelect")
+ ?.addEventListener("change", async (event) => {
+ this.encounter.display = this.encounter.display || {};
+ this.encounter.display.sceneImage = event.target.value;
await this.save();
this.render();
});
@@ -738,11 +749,9 @@ ENC.combat = {
try {
const result = await ENC.assets.upload(file, "backgrounds/custom");
this.encounter.background = result.url;
- ENC.assets.fillSelect(
- document.getElementById("backgroundSelect"),
- "backgrounds/",
- result.url,
- );
+ this.encounter.display = this.encounter.display || {};
+ this.encounter.display.combatImage = result.url;
+ ENC.app?.refreshEncounterMediaControls?.();
await this.save();
this.render();
ENC.app.toast("Background imported.");
diff --git a/programs/app/js/modules/encounters.js b/programs/app/js/modules/encounters.js
new file mode 100644
index 0000000..dab6e40
--- /dev/null
+++ b/programs/app/js/modules/encounters.js
@@ -0,0 +1,236 @@
+/*
+ * Copyright (C) 2026 Zygons
+ * SPDX-License-Identifier: MIT
+ * This file is part of enCounter. See LICENSE for the full license terms.
+ */
+
+ENC.encounterUI = {
+ saved: [],
+
+ getPreparedCues() {
+ const soundscape = ENC.normalizeSoundscape(ENC.combat?.encounter?.soundscape);
+ return (soundscape.cues || []).filter((cue) => cue?.asset);
+ },
+
+ renderCues() {
+ const container = document.getElementById("encounterCueButtons");
+ if (!container) return;
+
+ container.innerHTML = "";
+ const cues = this.getPreparedCues();
+
+ if (!cues.length) {
+ const empty = document.createElement("span");
+ empty.className = "hint";
+ empty.textContent = "No prepared sound cues for this encounter.";
+ container.appendChild(empty);
+ return;
+ }
+
+ cues.forEach((cue, index) => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "encounter-cue-button";
+ button.textContent = cue.name || `Cue ${index + 1}`;
+ button.title = `Play ${cue.name || `Cue ${index + 1}`}`;
+
+ button.addEventListener("click", async () => {
+ const encounter = ENC.combat?.encounter;
+ if (!encounter) return;
+
+ // Use the soundscape belonging to the loaded working encounter.
+ ENC.audio?.setSoundscape(encounter.soundscape);
+ button.classList.add("is-playing");
+
+ try {
+ await ENC.audio?.playCue(cue);
+ } catch (error) {
+ ENC.app.toast(error.message || "Sound cue could not start.");
+ } finally {
+ window.setTimeout(() => button.classList.remove("is-playing"), 220);
+ }
+ });
+
+ container.appendChild(button);
+ });
+ },
+
+ async refreshSaved() {
+ this.saved = await ENC.db.getSavedEncounters();
+ const select = document.getElementById("savedEncounterSelect");
+ if (!select) return;
+ const selected = select.value;
+ select.innerHTML = '
Load saved encounter… ';
+ for (const encounter of this.saved) {
+ const option = document.createElement("option");
+ option.value = encounter.id;
+ option.textContent = `${encounter.name} — ${ENC.getSystemLabel(encounter.systemId, encounter.customProfile)}`;
+ select.appendChild(option);
+ }
+ if (this.saved.some((item) => item.id === selected)) select.value = selected;
+ },
+
+ renderPhase() {
+ const encounter = ENC.combat?.encounter;
+ if (!encounter) return;
+ const phase = encounter.phase || "prepared";
+ const badge = document.getElementById("encounterPhaseStatus");
+ if (badge) badge.textContent = phase.toUpperCase();
+
+ const sceneButton = document.getElementById("showSceneBtn");
+ const combatButton = document.getElementById("startCombatBtn");
+ const endButton = document.getElementById("endCombatBtn");
+ if (sceneButton) sceneButton.disabled = phase === "scene" && encounter.playerDisplayMode === "scene";
+ if (combatButton) combatButton.disabled = phase === "combat";
+ if (endButton) endButton.disabled = phase !== "combat";
+
+ const sourceText = document.getElementById("loadedEncounterSource");
+ if (sourceText) {
+ const source = this.saved.find((item) => item.id === encounter.sourceEncounterId);
+ sourceText.textContent = source ? `Loaded from: ${source.name}` : "Unsaved working encounter";
+ }
+
+ this.renderCues();
+ },
+
+ async saveCurrent(saveAs = false) {
+ const encounter = ENC.combat?.encounter;
+ if (!encounter) return;
+ let existingId = saveAs ? null : encounter.sourceEncounterId;
+ let name = encounter.name;
+ if (ENC.soundscapeUI?.collect) {
+ encounter.soundscape = ENC.soundscapeUI.collect();
+ ENC.audio?.setSoundscape(encounter.soundscape);
+ }
+ if (saveAs || !existingId) {
+ const requested = prompt("Saved encounter name:", encounter.name || "Encounter");
+ if (!requested?.trim()) return;
+ name = requested.trim();
+ }
+
+ const saved = await ENC.db.saveEncounterTemplate(encounter, name, existingId);
+ encounter.sourceEncounterId = saved.id;
+ encounter.name = saved.name;
+ await ENC.combat.save(saveAs ? "encounter saved as" : "encounter template saved");
+ await this.refreshSaved();
+ this.renderPhase();
+ ENC.app.toast(saveAs || !existingId ? "Encounter saved to Library." : "Saved encounter updated.");
+ },
+
+ async loadSelected() {
+ const select = document.getElementById("savedEncounterSelect");
+ const id = select?.value;
+ if (!id) return ENC.app.toast("Choose a saved encounter first.");
+ const item = this.saved.find((entry) => entry.id === id);
+ if (!item) return;
+ if (!confirm(`Load “${item.name}”? Your current working encounter will be replaced.`)) return;
+
+ ENC.audio?.stopAll(250);
+ const loaded = await ENC.db.loadSavedEncounter(id);
+ if (!loaded) return ENC.app.toast("Saved encounter could not be loaded.");
+ ENC.combat.encounter = loaded;
+ ENC.audio?.setSoundscape(loaded.soundscape);
+ ENC.app.playerDisplayPaused = true;
+ ENC.app.playerDisplayMode = "standby";
+ ENC.app.broadcastDisplayState?.();
+ ENC.app.updatePlayerDisplayButton?.();
+ ENC.combat.render();
+ ENC.app.refreshEncounterMediaControls?.();
+ ENC.soundscapeUI?.render?.();
+ this.renderPhase();
+ ENC.app.toast(`Loaded ${loaded.name}.`);
+ },
+
+ async duplicateSelected() {
+ const id = document.getElementById("savedEncounterSelect")?.value;
+ if (!id) return ENC.app.toast("Choose a saved encounter first.");
+ const copy = await ENC.db.duplicateSavedEncounter(id);
+ if (!copy) return;
+ await this.refreshSaved();
+ document.getElementById("savedEncounterSelect").value = copy.id;
+ ENC.app.toast(`Created ${copy.name}.`);
+ },
+
+ async deleteSelected() {
+ const id = document.getElementById("savedEncounterSelect")?.value;
+ if (!id) return ENC.app.toast("Choose a saved encounter first.");
+ const item = this.saved.find((entry) => entry.id === id);
+ if (!item) return;
+ if (!confirm(`Delete saved encounter “${item.name}”? This does not delete the currently loaded working copy.`)) return;
+ await ENC.db.delete("encounters", id);
+ if (ENC.combat.encounter?.sourceEncounterId === id) {
+ ENC.combat.encounter.sourceEncounterId = null;
+ await ENC.combat.save();
+ }
+ await this.refreshSaved();
+ this.renderPhase();
+ ENC.app.toast("Saved encounter deleted.");
+ },
+
+ async setPhase(phase, displayMode) {
+ const encounter = ENC.combat?.encounter;
+ if (!encounter) return;
+ encounter.phase = phase;
+ encounter.playerDisplayMode = displayMode;
+
+ if (phase === "combat") {
+ encounter.round = Math.max(1, Number(encounter.round || 1));
+ if (!encounter.currentId) {
+ ENC.sortCombatants(encounter.combatants);
+ encounter.currentId = encounter.combatants.find(ENC.isTurnEligibleCombatant)?.id || null;
+ }
+ if (encounter.soundscape?.autoSwitchToCombat) {
+ ENC.audio?.setSoundscape(encounter.soundscape);
+ try { await ENC.audio?.playCombat(); } catch (error) { ENC.app.toast(error.message); }
+ }
+ } else if (phase === "scene" && encounter.soundscape?.returnToSceneAfterCombat && ENC.audio?.mode === "combat") {
+ ENC.audio?.setSoundscape(encounter.soundscape);
+ try { await ENC.audio?.returnToScene(); } catch (error) { ENC.app.toast(error.message); }
+ }
+
+ await ENC.combat.save(`phase changed: ${phase}`);
+ ENC.app.setPlayerDisplayMode?.(displayMode);
+ ENC.combat.render();
+ this.renderPhase();
+ },
+
+ async startSceneAudio() {
+ const encounter = ENC.combat?.encounter;
+ if (!encounter) return;
+ ENC.audio.setSoundscape(encounter.soundscape);
+ try {
+ await ENC.audio.playScene();
+ ENC.soundscapeUI?.renderStatus?.();
+ ENC.app.toast("Scene soundscape started.");
+ } catch (error) {
+ ENC.app.toast(error.message);
+ }
+ },
+
+ stopAudio() {
+ ENC.audio?.stopAll();
+ ENC.soundscapeUI?.renderStatus?.();
+ ENC.app.toast("Audio stopped.");
+ },
+
+ bind() {
+ document.getElementById("saveEncounterLibraryBtn")?.addEventListener("click", () => this.saveCurrent(false));
+ document.getElementById("saveEncounterAsBtn")?.addEventListener("click", () => this.saveCurrent(true));
+ document.getElementById("loadSavedEncounterBtn")?.addEventListener("click", () => this.loadSelected());
+ document.getElementById("duplicateSavedEncounterBtn")?.addEventListener("click", () => this.duplicateSelected());
+ document.getElementById("deleteSavedEncounterBtn")?.addEventListener("click", () => this.deleteSelected());
+
+ document.getElementById("showSceneBtn")?.addEventListener("click", () => this.setPhase("scene", "scene"));
+ document.getElementById("startCombatBtn")?.addEventListener("click", () => this.setPhase("combat", "combat"));
+ document.getElementById("endCombatBtn")?.addEventListener("click", () => this.setPhase("scene", "scene"));
+ document.getElementById("hideEncounterDisplayBtn")?.addEventListener("click", () => ENC.app.setPlayerDisplayMode?.("standby"));
+ document.getElementById("startSceneAudioBtn")?.addEventListener("click", () => this.startSceneAudio());
+ document.getElementById("stopEncounterAudioBtn")?.addEventListener("click", () => this.stopAudio());
+ },
+
+ async initialize() {
+ await this.refreshSaved();
+ this.bind();
+ this.renderPhase();
+ },
+};
diff --git a/programs/app/js/modules/soundscape.js b/programs/app/js/modules/soundscape.js
new file mode 100644
index 0000000..3b3ca15
--- /dev/null
+++ b/programs/app/js/modules/soundscape.js
@@ -0,0 +1,182 @@
+/*
+ * Copyright (C) 2026 Zygons
+ * SPDX-License-Identifier: MIT
+ * This file is part of enCounter. See LICENSE for the full license terms.
+ */
+
+ENC.soundscapeUI = {
+ trackFromRow(prefix, defaultName, defaultVolume = 0.4, defaultBehavior = "stop") {
+ const asset = document.getElementById(`${prefix}Asset`)?.value || "";
+ const volume = Number(document.getElementById(`${prefix}Volume`)?.value ?? defaultVolume * 100) / 100;
+ const behavior = document.getElementById(`${prefix}Behavior`)?.value || defaultBehavior;
+ const combatVolume = Number(document.getElementById(`${prefix}CombatVolume`)?.value ?? 15) / 100;
+ return ENC.normalizeAudioTrack({
+ name: defaultName,
+ asset,
+ volume,
+ loop: true,
+ combatBehavior: behavior,
+ combatVolume,
+ }, { name: defaultName, volume: defaultVolume, combatBehavior: defaultBehavior });
+ },
+
+ cueFromRow(index) {
+ const asset = document.getElementById(`cue${index}Asset`)?.value || "";
+ const name = document.getElementById(`cue${index}Name`)?.value.trim() || `Cue ${index}`;
+ const volume = Number(document.getElementById(`cue${index}Volume`)?.value ?? 80) / 100;
+ return { ...ENC.normalizeAudioTrack({ name, asset, volume, loop: false }, { name, volume: 0.8 }), loop: false };
+ },
+
+ collect() {
+ const sceneAmbience = [1, 2, 3]
+ .map((index) => this.trackFromRow(`sceneAmb${index}`, `Scene Ambience ${index}`, 0.4, "duck"))
+ .filter((track) => track.asset);
+ const combatAmbience = [1, 2]
+ .map((index) => this.trackFromRow(`combatAmb${index}`, `Combat Ambience ${index}`, 0.35, "continue"))
+ .filter((track) => track.asset);
+ const cues = [1, 2, 3]
+ .map((index) => this.cueFromRow(index))
+ .filter((track) => track.asset);
+
+ return ENC.normalizeSoundscape({
+ enabled: true,
+ masterVolume: Number(document.getElementById("soundscapeMasterVolume")?.value ?? 80) / 100,
+ fadeMs: Number(document.getElementById("soundscapeFadeSeconds")?.value ?? 2.5) * 1000,
+ autoSwitchToCombat: document.getElementById("soundscapeAutoCombat")?.checked !== false,
+ returnToSceneAfterCombat: document.getElementById("soundscapeReturnScene")?.checked !== false,
+ scene: {
+ music: this.trackFromRow("sceneMusic", "Scene Music", 0.35, "stop"),
+ ambience: sceneAmbience,
+ },
+ combat: {
+ music: this.trackFromRow("combatMusic", "Combat Music", 0.65, "continue"),
+ ambience: combatAmbience,
+ },
+ cues,
+ });
+ },
+
+ setTrackRow(prefix, track = {}, behavior = "stop") {
+ const asset = document.getElementById(`${prefix}Asset`);
+ const volume = document.getElementById(`${prefix}Volume`);
+ const behaviorSelect = document.getElementById(`${prefix}Behavior`);
+ const combatVolume = document.getElementById(`${prefix}CombatVolume`);
+ if (asset) asset.value = track.asset || "";
+ if (volume) volume.value = Math.round(Number(track.volume ?? 0.4) * 100);
+ if (behaviorSelect) behaviorSelect.value = track.combatBehavior || behavior;
+ if (combatVolume) combatVolume.value = Math.round(Number(track.combatVolume ?? 0.15) * 100);
+ },
+
+ refreshAudioSelects() {
+ const audioItems = ENC.assets.byPrefix("sounds/");
+ for (const select of document.querySelectorAll("select[data-audio-select]")) {
+ const selected = select.value;
+ const prefix = select.dataset.audioPrefix || "sounds/";
+ select.innerHTML = '
— None — ';
+ for (const item of audioItems.filter((entry) => entry.category.startsWith(prefix))) {
+ const option = document.createElement("option");
+ option.value = item.url;
+ option.textContent = `${item.category.replace("sounds/", "")} / ${item.name}`;
+ select.appendChild(option);
+ }
+ if ([...select.options].some((option) => option.value === selected)) select.value = selected;
+ }
+ },
+
+ render() {
+ const soundscape = ENC.normalizeSoundscape(ENC.combat?.encounter?.soundscape);
+ this.refreshAudioSelects();
+ const master = document.getElementById("soundscapeMasterVolume");
+ const fade = document.getElementById("soundscapeFadeSeconds");
+ const auto = document.getElementById("soundscapeAutoCombat");
+ const returnScene = document.getElementById("soundscapeReturnScene");
+ if (master) master.value = Math.round(soundscape.masterVolume * 100);
+ if (fade) fade.value = soundscape.fadeMs / 1000;
+ if (auto) auto.checked = soundscape.autoSwitchToCombat;
+ if (returnScene) returnScene.checked = soundscape.returnToSceneAfterCombat;
+
+ this.setTrackRow("sceneMusic", soundscape.scene.music, "stop");
+ [1, 2, 3].forEach((index) => this.setTrackRow(`sceneAmb${index}`, soundscape.scene.ambience[index - 1] || {}, "duck"));
+ this.setTrackRow("combatMusic", soundscape.combat.music, "continue");
+ [1, 2].forEach((index) => this.setTrackRow(`combatAmb${index}`, soundscape.combat.ambience[index - 1] || {}, "continue"));
+
+ [1, 2, 3].forEach((index) => {
+ const cue = soundscape.cues[index - 1] || {};
+ const name = document.getElementById(`cue${index}Name`);
+ const asset = document.getElementById(`cue${index}Asset`);
+ const volume = document.getElementById(`cue${index}Volume`);
+ if (name) name.value = cue.name || `Cue ${index}`;
+ if (asset) asset.value = cue.asset || "";
+ if (volume) volume.value = Math.round(Number(cue.volume ?? 0.8) * 100);
+ });
+ this.renderStatus();
+ ENC.encounterUI?.renderCues?.();
+ },
+
+ renderStatus() {
+ const status = document.getElementById("soundscapeStatus");
+ const live = document.getElementById("encounterAudioStatus");
+ const label = ENC.audio?.mode === "scene" ? "SCENE AUDIO PLAYING" : ENC.audio?.mode === "combat" ? "COMBAT AUDIO PLAYING" : "AUDIO STOPPED";
+ if (status) status.textContent = label;
+ if (live) live.textContent = label;
+ },
+
+ async save() {
+ if (!ENC.combat?.encounter) return;
+ ENC.combat.encounter.soundscape = this.collect();
+ ENC.audio.setSoundscape(ENC.combat.encounter.soundscape);
+ await ENC.combat.save("soundscape updated");
+ this.renderStatus();
+ ENC.encounterUI?.renderCues?.();
+ ENC.app.toast("Soundscape applied to the working encounter. Use Encounter → Save to update the Saved Encounter template.");
+ },
+
+ async previewScene() {
+ ENC.combat.encounter.soundscape = this.collect();
+ ENC.audio.setSoundscape(ENC.combat.encounter.soundscape);
+ try { await ENC.audio.playScene(); this.renderStatus(); } catch (error) { ENC.app.toast(error.message); }
+ },
+
+ async previewCombat() {
+ ENC.combat.encounter.soundscape = this.collect();
+ ENC.audio.setSoundscape(ENC.combat.encounter.soundscape);
+ try { await ENC.audio.playCombat(); this.renderStatus(); } catch (error) { ENC.app.toast(error.message); }
+ },
+
+ async playCue(index) {
+ const cue = this.cueFromRow(index);
+ if (!cue.asset) return ENC.app.toast("Choose a sound for this cue first.");
+ try { await ENC.audio.playCue(cue); } catch (error) { ENC.app.toast(error.message); }
+ },
+
+ bind() {
+ document.getElementById("saveSoundscapeBtn")?.addEventListener("click", () => this.save());
+ document.getElementById("previewSceneAudioBtn")?.addEventListener("click", () => this.previewScene());
+ document.getElementById("previewCombatAudioBtn")?.addEventListener("click", () => this.previewCombat());
+ document.getElementById("stopSoundscapeBtn")?.addEventListener("click", () => { ENC.audio.stopAll(); this.renderStatus(); });
+ [1, 2, 3].forEach((index) => document.getElementById(`playCue${index}Btn`)?.addEventListener("click", () => this.playCue(index)));
+
+ document.getElementById("soundscapeMasterVolume")?.addEventListener("input", (event) => ENC.audio.setMasterVolume(Number(event.target.value) / 100));
+
+ document.getElementById("uploadAudioBtn")?.addEventListener("click", async () => {
+ const file = document.getElementById("audioUploadFile")?.files[0];
+ const category = document.getElementById("audioUploadCategory")?.value || "sounds/custom";
+ if (!file) return ENC.app.toast("Choose an audio file first.");
+ try {
+ await ENC.assets.upload(file, category);
+ this.refreshAudioSelects();
+ ENC.app.toast("Audio imported.");
+ } catch (error) {
+ ENC.app.toast(error.message);
+ }
+ });
+
+ ENC.audio.onStateChange(() => this.renderStatus());
+ },
+
+ initialize() {
+ ENC.audio.setSoundscape(ENC.combat?.encounter?.soundscape);
+ this.bind();
+ this.render();
+ },
+};
diff --git a/programs/launcher.py b/programs/launcher.py
index b4c1851..bae6095 100644
--- a/programs/launcher.py
+++ b/programs/launcher.py
@@ -53,9 +53,14 @@
"icons/combat",
"icons/systems",
"tokens",
+ "sounds/music",
+ "sounds/ambience",
+ "sounds/sfx",
+ "sounds/custom",
}
ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
-PUBLIC_ASSET_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS | {".ico", ".wav", ".mp3", ".ogg", ".flac"}
+ALLOWED_AUDIO_EXTENSIONS = {".wav", ".mp3", ".ogg", ".flac"}
+PUBLIC_ASSET_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS | ALLOWED_AUDIO_EXTENSIONS | {".ico"}
APP_FILE_EXTENSIONS = {".html", ".css", ".js", ".map"}
DOC_FILE_EXTENSIONS = {".html", ".md", ".txt"}
PUBLIC_ROOT_FILES = {
@@ -66,7 +71,7 @@
"/THIRD_PARTY_NOTICES.md",
"/CHANGELOG.md",
}
-MAX_UPLOAD_BYTES = 10 * 1024 * 1024
+MAX_UPLOAD_BYTES = 50 * 1024 * 1024
MAX_BACKUP_BYTES = 50 * 1024 * 1024
for path in [BACKUP_DIR, EXPORT_DIR, IMPORT_DIR]:
@@ -117,6 +122,21 @@ def detect_image_type(data: bytes) -> str | None:
return None
+def detect_audio_type(data: bytes) -> str | None:
+ """Return a normalized MIME type for supported audio signatures, or None."""
+ if data.startswith(b"ID3"):
+ return "audio/mpeg"
+ if len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0:
+ return "audio/mpeg"
+ if data.startswith(b"OggS"):
+ return "audio/ogg"
+ if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WAVE":
+ return "audio/wav"
+ if data.startswith(b"fLaC"):
+ return "audio/flac"
+ return None
+
+
class EnCounterHandler(http.server.SimpleHTTPRequestHandler):
server_version = f"{APP_NAME}Local/{APP_VERSION}"
@@ -273,7 +293,9 @@ def do_GET(self):
if relative.startswith("branding/"):
continue
- if file_path.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS:
+ if file_path.suffix.lower() not in PUBLIC_ASSET_EXTENSIONS:
+ continue
+ if file_path.suffix.lower() == ".ico":
continue
category = Path(relative).parent.as_posix()
@@ -328,27 +350,53 @@ def do_POST(self):
content_length = self._read_content_length(MAX_UPLOAD_BYTES)
if content_length is None:
- return self.send_text("Image must be between 1 byte and 10 MB.", 400)
+ return self.send_text("Asset must be between 1 byte and 50 MB.", 400)
filename = safe_name(requested_name)
extension = Path(filename).suffix.lower()
- if extension not in ALLOWED_IMAGE_EXTENSIONS:
- return self.send_text("Only PNG, JPEG, and WebP images are allowed.", 400)
+ is_audio = category.startswith("sounds/")
+ allowed_extensions = ALLOWED_AUDIO_EXTENSIONS if is_audio else ALLOWED_IMAGE_EXTENSIONS
+ if extension not in allowed_extensions:
+ return self.send_text(
+ "Only MP3, OGG, WAV, and FLAC audio files are allowed."
+ if is_audio
+ else "Only PNG, JPEG, and WebP images are allowed.",
+ 400,
+ )
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].lower()
- expected_mime = {
- ".png": "image/png",
- ".jpg": "image/jpeg",
- ".jpeg": "image/jpeg",
- ".webp": "image/webp",
- }[extension]
- if content_type != expected_mime:
- return self.send_text("The file extension does not match its image type.", 400)
-
- data = self.rfile.read(content_length)
- detected_mime = detect_image_type(data)
- if detected_mime != expected_mime:
- return self.send_text("The uploaded file is not a valid supported image.", 400)
+ if is_audio:
+ allowed_mimes = {
+ ".mp3": {"audio/mpeg", "audio/mp3"},
+ ".ogg": {"audio/ogg", "application/ogg"},
+ ".wav": {"audio/wav", "audio/x-wav", "audio/wave"},
+ ".flac": {"audio/flac", "audio/x-flac"},
+ }
+ if content_type not in allowed_mimes[extension]:
+ return self.send_text("The file extension does not match its audio type.", 400)
+ data = self.rfile.read(content_length)
+ detected_audio = detect_audio_type(data)
+ normalized_expected = {
+ ".mp3": "audio/mpeg",
+ ".ogg": "audio/ogg",
+ ".wav": "audio/wav",
+ ".flac": "audio/flac",
+ }[extension]
+ if detected_audio != normalized_expected:
+ return self.send_text("The uploaded file is not valid supported audio.", 400)
+ else:
+ expected_mime = {
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".webp": "image/webp",
+ }[extension]
+ if content_type != expected_mime:
+ return self.send_text("The file extension does not match its image type.", 400)
+ data = self.rfile.read(content_length)
+ detected_mime = detect_image_type(data)
+ if detected_mime != expected_mime:
+ return self.send_text("The uploaded file is not a valid supported image.", 400)
folder = ROOT / "assets" / category
folder.mkdir(parents=True, exist_ok=True)
diff --git a/tests/core.test.js b/tests/core.test.js
index 68e47f8..3b4177d 100644
--- a/tests/core.test.js
+++ b/tests/core.test.js
@@ -194,3 +194,52 @@ test("player display next-turn helper can advance from a hidden current combatan
const next = ENC.findNextPlayerVisibleEligibleCombatant(combatants, "hidden");
assert.equal(next?.id, "c");
});
+
+test("sanitizeAudioUrl accepts only local supported sound assets", () => {
+ assert.equal(
+ ENC.sanitizeAudioUrl("/assets/sounds/ambience/forest.mp3"),
+ "/assets/sounds/ambience/forest.mp3",
+ );
+ assert.equal(ENC.sanitizeAudioUrl("https://example.com/forest.mp3"), "");
+ assert.equal(ENC.sanitizeAudioUrl("/assets/backgrounds/forest.mp3"), "");
+});
+
+test("normalizeEncounter preserves scene/combat display modes and soundscape", () => {
+ const encounter = ENC.normalizeEncounter({
+ kind: "saved",
+ phase: "scene",
+ playerDisplayMode: "scene",
+ display: {
+ sceneImage: "/assets/backgrounds/fantasy/market.png",
+ combatImage: "/assets/backgrounds/fantasy/battle.png",
+ },
+ soundscape: {
+ masterVolume: 0.75,
+ scene: {
+ ambience: [
+ {
+ name: "Market",
+ asset: "/assets/sounds/ambience/market.mp3",
+ volume: 0.5,
+ combatBehavior: "duck",
+ combatVolume: 0.2,
+ },
+ ],
+ },
+ combat: {
+ music: {
+ name: "Battle",
+ asset: "/assets/sounds/music/battle.mp3",
+ volume: 0.6,
+ },
+ },
+ },
+ });
+
+ assert.equal(encounter.kind, "saved");
+ assert.equal(encounter.phase, "scene");
+ assert.equal(encounter.playerDisplayMode, "scene");
+ assert.equal(encounter.display.sceneImage, "/assets/backgrounds/fantasy/market.png");
+ assert.equal(encounter.soundscape.scene.ambience[0].combatBehavior, "duck");
+ assert.equal(encounter.soundscape.combat.music.asset, "/assets/sounds/music/battle.mp3");
+});
From c94fb6c10dd13de79f1a7b791b4364a1f38e932f Mon Sep 17 00:00:00 2001
From: Zygons <30783491+zygons@users.noreply.github.com>
Date: Mon, 21 Sep 2026 15:02:39 -0400
Subject: [PATCH 3/3] Updated Documentation and User Guide - Version
0.2.0-alpha.1
---
Build enCounter Linux.sh | 2 +-
Build enCounter Windows.bat | 4 +-
CHANGELOG.md | 190 ++++++++++++++++++++++++--
README.md | 132 +++++++++++++++---
Start enCounter.bat | 2 +-
docs/AUDIO_AND_ENCOUNTER_WORKFLOW.md | 148 +++++++++++++++++---
docs/LEGAL.html | 2 +-
docs/VERSION.txt | 4 +-
docs/enCounter User Guide.pdf | Bin 1418444 -> 27612686 bytes
programs/app/index.html | 2 +-
programs/app/js/core/config.js | 2 +-
programs/app/js/modules/combat.js | 29 +++-
programs/app/js/modules/encounters.js | 129 +++++++++++++----
programs/enCounter_version_info.txt | 8 +-
programs/launcher.py | 2 +-
15 files changed, 567 insertions(+), 89 deletions(-)
diff --git a/Build enCounter Linux.sh b/Build enCounter Linux.sh
index 6579bfe..1f06eba 100644
--- a/Build enCounter Linux.sh
+++ b/Build enCounter Linux.sh
@@ -6,7 +6,7 @@
set -euo pipefail
cd "$(dirname "$0")"
-VERSION="0.1.0-alpha.1"
+VERSION="0.2.0-alpha.1"
ARCH="$(uname -m)"
case "${ARCH}" in
amd64) ARCH="x86_64" ;;
diff --git a/Build enCounter Windows.bat b/Build enCounter Windows.bat
index 858be2d..c988248 100644
--- a/Build enCounter Windows.bat
+++ b/Build enCounter Windows.bat
@@ -6,9 +6,9 @@ REM See LICENSE for the full license terms.
setlocal EnableExtensions
cd /d "%~dp0"
-title enCounter 0.1.0-alpha.1 Windows Build
+title enCounter 0.2.0-alpha.1 Windows Build
-set "VERSION=0.1.0-alpha.1"
+set "VERSION=0.2.0-alpha.1"
set "RELEASE_ROOT=release"
set "RELEASE_NAME=enCounter-v%VERSION%-Windows-Portable"
set "RELEASE_DIR=%RELEASE_ROOT%\%RELEASE_NAME%"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce52119..60de100 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,55 +4,223 @@ All notable changes to enCounter will be documented here.
## Unreleased
+No changes recorded yet.
+
+## 0.2.0-alpha.1 - 2026-09-13
+
### Added
-- Saved Encounter Library workflow with Save, Save As, Load, Duplicate, and Delete while keeping the loaded encounter as a working copy.
-- Encounter phases for prepared, scene/roleplay, combat, and complete states.
-- Player Display Scene mode that shows encounter artwork without initiative/combat information while still allowing visual timers.
-- Encounter Soundscape system with scene music, layered ambience, combat music/ambience, fade transitions, master volume, prepared one-shot cues, and scene-to-combat switching.
-- Audio asset folders and import support for MP3, OGG, WAV, and FLAC files.
-- Separate Scene Image and Combat Background selections for prepared encounters.
-- Live Prepared Cue buttons on the Encounter tab, populated automatically from the loaded encounter's Soundscape and usable during both Scene and Combat phases.
+
+- Saved Encounter Library workflow with **Save**, **Save As**, **Load**, **Duplicate**, and **Delete** while keeping the loaded encounter as a working copy so game-session changes do not automatically overwrite the reusable Saved Encounter.
+
+- Encounter phases for **Prepared**, **Scene / Roleplay**, **Combat**, and **Complete** states.
+
+- Player Display **Scene mode** that shows encounter artwork without initiative or combat information while still allowing visual timers.
+
+- Separate **Scene Image** and **Combat Background** selections for prepared encounters.
+
+- Visual Timer system for Scene and Combat encounters.
+
+- Visual Timer controls for:
+ - Start
+ - Pause
+ - Resume
+ - Reset
+ - Stop
+ - Add 10 seconds
+ - Subtract 10 seconds
+ - Add 1 minute
+ - Subtract 1 minute
+
+- Three visual timer skins:
+ - Fantasy Hourglass
+ - Candle
+ - Sci-Fi Pixel Depletion
+
+- Timer display modes for different presentation needs, including overlay, featured, and focus-style presentation.
+
+- Independent options for showing the timer and numeric countdown on the DM Console and Player Display.
+
+- Synchronized Player Display timer state so the DM and player-facing displays remain aligned during a running countdown.
+
+- Encounter Soundscape system with:
+ - Scene music
+ - Layered scene ambience
+ - Combat music
+ - Combat ambience
+ - Master volume
+ - Fade transitions
+ - Scene-to-combat audio switching
+ - Optional return to scene audio after combat
+
+- Per-ambience combat transition behavior, allowing scene ambience to continue, lower in volume, or stop when combat begins.
+
+- Audio asset folders and import support for:
+ - MP3
+ - OGG
+ - WAV
+ - FLAC
+
+- Prepared one-shot audio cues that can be configured as part of an encounter.
+
+- Live **Prepared Cue** buttons on the Encounter tab, populated automatically from the loaded encounter's Soundscape and usable during both Scene and Combat phases.
+
+- Player Display initiative auto-centering. The active visible combatant is automatically brought into view and centered as initiative advances.
+
+- DM initiative tracking and Auto-Follow.
+
+- The DM Console now follows the current initiative order and clearly identifies the combatant whose turn is currently active.
+
+- The DM Console automatically brings the active combatant into view when:
+ - Combat starts.
+ - **Next Turn** is selected.
+ - **Previous Turn** is selected.
+ - **Set Turn** is used.
+ - A delayed or readied combatant resumes and takes the current turn.
+
+- Current-turn highlighting on the DM Console remains synchronized with the Player Display so both screens reflect the same active combatant.
### Changed
-- Player Display presentation is now modeled as Stand By, Scene, or Combat instead of only visible/hidden.
-- Start Combat can automatically transition scene audio to combat audio; End Combat can restore prior scene audio when appropriate.
-- Encounter data normalization now preserves soundscape, display, phase, and saved-template metadata.
-- Soundscape editor wording now makes clear that Soundscapes are encounter-bound and load automatically with Saved Encounters.
+- Player Display presentation is now modeled as **Stand By**, **Scene**, or **Combat** instead of only visible or hidden.
+
+- **Start Combat** can automatically transition scene audio to combat audio when configured.
+
+- **End Combat** can automatically restore the previous scene Soundscape when configured.
+
+- Encounter data normalization now preserves Soundscape, display, phase, scene/combat image, and Saved Encounter metadata.
+
+- Soundscape configuration is now encounter-bound rather than maintained as a separate Soundscape Library.
+
+- Loading a Saved Encounter restores its Soundscape configuration and Prepared Cue assignments.
+
+- Loading a Saved Encounter does **not** automatically begin audio playback. Scene or Combat audio remains under DM control.
+
+- Soundscape editor wording now makes clear that Soundscapes are stored with and loaded automatically from Saved Encounters.
+
+- Encounter remains the primary live game-control screen and now includes Saved Encounter controls, Scene/Combat phase controls, scene/combat images, audio controls, and Prepared Cue buttons.
+
+- Timer and Soundscape configuration are available as dedicated DM Console navigation sections.
+
+- Improved synchronization between Scene, Combat, timer, Soundscape, initiative, and Player Display state.
+
+- Initiative order on both the DM Console and Player Display now tracks the same current combatant as turns advance.
+
+- Player Display initiative cards automatically reposition so the current combatant remains visible and centered rather than moving off-screen during longer initiative orders.
+
+- DM initiative tracking now moves through the encounter order alongside the Player Display, making it easier for the DM to see:
+ - Who is currently acting.
+ - Where the current combatant is within the initiative order.
+ - Which combatant will act as initiative continues.
+
+- DM Auto-Follow occurs only when the active turn changes. Ordinary combatant edits do not force the DM screen back to the active combatant.
+
+- HP changes, damage/healing, status edits, Condition Track changes, resource changes, Hide/Reveal actions, and other non-turn edits leave the DM's current scroll position unchanged.
+
+- Improved initiative usability for larger encounters where the full combatant list cannot fit on the screen at once.
+
+### Notes
+
+- This is an **Alpha / pre-release** version of enCounter.
+
+- The Timer, Soundscape, Saved Encounter, Scene/Combat presentation, and initiative-follow systems remain Alpha features and may continue to evolve.
+
+- Player Display initiative centering and DM Auto-Follow use the same active encounter turn state, keeping DM and player-facing initiative presentation aligned.
+
+- DM Auto-Follow is intentionally triggered by turn changes rather than every encounter render so the DM can edit another combatant without the screen unexpectedly jumping away.
+
+- Audio configuration and media references are saved with Saved Encounters, but active playback state is not treated as permanent encounter state.
+
+- Imported audio files remain local assets and are not embedded directly into JSON backup/export files.
+
+- Export important Library and encounter data before upgrading between Alpha versions.
+
+## 0.1.0-alpha.2 - 2026-09-05
+
+### Maintenance
+
+- Published the second enCounter Alpha release and updated release-facing documentation for `0.1.0-alpha.2`.
+
+- Standardized documentation for the single-button Player Display workflow:
+ - **Start Player Display**
+ - **Hide Player Display**
+ - **Show Player Display**
+
+- Clarified the **STAND BY** privacy-screen workflow for making private DM changes without exposing them to players.
+
+- Clarified that the Player Display and DM Console must use the same browser profile for local synchronization.
+
+- Expanded Player Display synchronization and troubleshooting documentation, including DM Sync connected/disconnected behavior.
+
+- Updated Alpha documentation while retaining the existing initiative, hidden-combatant, backup, Library, and portable-build functionality established during the initial Alpha hardening cycle.
+
+### Notes
+
+- This release remained part of the original `0.1.0` Alpha development line.
+
+- The subsequent Saved Encounter, Scene/Combat presentation, Timer, Soundscape, and enhanced initiative-follow work was developed after this Alpha milestone and is included in `0.2.0-alpha.1`.
## 0.1.0-alpha.1 - 2026-08-20
### Added
+
- Initial enCounter Alpha release baseline.
+
- DM encounter console and separate Player Display.
+
- Reusable Library for players, NPCs, enemies, and creatures.
+
- Generic, SWSE, 5E, and custom system profiles.
+
- Local asset handling, backup/export/import, and recovery features.
+
- Clean Windows portable build workflow using PyInstaller.
+
- Clean Linux x86_64 portable build workflow using PyInstaller.
+
- AI Assistance Disclosure for development transparency.
### Distribution
+
- Standardized Alpha version identifiers.
+
- Isolated Alpha IndexedDB name (`enCounterAlphaDB`).
+
- Build scripts exclude prior user data and personal campaign assets.
+
- Licensed the project under the MIT License.
+
- Added Windows/Linux release SHA-256 checksum generation.
+
- Hardened GitHub Actions permissions and cross-platform Python invocation.
### Pre-publication hardening
+
- Restricted the local HTTP server to required public application resources instead of serving the entire project root.
+
- Added local Host/Origin checks, response security headers, and image signature validation for uploads.
+
- Added graceful application shutdown from the DM Console.
+
- Added BroadcastChannel fallback synchronization for browsers where BroadcastChannel is unavailable.
+
- Added stricter backup/import validation and atomic full-database imports.
+
- Fixed single Library-entry imports and made **Export Everything** include recovery snapshots.
+
- Fixed Settings module changes so the encounter UI rerenders immediately.
+
- Normalized imported settings, encounters, combatants, and local asset references before use.
+
- Removed duplicate encounter broadcasts from UI-only renders; encounter changes now broadcast from save operations.
+
- Added Player Display DM Sync connected/disconnected status and DM-presence heartbeats.
+
- Added a documented single-DM-window workflow warning for the current Alpha.
+
- Applied the same local Origin policy to GET/HEAD requests as POST requests when an Origin header is present.
+
- Added dependency-free Node unit tests for normalization, initiative sorting, asset sanitization, and turn-transition behavior.
+
- Changed hidden combatants so they remain fully visible to the DM but are completely omitted from the Player Display until **Reveal to Players** is clicked.
+
- Added Player Display visibility tests for hidden combatants and next-turn selection.
diff --git a/README.md b/README.md
index a0f5d20..7a0fb50 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# enCounter
-**enCounter** is a free, open-source, local-first TTRPG encounter and initiative manager with separate DM and Player Displays, reusable player/NPC/enemy/creature libraries, encounter controls, asset support, and local backup/export features.
+**enCounter** is a free, open-source, local-first TTRPG encounter, initiative, scene-presentation, timer, and soundscape manager with separate DM and Player Displays, reusable player/NPC/enemy/creature libraries, Saved Encounters, local media assets, and backup/export features.
-**Current release:** `0.1.0-alpha.1`
+**Current release:** `0.2.0-alpha.1`
**Status:** Alpha / pre-release
**License:** MIT
**Platforms:** Windows and Linux
@@ -24,32 +24,97 @@
- **Start Player Display**
- **Hide Player Display**
- **Show Player Display**
+- Player Display presentation modes:
+ - **STAND BY**
+ - **Scene**
+ - **Combat**
- Private DM editing while the Player Display shows **STAND BY**
-- Initiative and turn management
+- Initiative and turn management with synchronized DM and Player Display tracking
+- **DM Initiative Auto-Follow**
+ - follows the active combatant as turns advance
+ - clearly highlights who is currently acting
+ - automatically brings the current combatant into view
+ - does not interrupt unrelated HP, status, resource, or combatant edits
+- **Player Display initiative auto-centering**
+ - automatically centers the active visible combatant
+ - keeps the current turn visible during larger encounters
+- Automatic round advancement
+- Delay, Ready, Trigger / Resume, and Set Turn controls
- HP, status, condition, and combatant controls
- Hidden enemies with **Hide from Players / Reveal to Players**
- Reusable Player, NPC, Enemy, and Creature Library
-- Portrait and encounter background support
-- Saved Encounter Library with reusable prepared encounters and working-copy loading
-- Scene / roleplay Player Display mode with full-screen encounter artwork and optional visual timers
-- Encounter Soundscapes with scene ambience, combat music, transitions, and prepared sound cues
-- Separate scene and combat artwork per encounter
+- Saved Encounter Library with:
+ - Save
+ - Save As
+ - Load
+ - Duplicate
+ - Delete
+ - working-copy loading for game-session changes
+- Encounter phases for:
+ - Prepared
+ - Scene / Roleplay
+ - Combat
+ - Complete
+- Separate **Scene Image** and **Combat Background** per encounter
+- Scene / roleplay Player Display mode with full-screen encounter artwork
+- Visual countdown timers for Scene and Combat
+- Timer controls for Start, Pause, Resume, Reset, Stop, and time adjustments
+- Visual timer skins including:
+ - Fantasy Hourglass
+ - Candle
+ - Sci-Fi Pixel Depletion
+- Optional Player Display numeric countdown
+- Encounter Soundscapes with:
+ - scene music
+ - layered scene ambience
+ - combat music
+ - combat ambience
+ - master volume
+ - fade transitions
+ - automatic Scene-to-Combat switching
+ - optional return to Scene audio after combat
+- Prepared one-shot audio cues
+- Live Prepared Cue buttons directly on the Encounter screen
+- Local audio import support for MP3, OGG, WAV, and FLAC
+- Portrait and encounter-background support
- Generic TTRPG, SWSE, D&D 5E, and Custom system profiles
- Local autosave and recovery snapshots
- Backup, export, and import tools
- Windows and Linux portable builds
- Runs locally without requiring a cloud account
+## What's new in 0.2.0-alpha.1
+
+Version `0.2.0-alpha.1` is a major Alpha feature milestone focused on encounter presentation and live game management.
+
+Highlights include:
+
+- Visual Scene and Combat timers
+- Fantasy Hourglass, Candle, and Sci-Fi Pixel timer presentations
+- Encounter-bound Soundscapes
+- Scene and Combat music and ambience
+- Prepared one-shot audio cues
+- Live cue buttons on the Encounter screen
+- Saved Encounter audio and presentation configuration
+- Stand By, Scene, and Combat Player Display modes
+- Separate Scene and Combat artwork
+- Player Display initiative auto-centering
+- DM initiative Auto-Follow and current-turn tracking
+
## Screenshots
### DM Console
+The DM Console manages Saved Encounters, initiative, combatants, timers, scene/combat presentation, Soundscapes, and Prepared Cues.
+




-### Player Display
+### Player Display — Scene
+
+Scene mode displays encounter artwork and optional visual timers without revealing initiative or combat information.


@@ -58,10 +123,13 @@

-
### Encounter Soundscape live cues
-Soundscapes are saved as part of Saved Encounters. Loading an encounter automatically restores its scene audio, combat audio, and prepared sound cues. Configured cues are available as live buttons on the Encounter tab during both Scene and Combat phases.
+Soundscapes are stored as part of Saved Encounters. Loading a Saved Encounter restores its Scene music, Scene ambience, Combat music, Combat ambience, transition settings, and Prepared Cue assignments.
+
+Configured one-shot cues appear as live buttons on the Encounter screen and can be triggered during either Scene or Combat play.
+
+Audio playback itself does not automatically begin when an encounter is loaded. The DM remains in control of when Scene or Combat audio starts.
## Alpha notice
@@ -157,11 +225,19 @@ The Player Display should normally be opened from the DM Console using the same
The Player Display button changes automatically depending on its current state:
-| Button | Meaning |
-| --- | --- |
-| **Start Player Display** | Opens the Player Display |
-| **Hide Player Display** | Replaces encounter information with a STAND BY screen |
-| **Show Player Display** | Restores the Player Display using the latest encounter state |
+| Button | Meaning |
+| ------------------------ | ------------------------------------------------------------ |
+| **Start Player Display** | Opens the Player Display |
+| **Hide Player Display** | Replaces encounter information with a STAND BY screen |
+| **Show Player Display** | Restores the Player Display using the latest encounter state |
+
+Player Display content is also controlled by the current encounter phase:
+
+| Encounter state | Player Display |
+| --------------- | ---------------------------------------------------------------------- |
+| **Stand By** | Privacy screen; encounter information is hidden |
+| **Scene** | Scene artwork and optional timers without initiative |
+| **Combat** | Combat artwork, initiative order, current turn, and combat information |
### Making private DM changes
@@ -172,6 +248,8 @@ The DM Console remains fully functional. The DM can privately:
- change initiative values
- add or remove combatants
- advance or correct turns
+- use Set Turn or Trigger / Resume
+- adjust initiative order
- change HP or status information
- add reinforcements
- hide or reveal enemies
@@ -189,6 +267,24 @@ When a combatant is marked **Hide from Players**, it remains fully visible in th
Use **Reveal to Players** to return that combatant to the Player Display at its normal initiative position.
+### Initiative tracking
+
+The DM Console and Player Display use the same active-turn state so both screens remain synchronized as initiative advances.
+
+On the **Player Display**, the current visible combatant is highlighted and automatically centered in the initiative track.
+
+On the **DM Console**, the current combatant is highlighted and the initiative list automatically follows that combatant when:
+
+- Combat starts
+- **Next Turn** is selected
+- **Previous Turn** is selected
+- **Set Turn** is used
+- A delayed or readied combatant resumes and takes the current turn
+
+DM Auto-Follow occurs only when the active turn changes.
+
+Editing HP, applying damage or healing, changing a status or condition, adjusting resources, hiding or revealing combatants, or making other non-turn changes does not force the DM screen back to the current combatant.
+
### Display synchronization
The Player Display includes a **DM Sync** connection indicator.
@@ -199,7 +295,7 @@ Opening multiple DM Console windows against the same browser database is not sup
## Automated core tests
-The repository includes dependency-free Node tests for normalization, initiative sorting, asset sanitization, and turn-transition behavior. Node.js is only needed to run these development tests; packaged enCounter users do not need Node.js.
+The repository includes dependency-free Node tests for normalization, initiative sorting, asset sanitization, Player Display visibility, and turn-transition behavior. Node.js is only needed to run these development tests; packaged enCounter users do not need Node.js.
Run them from the repository root with:
@@ -214,6 +310,8 @@ node --test tests/*.test.js
- Exports/imports and user-supplied assets remain local unless the user moves or shares them.
- The current Alpha binds its local web server to `127.0.0.1` and does not intentionally send encounter or Library data to an enCounter-operated cloud service.
- The local server exposes only the application files, public notices, and supported asset files needed by enCounter; runtime `data/` files and source/build files are not served through the browser.
+- Imported audio files remain local under the enCounter `assets/sounds/` folders.
+- Soundscape configuration stores references to those local audio assets; audio files are not embedded directly into JSON encounter backups or exports.
See [`PRIVACY.md`](PRIVACY.md).
diff --git a/Start enCounter.bat b/Start enCounter.bat
index 1b5caf8..0e764a7 100644
--- a/Start enCounter.bat
+++ b/Start enCounter.bat
@@ -6,7 +6,7 @@ REM This file is part of enCounter. See LICENSE for the full license terms.
setlocal EnableDelayedExpansion
cd /d "%~dp0"
-title enCounter 0.1.0-alpha.1
+title enCounter 0.2.0-alpha.1
REM ============================================================
REM Verify launcher exists
diff --git a/docs/AUDIO_AND_ENCOUNTER_WORKFLOW.md b/docs/AUDIO_AND_ENCOUNTER_WORKFLOW.md
index 0f4a621..f596d39 100644
--- a/docs/AUDIO_AND_ENCOUNTER_WORKFLOW.md
+++ b/docs/AUDIO_AND_ENCOUNTER_WORKFLOW.md
@@ -1,44 +1,137 @@
-# enCounter — Saved Encounters, Scene Display, and Soundscapes
+# enCounter — Saved Encounters, Scene Display, Soundscapes, and Initiative Tracking
-This development update separates a prepared encounter from active combat.
+This document describes the encounter presentation and live-session workflow for **enCounter v0.2.0-alpha.1**.
+
+A prepared encounter can move through Scene and Combat presentation while retaining its Saved Encounter configuration, visual timers, Soundscape, and initiative state.
## Encounter workflow
1. Build an encounter in the DM Console: name, system, participants, Scene Image, Combat Background, Soundscape, and timer settings.
+
2. Use **Save** or **Save As…** in the Saved Encounter bar to store the prepared encounter.
-3. Load a saved encounter later. enCounter creates an active working copy; game-night changes do not overwrite the saved template unless the DM explicitly presses **Save**.
+
+3. Load a Saved Encounter later. enCounter creates an active working copy; game-night changes do not overwrite the saved encounter unless the DM explicitly presses **Save**.
+
4. Use **Show Scene** to display only the encounter's Scene Image on the Player Display. Initiative, combatants, round information, current turn, and next turn remain hidden.
-5. Use **Scene Audio** to start the roleplay/exploration soundscape independently of the Player Display.
-6. Use **Start Combat** to switch the Player Display to combat mode and, when enabled, transition the soundscape to combat audio.
-7. Use **End Combat** to return the Player Display to Scene mode. If Scene audio was playing before combat and return-to-scene is enabled, it resumes automatically.
-8. Use **Hide Player Display** at any time to return the players to STAND BY without stopping DM-side audio.
+
+5. Use **Scene Audio** to start the roleplay/exploration Soundscape independently of Player Display visibility.
+
+6. Use **Start Combat** to:
+ - Change the encounter to Combat phase.
+ - Switch the Player Display to Combat mode.
+ - Establish the current combatant from the initiative order.
+ - Bring the current combatant into view on the DM Console.
+ - Center the current visible combatant on the Player Display.
+ - Transition the Soundscape to Combat audio when automatic Combat audio switching is enabled.
+
+7. During combat, use **Next Turn**, **Previous Turn**, **Set Turn**, and **Trigger / Resume** to manage the active combatant. The DM Console and Player Display use the same current-turn state and remain synchronized as initiative changes.
+
+8. Use **End Combat** to return the Player Display to Scene mode. If Scene audio was playing before combat and return-to-scene audio is enabled, the Scene Soundscape resumes automatically.
+
+9. Use **Hide Player Display** at any time to return the players to STAND BY without stopping DM-side audio.
## Player Display modes
-- **Stand By** — enCounter logo / STAND BY only. Timers are hidden.
-- **Scene** — full-screen Scene Image. No initiative or combat UI. Player-visible timers may still appear.
-- **Combat** — existing initiative/combat Player Display using the Combat Background. Player-visible timers may still appear.
+- **Stand By** — enCounter logo / STAND BY only. Encounter information and Player-visible timers are hidden.
+- **Scene** — full-screen Scene Image. No initiative or combat UI is displayed. Player-visible timers may still appear.
+- **Combat** — initiative and combat information is displayed using the Combat Background. Player-visible timers may still appear.
+
+### Combat initiative presentation
+
+During Combat mode, the Player Display follows the active initiative state maintained by the DM Console.
+
+The current visible combatant is highlighted and automatically centered within the initiative display as turns advance. This keeps the active character or creature visible during encounters with initiative orders that are wider than the available Player Display area.
+
+Hidden combatants remain omitted from the Player Display until the DM uses **Reveal to Players**.
+
+## Initiative Tracking and DM Auto-Follow
+
+The DM Console and Player Display share the same active-turn state. When initiative changes, both displays identify the same current combatant.
+
+### DM Console behavior
+
+The current combatant is highlighted on the DM Console.
+
+The DM Console automatically follows and brings the active combatant into view when:
+
+- **Start Combat** establishes the first current combatant.
+- **Next Turn** advances initiative.
+- **Previous Turn** moves backward through initiative.
+- **Set Turn** manually selects a combatant as the current turn.
+- **Trigger / Resume** causes a Delayed or Readied combatant to resume and take the current turn.
+
+This makes it easier for the DM to follow the initiative order during encounters containing more combatants than can fit on the screen at once.
+
+### Player Display behavior
+
+When the active turn changes, the Player Display:
+
+- Updates the current-turn highlight.
+- Keeps the visible initiative order synchronized with the DM Console.
+- Automatically centers the current visible combatant.
+- Continues to omit combatants that are hidden from players.
+
+### Auto-Follow only occurs on turn changes
+
+DM Auto-Follow is intentionally limited to actions that change the active turn.
+
+The DM Console does **not** automatically move back to the current combatant when the DM performs ordinary encounter edits such as:
+
+- Damage or healing.
+- Direct HP changes.
+- Status changes.
+- Condition Track changes.
+- Resource adjustments.
+- Notes or other combatant edits.
+- Hide or Reveal actions.
+- Restoring a combatant without making that combatant the current turn.
+
+This allows the DM to work on another combatant's card without the screen unexpectedly jumping back to the active character.
## Soundscape
-The Soundscape belongs to the loaded encounter and is saved with it.
+The Soundscape belongs to the loaded encounter and its configuration is saved with the Saved Encounter.
### Scene audio
-- Optional scene music.
+- Optional Scene music.
- Up to three ambience layers in the current UI.
-- Each ambience layer can **Stop**, **Continue**, or **Lower Volume** when combat begins.
+- Each Scene ambience layer can **Stop**, **Continue**, or **Lower Volume** when Combat begins.
### Combat audio
-- Optional combat music.
-- Up to two additional combat ambience layers in the current UI.
+- Optional Combat music.
+- Up to two additional Combat ambience layers in the current UI.
+- Combat audio can begin automatically when **Start Combat** is selected.
+- Scene audio can optionally return when Combat ends.
### Prepared cues
-Three one-shot cue slots are available in the current Soundscape editor for alarms, explosions, doors, horns, creature sounds, and similar effects. After the Soundscape is applied to the encounter, any configured cue appears automatically as a live **Prepared Cues** button on the Encounter tab. These live cue buttons remain available in both Scene and Combat phases, so the DM does not need to leave the Encounter screen during play.
+Three one-shot cue slots are available in the current Soundscape editor for alarms, explosions, doors, horns, creature sounds, transmissions, weapon effects, and similar events.
+
+After the Soundscape is applied to the encounter, any configured cue appears automatically as a live **Prepared Cues** button on the Encounter tab.
+
+These live cue buttons remain available during both Scene and Combat phases so the DM does not need to leave the Encounter screen during play.
-Soundscapes are not loaded separately. They are part of the Saved Encounter. Loading a Saved Encounter automatically loads its scene audio, combat audio, cue assignments, volumes, transition settings, and other Soundscape configuration into the working encounter.
+### Saving and loading Soundscapes
+
+Soundscapes are not stored or loaded separately. They are part of the Saved Encounter.
+
+Loading a Saved Encounter restores its:
+
+- Scene music configuration.
+- Scene ambience configuration.
+- Combat music configuration.
+- Combat ambience configuration.
+- Prepared Cue assignments.
+- Volume settings.
+- Fade settings.
+- Scene-to-Combat transition behavior.
+- Return-to-Scene behavior.
+
+Loading an encounter does **not** automatically start audio playback.
+
+The DM chooses when to start Scene Audio, Combat Audio, or a Prepared Cue.
### Audio library
@@ -58,8 +151,25 @@ assets/sounds/sfx/
assets/sounds/custom/
```
-Audio plays from the DM computer. The Player Display remains visual-only, avoiding cross-window audio duplication and browser autoplay/synchronization issues.
+Audio plays from the DM computer. The Player Display remains visual-only, avoiding duplicate playback and browser audio synchronization issues.
+
+Saved Encounters store references to local audio assets rather than embedding the audio files themselves. When moving enCounter to another computer, copy the complete `assets/` folder if Saved Encounters depend on imported audio or images.
## Timers outside combat
-Player-visible timers are independent of initiative. A timer may be shown while the Player Display is in Scene mode, making it suitable for puzzles, arrivals, evacuations, security response, environmental events, and other roleplaying sequences.
+Player-visible timers are independent of initiative.
+
+A timer may be shown while the Player Display is in Scene mode, making it suitable for:
+
+- Puzzles.
+- Arrivals.
+- Evacuations.
+- Security responses.
+- Environmental hazards.
+- Reinforcements.
+- Countdown events.
+- Other roleplaying sequences.
+
+Player-visible timers can also remain active during Combat mode.
+
+While the Player Display is on **STAND BY**, Player-visible timers remain hidden.
diff --git a/docs/LEGAL.html b/docs/LEGAL.html
index 2cbf63f..4d91652 100644
--- a/docs/LEGAL.html
+++ b/docs/LEGAL.html
@@ -13,7 +13,7 @@
enCounter
-
Version: 0.1.0-alpha.1 (Alpha)
+
Version: 0.2.0-alpha.1 (Alpha)
License
Copyright © 2026 Zygons.
diff --git a/docs/VERSION.txt b/docs/VERSION.txt
index 9fc67c8..b523c76 100644
--- a/docs/VERSION.txt
+++ b/docs/VERSION.txt
@@ -1,5 +1,5 @@
enCounter
-Version: 0.1.0-alpha.1
+Version: 0.2.0-alpha.1
Release stage: Alpha
License: MIT
-Platforms: Windows portable, Linux portable (architecture-specific)
+Platforms: Windows portable, Linux portable (architecture-specific)
\ No newline at end of file
diff --git a/docs/enCounter User Guide.pdf b/docs/enCounter User Guide.pdf
index 6a84adea5abe119f9b392f4192baf01d731563e4..a6bb2a88515d7bb67ec61d8ea6388537692595a5 100644
GIT binary patch
literal 27612686
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