golfgame/docs/v3/V3_16_REALISTIC_CARD_SOUNDS.md
adlee-was-taken 9fc6b83bba v3.0.0: V3 features, server refactoring, and documentation overhaul
- Extract WebSocket handlers from main.py into handlers.py
- Add V3 feature docs (dealer rotation, dealing animation, round end reveal,
  column pair celebration, final turn urgency, opponent thinking, score tallying,
  card hover/selection, knock early drama, column pair indicator, swap animation
  improvements, draw source distinction, card value tooltips, active rules context,
  discard pile history, realistic card sounds)
- Add V3 refactoring docs (ai.py, main.py/game.py, misc improvements)
- Add installation guide with Docker, systemd, and nginx setup
- Add helper scripts (install.sh, dev-server.sh, docker-build.sh)
- Add animation flow diagrams documentation
- Add test files for handlers, rooms, and V3 features
- Add e2e test specs for V3 features
- Update README with complete project structure and current tech stack
- Update CLAUDE.md with full architecture tree and server layer descriptions
- Update .env.example to reflect PostgreSQL (remove SQLite references)
- Update .gitignore to exclude virtualenv files, .claude/, and .db files
- Remove tracked virtualenv files (bin/, lib64, pyvenv.cfg)
- Remove obsolete game_log.py (SQLite) and games.db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:03:45 -05:00

18 KiB

V3-16: Realistic Card Sounds

Overview

Current sounds use simple Web Audio oscillator beeps. Real card games have distinct sounds: shuffling, dealing, flipping, placing. This feature improves audio feedback to feel more physical.

Dependencies: None Dependents: None


Goals

  1. Distinct sounds for each card action
  2. Variation to avoid repetition fatigue
  3. Physical "card" quality (paper, snap, thunk)
  4. Volume control and mute option
  5. Performant (Web Audio API synthesis or small samples)

Current State

From app.js and card-animations.js:

// app.js has the main playSound method
playSound(type) {
    const ctx = new AudioContext();
    const osc = ctx.createOscillator();
    // Simple beep tones for different actions
}

// CardAnimations routes to app.js via window.game.playSound()
playSound(type) {
    if (window.game && typeof window.game.playSound === 'function') {
        window.game.playSound(type);
    }
}

Sounds are functional but feel digital/arcade rather than physical. The existing sound types include:

  • card - general card movement
  • flip - card flip
  • shuffle - deck shuffle

Design

Sound Palette

Action Sound Character Notes
Card flip Sharp snap Paper/cardboard flip
Card place Soft thunk Card landing on table
Card draw Slide + lift Taking from pile
Card shuffle Multiple snaps Riffle texture
Pair formed Satisfying click Success feedback
Knock Table tap Knuckle on table
Deal Quick sequence Multiple snaps
Turn notification Subtle chime Alert without jarring
Round end Flourish Resolution feel

Synthesis vs Samples

Option A: Synthesized sounds (current approach, enhanced)

  • No external files needed
  • Smaller bundle size
  • More control over variations
  • Can sound artificial

Option B: Audio samples

  • More realistic
  • Larger file size (small samples ~5-10KB each)
  • Need to handle loading
  • Can use Web Audio for variations

Recommendation: Hybrid - synthesized base with sample layering for key sounds.


Implementation

Enhanced Sound System

// sound-system.js

class SoundSystem {
    constructor() {
        this.ctx = null;
        this.enabled = true;
        this.volume = 0.5;
        this.samples = {};
        this.initialized = false;
    }

    async init() {
        if (this.initialized) return;

        this.ctx = new (window.AudioContext || window.webkitAudioContext)();
        this.masterGain = this.ctx.createGain();
        this.masterGain.connect(this.ctx.destination);
        this.masterGain.gain.value = this.volume;

        // Load settings
        this.enabled = localStorage.getItem('soundEnabled') !== 'false';
        this.volume = parseFloat(localStorage.getItem('soundVolume') || '0.5');

        this.initialized = true;
    }

    setVolume(value) {
        this.volume = Math.max(0, Math.min(1, value));
        if (this.masterGain) {
            this.masterGain.gain.value = this.volume;
        }
        localStorage.setItem('soundVolume', this.volume.toString());
    }

    setEnabled(enabled) {
        this.enabled = enabled;
        localStorage.setItem('soundEnabled', enabled.toString());
    }

