Echo Case

La voz revela lo que las palabras esconden.

Juego narrativo de detectives donde interrogas sospechosos generados por Gemini, escuchas su voz, confrontas con evidencia y acusas al culpable. Construido para hackathon en pocos días.


## Inspiración

La idea nació de una pregunta simple: ¿y si un LLM no solo respondiera con texto, sino también con emoción, tono y nervios? Los juegos de detectives clásicos (L.A. Noire, Ace Attorney, Disco Elysium) viven de leer micro-expresiones y contradicciones. Antes eso requería actores, animaciones faciales y guiones gigantes. Hoy, con Gemini multimodal, un sospechoso puede improvisar, mentir, ponerse nervioso y delatarse — todo en runtime.

Quisimos capturar esa sensación de "sé que está mintiendo, lo escucho en su voz" con un stack mínimo: un caso, un detective, una IA que actúa.


## Qué aprendimos

  • Prompt engineering como diseño de sistemas. El prompt del sospechoso no es una instrucción, es un contrato con campos tipados (emotion, tone, suspicionDelta, stressDelta, dialogue, revealsClue). Tratar la salida del LLM como JSON validable cambia todo.
  • El backend debe pensar, el frontend debe mostrar. Tentación enorme de calcular sospecha en React. Resistirla mantuvo el cliente tonto, los secretos seguros (isGuilty jamás cruza la red) y la lógica testeable.
  • Gemini TTS no es trivial. Devuelve PCM L16 sin header WAV. Hay que envolverlo manualmente. Una vez resuelto, el audio dispara en paralelo al texto y multiplica la inmersión.
  • Cache agresivo = demo viable. Una API key gratuita no aguanta una demo en vivo sin cache. Hash determinístico por línea (suspect + emotion + tone + dialogue) significa que la segunda vez que escuchas a Mateo decir lo mismo, sale del disco. Cero latencia.
  • Los fallbacks salvan demos. Sin API key, sin cuota, sin red — el juego sigue jugable con respuestas por defecto y SVG procedural en lugar de Imagen 3. La demo nunca se rompe en vivo.
  • El culpable no puede delatarse gratis. Primer playtest: el culpable confesaba a la tercera pregunta. Solución: el prompt obliga a que revealsClue solo sea true cuando hay evidencia presentada o presión sostenida ($\text{stress} \geq 60$). El juego se volvió un juego.

## Cómo lo construimos

### Stack

| Capa | Tecnología | |---|---| | Backend | NestJS 10 · TypeScript estricto · Swagger · Helmet · estado en memoria | | Frontend | Next.js 15 (App Router) · React 19 · Tailwind v4 · TanStack Query v5 · Zustand v5 | | IA | Gemini 2.5 Flash (texto) · Gemini 2.5 Flash TTS · Imagen 3 | | Validación | class-validator (backend) · Zod + React Hook Form (frontend) |

### Arquitectura del flujo

Usuario pregunta │ ▼ [Frontend] POST /interrogation/ask │ ▼ [Backend] construye prompt con: caso + sospechoso (incluye secret, isGuilty) + historial + evidencia adjunta + mention cruzada │ ▼ [Gemini text] devuelve JSON tipado: { dialogue, emotion, tone, suspicionDelta, stressDelta, revealsClue, ... } │ ├─► [Gemini TTS] paralelo: hash(suspect+emotion+tone+dialogue) → WAV cacheado │ ▼ [Backend] aplica deltas, detecta contradicciones, persiste estado por caseId │ ▼ [Frontend] re-renderiza: nuevo bubble + barras de sospecha/estrés + audio reproducible

