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:
-
Google Gemini (
gemini-3.1-flash-lite) writes the roasts procedurally — no scripts, no templates. Every battle is unique. -
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. - 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.
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
Code
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.
-
Sky Blue (
- 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]
Layer 1 generates the text. Layer 2 gives it a voice. Layer 3 makes the voice visible.
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
});
}
}
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);
});
}
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);
}
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);
}
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
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-litegenerates all roast dialogue procedurally with zero templates. -
Best Use of ElevenLabs —
eleven_v3synthesizes 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)
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.
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:
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.
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.
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.
Thanks
This is a fascinating concept! Did you use something like
I think your comment is not complete feel free to ask whatever you want