    async play(type) {
        if (!this.enabled) return;
        if (!this.ctx || this.ctx.state === 'suspended') {
            await this.ctx?.resume();
        }

        const now = this.ctx.currentTime;

        switch (type) {
            case 'flip':
                this.playFlip(now);
                break;
            case 'place':
            case 'discard':
                this.playPlace(now);
                break;
            case 'draw-deck':
                this.playDrawDeck(now);
                break;
            case 'draw-discard':
                this.playDrawDiscard(now);
                break;
            case 'pair':
                this.playPair(now);
                break;
            case 'knock':
                this.playKnock(now);
                break;
            case 'deal':
                this.playDeal(now);
                break;
            case 'shuffle':
                this.playShuffle(now);
                break;
            case 'turn':
                this.playTurn(now);
                break;
            case 'round-end':
                this.playRoundEnd(now);
                break;
            case 'win':
                this.playWin(now);
                break;
            default:
                this.playGeneric(now);
        }
    }

    // Card flip - sharp snap
    playFlip(now) {
        // White noise burst for paper snap
        const noise = this.createNoiseBurst(0.03, 0.02);

        // High frequency click
        const click = this.ctx.createOscillator();
        const clickGain = this.ctx.createGain();
        click.connect(clickGain);
        clickGain.connect(this.masterGain);

        click.type = 'square';
        click.frequency.setValueAtTime(2000 + Math.random() * 500, now);
        click.frequency.exponentialRampToValueAtTime(800, now + 0.02);

        clickGain.gain.setValueAtTime(0.15, now);
        clickGain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);

