DEV Community

Kaushikcoderpy
Kaushikcoderpy

Posted on

Fandom Fire — A GPU-Accelerated, AI-Powered Roast Battle Engine

Zero dependencies and no build step

This is a submission for Weekend Challenge: Passion Edition

What I Built

Fandom Fire is a real-time, procedural AI roast battle engine. You pick any two fictional characters — Superman vs Thor, Batman vs Joker, Goku vs Naruto — and the engine writes original roasts, synthesizes distinct character voices, and projects a gorgeous audio-reactive fluid visualizer directly onto your GPU.

The entire experience is driven by three AI systems working in concert:

  1. Google Gemini (gemini-3.1-flash-lite) writes the roasts procedurally — no scripts, no templates. Every battle is unique.
  2. ElevenLabs (eleven_v3) gives each fighter a distinct, high-fidelity voice. Fighter A gets a deep, aggressive tone. Fighter B gets a sharp, theatrical British delivery.
  3. A Custom GLSL Fragment Shader renders a Gemini Live-inspired fluid wave that physically reacts to the bass frequencies of each voice in real-time.

FANDOM FIRE HOME PAGE

There is no backend. No npm. No build step. Just three files (index.html, style.css, app.js), a Python static server, and raw browser APIs.

Demo

🎥 Watch the Demo Video

Code

GitHub logo Kaushikcoderpy / Fandom-Fire

Fandom Fire is a real-time, procedural AI roast battle engine. You pick any two fictional characters — Superman vs Thor, Batman vs Joker, Goku vs Naruto — and the engine writes original roasts, synthesizes distinct character voices, and projects a gorgeous audio-reactive fluid visualizer directly onto your GPU.

🔥 Fandom Fire

Fandom Fire is a sensory-heavy, hardware-accelerated, procedural AI-generated roast battle engine. It takes any two fictional characters, generates custom roasts on the fly using Google Gemini, synthesizes their distinct voices using ElevenLabs, and projects a gorgeous, audio-reactive fluid visualizer inspired directly by Gemini Live.

Built entirely on a pure frontend stack—no backend, no npm installs, and no local build systems required.


🚀 Key Visual & Architectural Highlights

