diff --git a/src/server-deno/presentation/WebSocketServer.ts b/src/server-deno/presentation/WebSocketServer.ts
index 6bb3e02..0b48663 100644
--- a/src/server-deno/presentation/WebSocketServer.ts
+++ b/src/server-deno/presentation/WebSocketServer.ts
@@ -169,6 +169,14 @@ export class WebSocketServer {
this.handleKickPlayer(msg, player);
break;
+ case 'rematch':
+ this.handleRematch(player);
+ break;
+
+ case 'reaction':
+ this.handleReaction(msg, player);
+ break;
+
default:
logger.debug(`Unknown message type: ${msg.type}`);
}
@@ -635,6 +643,74 @@ export class WebSocketServer {
}
}
+ /**
+ * Handle rematch request - reset game to lobby with same settings
+ */
+ private handleRematch(player: PlayerModel): void {
+ if (!player.roomId) return;
+
+ const room = this.roomService.getRoom(player.roomId);
+ if (!room) return;
+
+ // Only host can initiate rematch
+ if (room.hostId !== player.id) {
+ logger.debug(`Non-host ${player.id} tried to initiate rematch`);
+ return;
+ }
+
+ // Stop any sync timers
+ this.stopSyncTimer(room);
+
+ // Reset game state to lobby
+ room.state.status = GameStatus.LOBBY;
+ room.state.phase = GamePhase.GUESS;
+ room.state.currentGuesser = null;
+ room.state.currentTrack = null;
+ room.state.lastResult = null;
+ room.state.trackStartAt = null;
+ room.state.paused = false;
+ room.state.pausedPosSec = 0;
+ room.state.titleArtistAwardedThisRound = {};
+
+ // Clear all player timelines, tokens, and ready states
+ for (const playerId of room.players.keys()) {
+ room.state.timeline[playerId] = [];
+ room.state.tokens[playerId] = 0;
+ room.state.ready[playerId] = false;
+ }
+
+ // Keep playlist and goal settings intact
+ // room.state.playlist stays the same
+ // room.state.goal stays the same
+
+ // Reset deck (will be refilled when game starts again)
+ room.deck = [];
+ room.discard = [];
+
+ logger.info(`Room ${room.id}: Rematch initiated by host ${player.id}`);
+ this.broadcastRoomUpdate(room);
+ }
+
+ /**
+ * Handle reaction - broadcast emoji to all players in room
+ */
+ private handleReaction(msg: any, player: PlayerModel): void {
+ if (!player.roomId) return;
+
+ const room = this.roomService.getRoom(player.roomId);
+ if (!room) return;
+
+ const emoji = msg.emoji;
+ if (!emoji || typeof emoji !== 'string') return;
+
+ // Broadcast reaction to all players
+ this.broadcast(room, 'reaction', {
+ emoji,
+ playerId: player.id,
+ playerName: player.name,
+ });
+ }
+
/**
* Draw next track
*/
diff --git a/src/server-deno/public/index.html b/src/server-deno/public/index.html
index 885a5d1..82d979a 100644
--- a/src/server-deno/public/index.html
+++ b/src/server-deno/public/index.html
@@ -81,6 +81,31 @@
.animate-bounce-slow {
animation: bounce-slow 1.5s ease-in-out infinite;
}
+
+ /* Live reactions animation */
+ @keyframes reaction-rise {
+ 0% {
+ transform: translateX(-50%) translateY(0) scale(1);
+ opacity: 1;
+ }
+
+ 100% {
+ transform: translateX(-50%) translateY(-200px) scale(1.2);
+ opacity: 0;
+ }
+ }
+
+ .reaction-btn {
+ transition: transform 0.15s ease;
+ }
+
+ .reaction-btn:hover {
+ transform: scale(1.3);
+ }
+
+ .reaction-btn:active {
+ transform: scale(0.9);
+ }
@@ -98,6 +123,10 @@
Code kopiert!
+
+
+
@@ -300,10 +329,15 @@
-
@@ -343,18 +377,22 @@
Position
Wรคhle die Position und klicke Platzieren.
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ Reaktionen:
+
+
+
+
+
+
+
diff --git a/src/server-deno/public/js/dom.js b/src/server-deno/public/js/dom.js
index 778e0af..675d56f 100644
--- a/src/server-deno/public/js/dom.js
+++ b/src/server-deno/public/js/dom.js
@@ -47,6 +47,8 @@ export const $playlistSection = el("playlistSection");
export const $playlistSelect = el("playlistSelect");
export const $currentPlaylist = el("currentPlaylist");
export const $playlistInfo = el("playlistInfo");
+// SFX controls
+export const $sfxVolumeSlider = el("sfxVolumeSlider");
export function showLobby() {
$lobby.classList.remove("hidden");
diff --git a/src/server-deno/public/js/handlers.js b/src/server-deno/public/js/handlers.js
index a695d22..76451d5 100644
--- a/src/server-deno/public/js/handlers.js
+++ b/src/server-deno/public/js/handlers.js
@@ -9,7 +9,9 @@ import {
import { state } from "./state.js";
import { cacheLastRoomId, cacheSessionId, clearSessionId, sendMsg } from "./ws.js";
import { renderRoom } from "./render.js";
-import { applySync, loadTrack, getSound } from "./audio.js";
+import { applySync, loadTrack, getSound, stopAudioPlayback } from "./audio.js";
+import { playCorrect, playWrong, playWinner, playTurnStart, playCoinEarned } from "./sfx.js";
+import { showReaction, celebrateCorrect } from "./reactions.js";
function updatePlayerIdFromRoom(r) {
try {
@@ -101,6 +103,11 @@ export function handlePlayTrack(msg) {
}
}, delay);
+ // Play turn start sound if it's the current player's turn
+ if (state.room?.state?.currentGuesser === state.playerId) {
+ playTurnStart();
+ }
+
if (state.room) renderRoom(state.room);
}
@@ -143,10 +150,13 @@ export function handleReveal(msg) {
$rb.textContent = "Richtig!";
$rb.className =
"inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium";
+ playCorrect();
+ celebrateCorrect(); // Auto ๐ animation
} else {
$rb.textContent = "Falsch!";
$rb.className =
"inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium";
+ playWrong();
}
}
// Note: placeArea visibility is now controlled by renderRoom() based on game phase
@@ -167,6 +177,8 @@ export function handleReveal(msg) {
}
export function handleGameEnded(msg) {
+ // Play winner fanfare
+ playWinner();
// Create and show the winner popup with confetti
showWinnerPopup(msg);
}
@@ -208,9 +220,14 @@ function showWinnerPopup(msg) {
${timelineHtml}
-
+
+
+
+
`;
@@ -219,6 +236,13 @@ function showWinnerPopup(msg) {
// Start confetti animation
createConfetti();
+ // Rematch button handler
+ document.getElementById('rematchBtn').addEventListener('click', () => {
+ stopAudioPlayback(); // Stop the music
+ sendMsg({ type: 'rematch' });
+ overlay.remove();
+ });
+
// Close button handler
document.getElementById('closeWinnerBtn').addEventListener('click', () => {
overlay.remove();
@@ -333,8 +357,12 @@ export function onMessage(ev) {
parts.push(msg.correctTitle ? "Titel โ" : "Titel โ");
parts.push(msg.correctArtist ? "Kรผnstler โ" : "Kรผnstler โ");
let coin = "";
- if (msg.awarded) coin = " +1 Token";
- else if (msg.alreadyAwarded) coin = " (bereits erhalten)";
+ if (msg.awarded) {
+ coin = " +1 Token";
+ playCoinEarned();
+ } else if (msg.alreadyAwarded) {
+ coin = " (bereits erhalten)";
+ }
$answerResult.textContent = `${parts.join(" ยท ")}${coin}`;
$answerResult.className = okBoth
? "mt-1 text-sm text-emerald-600"
@@ -345,5 +373,10 @@ export function onMessage(ev) {
}
default:
return;
+ case "reaction": {
+ // Show reaction from another player
+ showReaction(msg.emoji, msg.playerName);
+ return;
+ }
}
}
diff --git a/src/server-deno/public/js/reactions.js b/src/server-deno/public/js/reactions.js
new file mode 100644
index 0000000..500db5b
--- /dev/null
+++ b/src/server-deno/public/js/reactions.js
@@ -0,0 +1,93 @@
+/**
+ * Live Reactions Module for Hitstar Party Mode
+ * Real-time emoji reactions visible to all players
+ */
+
+import { sendMsg } from './ws.js';
+import { state } from './state.js';
+
+// Rate limiting
+let lastReactionTime = 0;
+const REACTION_COOLDOWN_MS = 500;
+
+// Available reactions
+export const REACTIONS = ['๐', '๐ฑ', '๐ฅ', '๐', '๐'];
+
+/**
+ * Send a reaction to all players
+ * @param {string} emoji - The emoji to send
+ */
+export function sendReaction(emoji) {
+ const now = Date.now();
+ if (now - lastReactionTime < REACTION_COOLDOWN_MS) {
+ return; // Rate limited
+ }
+ lastReactionTime = now;
+ sendMsg({ type: 'reaction', emoji });
+}
+
+/**
+ * Display a floating reaction animation
+ * @param {string} emoji - The emoji to display
+ * @param {string} playerName - Name of the player who reacted
+ */
+export function showReaction(emoji, playerName) {
+ const container = document.getElementById('reactionContainer');
+ if (!container) return;
+
+ // Create reaction element
+ const reaction = document.createElement('div');
+ reaction.className = 'reaction-float';
+
+ // Random horizontal position (20-80% of container width)
+ const xPos = 20 + Math.random() * 60;
+
+ reaction.style.cssText = `
+ position: absolute;
+ bottom: 0;
+ left: ${xPos}%;
+ transform: translateX(-50%);
+ font-size: 2.5rem;
+ pointer-events: none;
+ animation: reaction-rise 2s ease-out forwards;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ z-index: 100;
+ `;
+
+ // Show player name if not self
+ const isMe = state.room?.players?.some(p => p.id === state.playerId && p.name === playerName);
+ const nameLabel = playerName && !isMe ? `${escapeHtml(playerName)}` : '';
+
+ reaction.innerHTML = `
+ ${emoji}
+ ${nameLabel}
+ `;
+
+ container.appendChild(reaction);
+
+ // Remove after animation
+ setTimeout(() => {
+ reaction.remove();
+ }, 2000);
+}
+
+/**
+ * Trigger celebration reactions (auto ๐)
+ */
+export function celebrateCorrect() {
+ // Show multiple party emojis
+ for (let i = 0; i < 3; i++) {
+ setTimeout(() => {
+ showReaction('๐', '');
+ }, i * 150);
+ }
+}
+
+function escapeHtml(s) {
+ return String(s).replace(
+ /[&<>"']/g,
+ (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
+ );
+}
diff --git a/src/server-deno/public/js/render.js b/src/server-deno/public/js/render.js
index 28e3e99..1513bcc 100644
--- a/src/server-deno/public/js/render.js
+++ b/src/server-deno/public/js/render.js
@@ -271,6 +271,12 @@ export function renderRoom(room) {
$answerResult.textContent = '';
$answerResult.className = 'mt-1 text-sm';
}
+
+ // Show reaction bar during gameplay
+ const $reactionBar = document.getElementById('reactionBar');
+ if ($reactionBar) {
+ $reactionBar.classList.toggle('hidden', room.state.status !== 'playing');
+ }
}
export function shortName(id) {
diff --git a/src/server-deno/public/js/sfx.js b/src/server-deno/public/js/sfx.js
new file mode 100644
index 0000000..56953f5
--- /dev/null
+++ b/src/server-deno/public/js/sfx.js
@@ -0,0 +1,157 @@
+/**
+ * Sound Effects Module for Hitstar Party Mode
+ * Uses Howler.js for audio playback with synthesized tones
+ */
+
+// SFX volume (0-1), separate from music
+let sfxVolume = 0.7;
+
+// Audio context for generating tones
+let audioCtx = null;
+
+function getAudioContext() {
+ if (!audioCtx) {
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ }
+ return audioCtx;
+}
+
+/**
+ * Play a synthesized tone
+ * @param {number} frequency - Frequency in Hz
+ * @param {number} duration - Duration in seconds
+ * @param {string} type - Oscillator type: 'sine', 'square', 'sawtooth', 'triangle'
+ * @param {number} [volume] - Volume multiplier (0-1)
+ */
+function playTone(frequency, duration, type = 'sine', volume = 1) {
+ try {
+ const ctx = getAudioContext();
+ if (ctx.state === 'suspended') {
+ ctx.resume();
+ }
+
+ const oscillator = ctx.createOscillator();
+ const gainNode = ctx.createGain();
+
+ oscillator.type = type;
+ oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
+
+ // Apply volume with fade out
+ const effectiveVolume = sfxVolume * volume * 0.3; // Keep SFX quieter than music
+ gainNode.gain.setValueAtTime(effectiveVolume, ctx.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
+
+ oscillator.connect(gainNode);
+ gainNode.connect(ctx.destination);
+
+ oscillator.start(ctx.currentTime);
+ oscillator.stop(ctx.currentTime + duration);
+ } catch (e) {
+ console.warn('SFX playback failed:', e);
+ }
+}
+
+/**
+ * Play a sequence of tones
+ * @param {Array<{freq: number, dur: number, delay: number, type?: string, vol?: number}>} notes
+ */
+function playSequence(notes) {
+ notes.forEach(note => {
+ setTimeout(() => {
+ playTone(note.freq, note.dur, note.type || 'sine', note.vol || 1);
+ }, note.delay * 1000);
+ });
+}
+
+// ============= Sound Effect Functions =============
+
+/**
+ * Play success sound - rising two-tone chime
+ */
+export function playCorrect() {
+ playSequence([
+ { freq: 523.25, dur: 0.15, delay: 0, type: 'sine' }, // C5
+ { freq: 659.25, dur: 0.25, delay: 0.1, type: 'sine' }, // E5
+ ]);
+}
+
+/**
+ * Play error sound - descending buzz
+ */
+export function playWrong() {
+ playSequence([
+ { freq: 220, dur: 0.15, delay: 0, type: 'sawtooth', vol: 0.5 }, // A3
+ { freq: 175, dur: 0.25, delay: 0.12, type: 'sawtooth', vol: 0.5 }, // F3
+ ]);
+}
+
+/**
+ * Play winner fanfare
+ */
+export function playWinner() {
+ playSequence([
+ { freq: 523.25, dur: 0.12, delay: 0, type: 'square', vol: 0.6 }, // C5
+ { freq: 659.25, dur: 0.12, delay: 0.12, type: 'square', vol: 0.6 }, // E5
+ { freq: 783.99, dur: 0.12, delay: 0.24, type: 'square', vol: 0.6 }, // G5
+ { freq: 1046.50, dur: 0.4, delay: 0.36, type: 'sine', vol: 0.8 }, // C6 (victory note)
+ ]);
+}
+
+/**
+ * Play turn start notification ping
+ */
+export function playTurnStart() {
+ playSequence([
+ { freq: 880, dur: 0.08, delay: 0, type: 'sine', vol: 0.4 }, // A5
+ { freq: 1108.73, dur: 0.15, delay: 0.08, type: 'sine', vol: 0.6 }, // C#6
+ ]);
+}
+
+/**
+ * Play coin/token earned sound
+ */
+export function playCoinEarned() {
+ playSequence([
+ { freq: 1318.51, dur: 0.06, delay: 0, type: 'sine', vol: 0.5 }, // E6
+ { freq: 1567.98, dur: 0.06, delay: 0.05, type: 'sine', vol: 0.6 }, // G6
+ { freq: 2093, dur: 0.12, delay: 0.1, type: 'sine', vol: 0.7 }, // C7
+ ]);
+}
+
+/**
+ * Play countdown tick sound
+ */
+export function playTick() {
+ playTone(800, 0.05, 'sine', 0.3);
+}
+
+/**
+ * Set SFX volume
+ * @param {number} volume - Volume level (0-1)
+ */
+export function setSfxVolume(volume) {
+ sfxVolume = Math.max(0, Math.min(1, volume));
+}
+
+/**
+ * Get current SFX volume
+ * @returns {number} Current volume (0-1)
+ */
+export function getSfxVolume() {
+ return sfxVolume;
+}
+
+/**
+ * Initialize SFX system (call on first user interaction)
+ */
+export function initSfx() {
+ // Pre-warm audio context on user interaction
+ try {
+ const ctx = getAudioContext();
+ if (ctx.state === 'suspended') {
+ ctx.resume();
+ }
+ } catch (e) {
+ console.warn('SFX initialization failed:', e);
+ }
+}
diff --git a/src/server-deno/public/js/ui.js b/src/server-deno/public/js/ui.js
index 6da7bed..a1ad833 100644
--- a/src/server-deno/public/js/ui.js
+++ b/src/server-deno/public/js/ui.js
@@ -22,14 +22,26 @@ import {
$playBtn,
$volumeSlider,
$saveName,
+ $sfxVolumeSlider,
} from "./dom.js";
import { state } from "./state.js";
import { initAudioUI, stopAudioPlayback } from "./audio.js";
import { sendMsg } from "./ws.js";
import { showToast, wire } from "./utils.js";
+import { setSfxVolume, initSfx } from "./sfx.js";
+import { sendReaction } from "./reactions.js";
export function wireUi() {
initAudioUI();
+ initSfx(); // Initialize sound effects system
+
+ // Wire reaction buttons
+ document.querySelectorAll('.reaction-btn').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const emoji = btn.getAttribute('data-emoji');
+ if (emoji) sendReaction(emoji);
+ });
+ });
// --- Name autosave helpers
let nameDebounce;
@@ -201,6 +213,13 @@ export function wireUi() {
// Volume slider is now handled in audio.js initAudioUI()
+ // SFX Volume slider
+ if ($sfxVolumeSlider) {
+ wire($sfxVolumeSlider, "input", (e) => {
+ setSfxVolume(parseFloat(e.target.value));
+ });
+ }
+
if ($copyRoomCode) {
$copyRoomCode.style.display = "inline-block";
wire($copyRoomCode, "click", () => {