        click.start(now);
        click.stop(now + 0.05);
    }

    // Card place - soft thunk
    playPlace(now) {
        // Low thump
        const thump = this.ctx.createOscillator();
        const thumpGain = this.ctx.createGain();
        thump.connect(thumpGain);
        thumpGain.connect(this.masterGain);

        thump.type = 'sine';
        thump.frequency.setValueAtTime(150 + Math.random() * 30, now);
        thump.frequency.exponentialRampToValueAtTime(80, now + 0.08);

        thumpGain.gain.setValueAtTime(0.2, now);
        thumpGain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);

        thump.start(now);
        thump.stop(now + 0.1);

        // Soft noise
        this.createNoiseBurst(0.02, 0.04);
    }

    // Draw from deck - mysterious slide + flip
    playDrawDeck(now) {
        // Slide sound
        const slide = this.ctx.createOscillator();
        const slideGain = this.ctx.createGain();
        slide.connect(slideGain);
        slideGain.connect(this.masterGain);

        slide.type = 'triangle';
        slide.frequency.setValueAtTime(200, now);
        slide.frequency.exponentialRampToValueAtTime(400, now + 0.1);

        slideGain.gain.setValueAtTime(0.08, now);
        slideGain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);

        slide.start(now);
        slide.stop(now + 0.12);

        // Delayed flip
        setTimeout(() => this.playFlip(this.ctx.currentTime), 150);
    }

    // Draw from discard - quick grab
    playDrawDiscard(now) {
        const grab = this.ctx.createOscillator();
        const grabGain = this.ctx.createGain();
        grab.connect(grabGain);
        grabGain.connect(this.masterGain);

        grab.type = 'square';
        grab.frequency.setValueAtTime(600, now);
        grab.frequency.exponentialRampToValueAtTime(300, now + 0.04);

        grabGain.gain.setValueAtTime(0.1, now);
        grabGain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);

        grab.start(now);
        grab.stop(now + 0.05);
    }

    // Pair formed - satisfying double click
    playPair(now) {
        // Two quick clicks
        for (let i = 0; i < 2; i++) {
            const click = this.ctx.createOscillator();
            const gain = this.ctx.createGain();
            click.connect(gain);
            gain.connect(this.masterGain);

            click.type = 'triangle';
            click.frequency.setValueAtTime(800 + i * 200, now + i * 0.08);

            gain.gain.setValueAtTime(0.15, now + i * 0.08);
            gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.06);

            click.start(now + i * 0.08);
            click.stop(now + i * 0.08 + 0.06);
        }
    }

    // Knock - table tap
    playKnock(now) {
        // Low woody thunk
        const knock = this.ctx.createOscillator();
        const knockGain = this.ctx.createGain();
        knock.connect(knockGain);
        knockGain.connect(this.masterGain);

        knock.type = 'sine';
        knock.frequency.setValueAtTime(120, now);
        knock.frequency.exponentialRampToValueAtTime(60, now + 0.1);

        knockGain.gain.setValueAtTime(0.3, now);
        knockGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);

        knock.start(now);
        knock.stop(now + 0.15);

        // Resonance
        const resonance = this.ctx.createOscillator();
        const resGain = this.ctx.createGain();
        resonance.connect(resGain);
        resGain.connect(this.masterGain);

        resonance.type = 'triangle';
        resonance.frequency.setValueAtTime(180, now);

        resGain.gain.setValueAtTime(0.1, now);
        resGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);

        resonance.start(now);
        resonance.stop(now + 0.2);
    }

    // Deal - rapid card sequence
    playDeal(now) {
        // Multiple quick snaps
        for (let i = 0; i < 4; i++) {
            setTimeout(() => {
                const snap = this.ctx.createOscillator();
                const gain = this.ctx.createGain();
                snap.connect(gain);
                gain.connect(this.masterGain);

                snap.type = 'square';
                snap.frequency.setValueAtTime(1500 + Math.random() * 300, this.ctx.currentTime);

                gain.gain.setValueAtTime(0.08, this.ctx.currentTime);
                gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.03);

                snap.start(this.ctx.currentTime);
                snap.stop(this.ctx.currentTime + 0.03);
            }, i * 80);
        }
    }

    // Shuffle - riffle texture
    playShuffle(now) {
        // Many tiny clicks with frequency variation
        for (let i = 0; i < 12; i++) {
            setTimeout(() => {
                this.createNoiseBurst(0.01, 0.01 + Math.random() * 0.02);
            }, i * 40 + Math.random() * 20);
        }
    }

    // Turn notification - gentle chime
    playTurn(now) {
        const freqs = [523, 659]; // C5, E5

        freqs.forEach((freq, i) => {
            const osc = this.ctx.createOscillator();
            const gain = this.ctx.createGain();
            osc.connect(gain);
            gain.connect(this.masterGain);

            osc.type = 'sine';
            osc.frequency.setValueAtTime(freq, now + i * 0.1);

            gain.gain.setValueAtTime(0.1, now + i * 0.1);
            gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.1 + 0.3);

            osc.start(now + i * 0.1);
            osc.stop(now + i * 0.1 + 0.3);
        });
    }

    // Round end - resolution flourish
    playRoundEnd(now) {
        const freqs = [392, 494, 587, 784]; // G4, B4, D5, G5

        freqs.forEach((freq, i) => {
            const osc = this.ctx.createOscillator();
            const gain = this.ctx.createGain();
            osc.connect(gain);
            gain.connect(this.masterGain);

            osc.type = 'triangle';
            osc.frequency.setValueAtTime(freq, now + i * 0.08);

            gain.gain.setValueAtTime(0.12, now + i * 0.08);
            gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.08 + 0.4);

            osc.start(now + i * 0.08);
            osc.stop(now + i * 0.08 + 0.4);
        });
    }

    // Win celebration
    playWin(now) {
        const freqs = [523, 659, 784, 1047]; // C5, E5, G5, C6

        freqs.forEach((freq, i) => {
            const osc = this.ctx.createOscillator();
            const gain = this.ctx.createGain();
            osc.connect(gain);
            gain.connect(this.masterGain);

            osc.type = 'sine';
            osc.frequency.setValueAtTime(freq, now + i * 0.12);

            gain.gain.setValueAtTime(0.15, now + i * 0.12);
            gain.gain.exponentialRampToValueAtTime(0.001, now + i * 0.12 + 0.5);

            osc.start(now + i * 0.12);
            osc.stop(now + i * 0.12 + 0.5);
        });
    }

    // Generic click
    playGeneric(now) {
        const osc = this.ctx.createOscillator();
        const gain = this.ctx.createGain();
        osc.connect(gain);
        gain.connect(this.masterGain);

        osc.type = 'triangle';
        osc.frequency.setValueAtTime(440, now);

        gain.gain.setValueAtTime(0.1, now);
        gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);

        osc.start(now);
        osc.stop(now + 0.1);
    }

    // Helper: Create white noise burst for paper/snap sounds
    createNoiseBurst(volume, duration) {
        const bufferSize = this.ctx.sampleRate * duration;
        const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
        const output = buffer.getChannelData(0);

        for (let i = 0; i < bufferSize; i++) {
            output[i] = Math.random() * 2 - 1;
        }

        const noise = this.ctx.createBufferSource();
        noise.buffer = buffer;

        const noiseGain = this.ctx.createGain();
        noise.connect(noiseGain);
        noiseGain.connect(this.masterGain);

        const now = this.ctx.currentTime;
        noiseGain.gain.setValueAtTime(volume, now);
        noiseGain.gain.exponentialRampToValueAtTime(0.001, now + duration);

        noise.start(now);
        noise.stop(now + duration);

        return noise;
    }
}