1. 🌌 Gemini Live Fluid Wave (WebGL + GLSL)

  • We replaced standard, boring 2D cards and orbiting wireframes with a full-viewport screen-space custom GLSL Fragment Shader running directly on the GPU.
  • Blends organic, eye-comforting color gradients
    • Sky Blue (#022aff) representing the Left Fighter.
    • Premium Indigo/Violet (#7a1ef0) blending in the center.
    • Rose Red/Pink (#f50d6b) representing the Right Fighter.
  • An organic mathematical wave deforms at the bottom of the screen using multi-octave simplex/value noise…

How I Built It

The Architecture (3 Layers)

The engine operates as a pipeline with three distinct layers:

[User Input] → [Gemini Roast Generator] → [ElevenLabs Voice Synth] → [Web Audio FFT] → [GLSL Shader]
Enter fullscreen mode Exit fullscreen mode

Layer 1 generates the text. Layer 2 gives it a voice. Layer 3 makes the voice visible.

HOW IT WORKS


Layer 1: Gemini Roast Generation (Best Use of Google AI)

When you click "ENGAGE BATTLE ENGINE," the app fires off alternating prompts to gemini-3.1-flash-lite:

async function generateBattle(f1, f2) {
    const turns = [
        { attacker: f1, defender: f2, side: 'left' },
        { attacker: f2, defender: f1, side: 'right' },
        { attacker: f1, defender: f2, side: 'left' },
        { attacker: f2, defender: f1, side: 'right' }
    ];

    for(let i=0; i<turns.length; i++) {
        if(!isBattling) break;
        const turn = turns[i];
        const prompt = `You are ${turn.attacker}. Write a single, highly 
          aggressive, funny, and punchy 1-2 sentence roast insulting 
          ${turn.defender}. Use VERY SIMPLE, EASY TO UNDERSTAND language. 
          Do not include quotes, hashtags, or emojis. Just the raw dialogue. 
          Speak in: ${preferredLang}.`;

        const response = await fetch(
          `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite:generateContent?key=${geminiKey}`, 
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                contents: [{ parts: [{ text: prompt }] }],
                systemInstruction: {
                    parts: [{ text: "You are a roast character. Output ONLY the raw roast text. No formatting." }]
                }
            })
        });

        const data = await response.json();
        const roastText = data.candidates[0].content.parts[0].text
          .replace(/["\n]/g, '').trim();

        roastQueue.push({
            attacker: turn.attacker,
            side: turn.side,
            text: roastText,
            dmg: Math.floor(Math.random() * 21) + 40,
            voice: turn.side === 'left' ? VOICE_LEFT : VOICE_RIGHT
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Key design decision: We use gemini-3.1-flash-lite specifically because latency matters more than depth here. The roasts need to land fast. The systemInstruction enforces raw text output — no markdown, no emoji, no quotes — so the pipeline never chokes on formatting artifacts.


Layer 2: ElevenLabs Voice Synthesis (Best Use of ElevenLabs)

Each generated roast is immediately piped to ElevenLabs' eleven_v3 model with character-specific voice IDs:

function playTurn(turn) {
    return new Promise(async (resolve) => {
        // Fetch synthesized audio from ElevenLabs
        const response = await fetch(
          `https://api.elevenlabs.io/v1/text-to-speech/${turn.voice}?output_format=mp3_44100_128`, 
          {
            method: 'POST',
            headers: { 'xi-api-key': elevenKey, 'Content-Type': 'application/json' },
            body: JSON.stringify({
                text: turn.text,
                model_id: "eleven_v3",
                voice_settings: { stability: 0.5, similarity_boost: 0.75 }
            })
        });

        // Decode directly into Web Audio API buffer
        const arrayBuffer = await response.arrayBuffer();
        const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);

        // Route through analyser node for FFT data extraction
        const source = audioContext.createBufferSource();
        source.buffer = audioBuffer;
        source.connect(analyser);        // ← FFT tap point
        analyser.connect(audioContext.destination);
        source.start(0);
    });
}
Enter fullscreen mode Exit fullscreen mode

The critical routing decision: instead of playing the audio directly to audioContext.destination, we insert an AnalyserNode in between. This gives us access to real-time frequency data (getByteFrequencyData) without affecting playback quality. This is the bridge between Layer 2 and Layer 3.


Layer 3: The GLSL Fluid Wave (The Visual Heartbeat)

This is where the magic lives. A full-viewport PlaneGeometry is rendered via Three.js with a custom ShaderMaterial. The fragment shader generates an organic, flowing liquid wave inspired by Google's Gemini Live interface:

void main() {
    vec2 uv = gl_FragCoord.xy / u_resolution.xy;

    // Multi-octave noise generates organic wave forms
    float n1 = noise(vec2(uv.x * 2.5 + u_time * 0.6, u_time * 0.4));
    float n2 = noise(vec2(uv.x * 4.5 - u_time * 0.4, u_time * 0.3));

    // Audio volumes directly modulate wave height
    float leftHeight = u_leftVolume * 0.28;
    float rightHeight = u_rightVolume * 0.28;

    float waveHeight = 0.15 
        + n1 * (0.12 + leftHeight) 
        + n2 * (0.08 + rightHeight);

    // Smooth, glowing boundary (no harsh edges)
    float alpha = smoothstep(-0.35, 0.05, waveHeight - uv.y);

    // Premium gradient: Sky Blue → Violet → Rose Pink
    vec3 colorLeft  = vec3(0.02, 0.42, 0.98);
    vec3 colorMid   = vec3(0.48, 0.12, 0.94);
    vec3 colorRight = vec3(0.96, 0.05, 0.42);

    vec3 gradColor = mix(colorLeft, colorMid, smoothstep(0.1, 0.5, uv.x));
    gradColor = mix(gradColor, colorRight, smoothstep(0.5, 0.9, uv.x));

    // Brightness surges with voice volume
    float intensity = 1.0 + (u_leftVolume + u_rightVolume) * 1.5;

    vec3 bgColor = vec3(0.004, 0.004, 0.008) * (1.0 - uv.y);
    gl_FragColor = vec4(mix(bgColor, gradColor * intensity, alpha), 1.0);
}
Enter fullscreen mode Exit fullscreen mode

And the JavaScript render loop that feeds it live audio data every frame:

function animate() {
    requestAnimationFrame(animate);
    uniforms.u_time.value = clock.getElapsedTime();

    if(analyser && isBattling) {
        const dataArray = new Uint8Array(analyser.frequencyBinCount);
        analyser.getByteFrequencyData(dataArray);

        // Extract bass frequencies (bins 0-19) for organic pulsing
        let sum = 0;
        for(let i = 0; i < 20; i++) sum += dataArray[i];
        let normalized = (sum / 20.0) / 255.0;

        // Lerp prevents jarring visual jumps between frames
        if (currentSpeakerSide === 'left') {
            uniforms.u_leftVolume.value = THREE.MathUtils.lerp(
              uniforms.u_leftVolume.value, normalized, 0.2);
            uniforms.u_rightVolume.value = THREE.MathUtils.lerp(
              uniforms.u_rightVolume.value, 0, 0.1);
        } else if (currentSpeakerSide === 'right') {
            uniforms.u_rightVolume.value = THREE.MathUtils.lerp(
              uniforms.u_rightVolume.value, normalized, 0.2);
            uniforms.u_leftVolume.value = THREE.MathUtils.lerp(
              uniforms.u_leftVolume.value, 0, 0.1);
        }
    }
    renderer.render(scene, camera);
}
Enter fullscreen mode Exit fullscreen mode

The lerp (linear interpolation) on the volume values is essential — without it, the wave would jitter violently between frames. The 0.2 attack / 0.1 decay rates create a smooth, organic breathing effect that feels alive.


The UI: Tactile Brutalism

The visual language is deliberately anti-glassmorphism:

  • Zero border-radius anywhere in the entire app
  • Sharp 1px solid white borders on pitch black
  • SVG-based fractal noise overlay for film grain texture
  • Chromatic aberration text animation on roast impact
  • Health bars with inline damage ticks (-45DMG) that appear post-speech

The Playback Queue Architecture

The entire battle runs on a producer-consumer queue pattern:

generateBattle() → pushes roasts to roastQueue[]
playbackWorker() → shifts from roastQueue[], plays each turn sequentially
Enter fullscreen mode Exit fullscreen mode

generateBattle() runs ahead, pre-fetching roasts from Gemini while the current turn is still playing. This hides API latency behind the ElevenLabs audio playback window. The user never waits.


Prize Categories

  • Best Use of Google AI — Gemini gemini-3.1-flash-lite generates all roast dialogue procedurally with zero templates.
  • Best Use of ElevenLabseleven_v3 synthesizes distinct character voices routed through Web Audio for real-time FFT-driven shader visuals.

Zero-Dependency Stack

Layer Technology
Rendering Three.js r128 (CDN)
Animation GSAP 3.12.2 (CDN)
AI Text Google Gemini gemini-3.1-flash-lite
AI Voice ElevenLabs eleven_v3
Audio Analysis Native Web Audio API
GPU Shaders Custom GLSL Fragment Shader
Build System None. python -m http.server 8000

Top comments (8)

Collapse
 
ahmetozel profile image
Ahmet Özel

Nice separation of the language, voice, and render loops. For a live experience, I would put an explicit latency budget around each stage and degrade independently: keep the visualizer running with the last audio features while the next generated line or TTS turn is late.

Collapse
 
kaushikcoderpy profile image
Kaushikcoderpy

Spot on Decoupling the network pipeline from the render loop is the exact play here to make this bulletproof.

Since the UI is already rendering the text line in real-time, any network hiccup or TTS lag completely breaks the illusion if the canvas flatlines. Adding an explicit latency budget is going straight into the v2 backlog.

Here is the quick architectural fix to handle this gracefully in the canvas loop:

// Inside the animate() loop
if (analyser && isBattling) {
    let normalized = 0;

    if (pipelineState === 'AUDIO_PLAYING') {
        // Standard FFT extraction
        const dataArray = new Uint8Array(analyser.frequencyBinCount);
        analyser.getByteFrequencyData(dataArray);
        let sum = 0;
        for(let i = 0; i < 20; i++) sum += dataArray[i];
        normalized = (sum / 20.0) / 255.0;
    } else if (pipelineState === 'WAITING_FOR_TTS') {
        // Late degradation: Keep canvas alive with an ambient, low-freq fake pulse
        normalized = 0.05 + Math.sin(clock.getElapsedTime() * 4.0) * 0.03;
    }

    // Smooth lerp keeps the transitions seamless
    if (currentSpeakerSide === 'left') {
        uniforms.u_leftVolume.value = THREE.MathUtils.lerp(uniforms.u_leftVolume.value, normalized, 0.2);
    } else {
        uniforms.u_rightVolume.value = THREE.MathUtils.lerp(uniforms.u_rightVolume.value, normalized, 0.2);
    }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
ahmetozel profile image
Ahmet Özel

The ambient pulse is a good degradation path. I would make it visually distinct from real audio energy so the UI stays alive without implying speech is playing, and expose the waiting-for-TTS state as telemetry rather than only a render state. Then you can track p50 and p95 time spent there and decide whether to prefetch, shorten the response, or switch voices or models before the illusion breaks.

Thread Thread
 
kaushikcoderpy profile image
Kaushikcoderpy

Damn, this is actually a killer point.

You're completely right about the illusion—if the ambient wave looks too much like the voice wave, it just looks broken or dishonest. I should probably damp the amplitude and drop the shader noise down to a slow, desaturated ripple so the user knows it’s a "charging up" state rather than a lag spike.

And using that wait time as telemetry is smart as hell. Tracking p95 spikes on the frontend would let me adaptively shorten the Gemini prompt length or trigger the pre-fetch a turn earlier before the engine chokes.

Appreciate the write-up, definitely adding this to the roadmap.

Thread Thread
 
ahmetozel profile image
Ahmet Özel

Good instinct on the desaturated ripple — the honesty of the state is what keeps it from reading as a bug.

Two things once the telemetry starts driving actions. Measure the wait from the perceptual start — when the text line lands and the user expects a voice — not from request dispatch; that's when the illusion clock actually starts, and it's what your p95 should gate on. And put some hysteresis on the adaptive switch: a rolling window instead of a single spike, so one slow turn doesn't flip voice or prompt length and make the mode change itself visible. If you tag each wait with its cause — cold model, long prompt, TTS queue — the adaptation can target the real bottleneck instead of always trimming the prompt.

Thread Thread
 
kaushikcoderpy profile image
Kaushikcoderpy

Thanks

Collapse
 
frank_signorini profile image
Frank

This is a fascinating concept! Did you use something like

Collapse
 
kaushikcoderpy profile image
Kaushikcoderpy

I think your comment is not complete feel free to ask whatever you want