### Decisiones clave

  1. Estado en memoria, sin DB. Hackathon, no producción. Un Map<caseId, GameState> basta y deploy es trivial.
  2. Secretos jamás al cliente. El DTO de respuesta omite secret, isGuilty, culpritSuspectId. El frontend recibe solo lo que un detective vería.
  3. Lista de sospechosos barajada. El culpable nunca está en posición fija — evita meta-juego.
  4. Inocentes con secreto. Cada inocente esconde algo (un affair, una deuda, un robo menor). Su sospecha sube tanto como la del culpable cuando se toca su tema. Sospecha alta ≠ culpa. Esto rompe la heurística "acuso al de número más alto".
  5. Mentions cruzadas. Si el sospechoso A menciona a B, el detective puede confrontar a B citando textualmente lo que A dijo. Convierte el juego en un grafo, no en interrogatorios aislados.
  6. TanStack Query para todo fetch, Zustand solo para UI. Cero fetch directo en componentes. Cero estado de servidor en Zustand.

### Modelo de costo

Demo en vivo $\Rightarrow$ minimizar llamadas. Estimación grosera por turno:

$$ C_{\text{turno}} = C_{\text{texto}} + C_{\text{TTS}} \cdot (1 - p_{\text{cache}}) + C_{\text{img}} \cdot \mathbb{1}_{\text{primera vez}} $$

Con $p_{\text{cache}} \approx 0.4$ tras 5 minutos de juego, el costo efectivo cae a la mitad. En el cierre del caso (imagen cinemática + monólogo + libreta) el cache no aplica, pero solo ocurre una vez.


## Retos

### 1. Gemini TTS devuelve audio sin header La respuesta es PCM lineal de 16-bit, 24kHz, mono. Sin header, ningún navegador lo reproduce. Hubo que escribir un wrapper WAV de 44 bytes a mano:

  const wav = Buffer.concat([buildWavHeader(pcm.length, 24000, 1, 16), pcm]);

  2. JSON robusto desde un LLM

  Gemini casi siempre devuelve JSON válido. El "casi" mata demos. Solución: responseMimeType: "application/json" + responseSchema tipado + parser tolerante que repara comas finales y bloques \``json`
  envolventes antes de fallar al fallback.

  3. Detectar contradicciones sin reglas hardcodeadas

  Una contradicción es semántica, no sintáctica. "Estaba en mi casa" vs "Salí a las 9pm" es una contradicción solo si el contexto temporal cuadra. Lo resolvimos pidiéndole a Gemini que en cada respuesta
  devuelva un campo contradictsPreviousStatement: { messageId, reason } cuando aplique. El LLM detecta su propia inconsistencia mejor que cualquier heurística que pudimos escribir.

  4. Layout sin scroll de página

  La pantalla del juego tiene chat, panel de evidencias, dock derecho con tabs (archivo/voz/pizarra) y header. Todo en h-dvh sin que la página entera scrollee — solo scrollean paneles internos. Tailwind v4 +
  grid-rows-[auto_1fr] anidados resolvió el rompecabezas tras varios intentos fallidos con flexbox.

  5. Que el culpable se sintiera culpable, no programado

  Primer iteración: el culpable usaba palabras delatoras ("yo no fui", sudor, tartamudeo) desde el turno 1. Demasiado obvio. Iteración final: el prompt instruye al culpable a mantener fachada mientras stress <
   40, y solo deja escapar micro-tells (tone: "defensivo", emotion: "incómodo") cuando se le confronta con evidencia o se le cita a otro testigo. La diferencia es enorme — ahora hay que trabajar la confesión.

  6. Imágenes consistentes para el mismo personaje

  Imagen 3 no tiene memoria. Pedir "Mateo" dos veces da dos personas distintas. Solución: el prompt de avatar incluye un descriptor físico determinístico generado una sola vez al cargar el caso ("hombre, 30s,
  barba corta, abrigo gris, ojos cansados") y se reusa en cada imagen del mismo sospechoso. No es perfecto, pero es coherente.

  ---
  Lo que queda

  - Casos generados dinámicamente por Gemini (hoy son 2, escritos a mano).
  - Voz del detective (input por micrófono → STT → pregunta).
  - Sistema de pistas físicas: lupa sobre la imagen forense que revela detalles ocultos.
  - Multi-jugador cooperativo: dos detectives, un caso, chat compartido.

  ---
  Echo Case — Hackathon Edition. Backend NestJS + Gemini · Frontend Next.js 15.

Built With

Share this project:

Updates