// Export singleton
const soundSystem = new SoundSystem();
export default soundSystem;

Integration with App

The SoundSystem can replace the existing playSound() method in app.js:

// In app.js - replace the existing playSound method
// Option 1: Direct integration (no import needed for non-module setup)

// Create global instance
window.soundSystem = new SoundSystem();

// Initialize on first interaction
document.addEventListener('click', async () => {
    await window.soundSystem.init();
}, { once: true });

// Replace existing playSound calls
playSound(type) {
    window.soundSystem.play(type);
}

// CardAnimations already routes through window.game.playSound()
// so no changes needed in card-animations.js

Sound Variation

Add slight randomization to prevent repetitive sounds:

playFlip(now) {
    // Random variation
    const pitchVariation = 1 + (Math.random() - 0.5) * 0.1;
    const volumeVariation = 1 + (Math.random() - 0.5) * 0.2;

    // Apply to sound...
    click.frequency.setValueAtTime(2000 * pitchVariation, now);
    clickGain.gain.setValueAtTime(0.15 * volumeVariation, now);
}

Settings UI

// In settings panel
renderSoundSettings() {
    return `
        <div class="setting-group">
            <label class="setting-toggle">
                <input type="checkbox" id="sound-enabled"
                       ${soundSystem.enabled ? 'checked' : ''}>
                <span>Sound Effects</span>
            </label>

            <label class="setting-slider" ${!soundSystem.enabled ? 'style="opacity: 0.5"' : ''}>
                <span>Volume</span>
                <input type="range" id="sound-volume"
                       min="0" max="1" step="0.1"
                       value="${soundSystem.volume}"
                       ${!soundSystem.enabled ? 'disabled' : ''}>
            </label>
        </div>
    `;
}

// Event handlers
document.getElementById('sound-enabled').addEventListener('change', (e) => {
    soundSystem.setEnabled(e.target.checked);
});

document.getElementById('sound-volume').addEventListener('input', (e) => {
    soundSystem.setVolume(parseFloat(e.target.value));
});

CSS for Settings

.setting-group {
    margin-bottom: 16px;
}

.setting-toggle {
    display: flex;
    align-items: center;
    gap: 8px;
    cursor: pointer;
}

.setting-slider {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-top: 8px;
    transition: opacity 0.2s;
}

.setting-slider input[type="range"] {
    flex: 1;
    -webkit-appearance: none;
    background: rgba(255, 255, 255, 0.2);
    height: 4px;
    border-radius: 2px;
}

.setting-slider input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 16px;
    height: 16px;
    background: #f4a460;
    border-radius: 50%;
    cursor: pointer;
}

Test Scenarios

  1. Card flip - Sharp snap sound
  2. Card place/discard - Soft thunk
  3. Draw from deck - Slide + flip sequence
  4. Draw from discard - Quick grab
  5. Pair formed - Double click satisfaction
  6. Knock - Table tap
  7. Deal sequence - Rapid snaps
  8. Volume control - Adjusts all sounds
  9. Mute toggle - Silences all sounds
  10. Settings persist - Reload maintains preferences
  11. First interaction - AudioContext initializes

Acceptance Criteria

  • Distinct sounds for each card action
  • Sounds feel physical (not arcade beeps)
  • Variation prevents repetition fatigue
  • Volume slider works
  • Mute toggle works
  • Settings persist in localStorage
  • AudioContext handles browser restrictions
  • No sound glitches or overlaps
  • Performant (no audio lag)

Implementation Order

  1. Create SoundSystem class with basic structure
  2. Implement individual sound methods
  3. Add noise burst helper for paper sounds
  4. Add volume/enabled controls
  5. Integrate with existing playSound calls
  6. Add variation to prevent repetition
  7. Add settings UI
  8. Test on various browsers
  9. Fine-tune sound character

Notes for Agent

  • Replaces existing playSound() method in app.js
  • CardAnimations already routes through window.game.playSound() - no changes needed there
  • Web Audio API has good browser support
  • AudioContext must be created after user interaction
  • Noise bursts add realistic texture to card sounds
  • Keep sounds short (<200ms) to stay responsive
  • Volume variation and pitch variation prevent fatigue
  • Test with headphones - sounds should be pleasant, not jarring
  • Consider: different sound "themes"? (Classic, Minimal, Fun)
  • Mobile: test performance impact of audio synthesis
  • Settings should persist in localStorage