DEV Community

Cover image for Passion is the one thing you are not supposed to be able to fake. So I built a machine that manufactures it.
JonathanSolvesProblems
JonathanSolvesProblems

Posted on

Passion is the one thing you are not supposed to be able to fake. So I built a machine that manufactures it.

DEV Weekend Challenge: Passion Edition Submission

This is a submission for Weekend Challenge: Passion Edition

What I Built

The prompt for this challenge was passion, so I took the most literal possible
run at it.

Every World Cup app ever made is built for people who already care. Fixtures,
scores, brackets, league tables, fantasy teams. Every one of them assumes you
turned up with a side. Nobody builds anything for the person who wants to care
and has nobody to care about, which is most of the planet: a few billion people
will watch this tournament, and the overwhelming majority of them have no dog in
any of it.

Pick Your Side is for them. You name any two nations. It goes and reads their
actual history, picks the one you were always meant to love, tells you why, and a
stadium announcer swears you in.

Two nations in. One side out, and a reason to mean it.

The interesting problem is that you cannot fake this with a nice adjective. If
the app tells you Ghana and Uruguay have a bitter history and the history is
invented, then the passion it hands you is counterfeit, and the moment you look
it up you will feel stupid and never open it again. Manufactured passion only
works if the thing it is built out of is true.

So the whole architecture is bent around that one constraint.


Demo

pick-your-side.vercel.app

Turn your sound on. Half of this app is audio.

The Pick Your Side landing page. Huge white type on near black reads

Try Uruguay against Ghana. It will side with Ghana, and it will tell you why:
Luis Suarez's handball on the line in 2010, the penalty Asamoah Gyan hit off the
crossbar in the last minute, the first African side ever to stand that close to a
semi-final. It also knows Ghana beat them 2-0 in the group stage a few days ago,
and it still argues the wound has not closed.

The reveal. The screen has flooded green and the word GHANA fills it, with the verdict

Or try England against Germany, if you want to watch it side with the team
that has not won anything since 1966.

Here is the part I am proudest of. Every fact above came from a live search, and
the app will show you exactly which pages it read:

The Verified panel, expanded. On the left, the six live searches Gemini actually ran. On the right, the six pages it actually read, each one a link you can open and check.


Code

Pick Your Side

The World Cup is on and you do not have a team.

Every World Cup app ever built is for the people who already care. This one is for everybody else. Name any two nations. It reads their real history, picks the side you were always meant to love, and a stadium announcer swears you in.

Built for the DEV Weekend Challenge: Passion Edition.

How it works

The interesting part is that none of the history is invented.

1. Gemini researches, grounded in Google Search. The first call has the Google Search tool switched on. Gemini goes and finds the actual head to head record, the actual famous match, and where both sides actually stand in the tournament being played right now. It comes back with the pages it read and the searches it ran, and the app shows you both. Ask it about Morocco and…


How I Built It

Gemini never gets to work from memory

Because of that constraint, it runs twice.

Call one goes and finds out. Google Search grounding is switched on, and
Gemini is told to report only what it can verify: every World Cup meeting between
the two nations with year and score, the single most famous match, what each side
has actually won, and where both of them stand in the tournament being played
right now.

const research = await ai.models.generateContent({
  model: "gemini-2.5-flash",
  contents: `Research ${a.name} and ${b.name} ... Be concise and factual.
    If they have rarely or never played each other, say that plainly
    instead of inventing a rivalry.`,
  config: { tools: [{ googleSearch: {} }] },
});
Enter fullscreen mode Exit fullscreen mode

Call two writes the case, and it only ever sees the facts call one came back
with. It is held to a responseSchema, so the output is always the same shape:
a side, a verdict, the history, the stakes, one legendary moment, your reason, a
line you can share, and a script for the announcer.

const written = await ai.models.generateContent({
  model: "gemini-2.5-flash",
  contents: `...Here is what was just researched. Every fact you use must
    come from here. Do not add matches, scores or players that are not in it:
    <researched-facts>${facts}</researched-facts>`,
  config: {
    responseMimeType: "application/json",
    responseSchema: revealSchema,
  },
});
Enter fullscreen mode Exit fullscreen mode

Why two calls, which is the bit that cost me an hour

I did not want two calls. I wanted one: grounded and schema'd together. The
Gemini docs say Gemini 3 supports exactly that, structured output combined with
Google Search.

On gemini-2.5-flash, asking for googleSearch and a responseSchema in the
same call does not throw an error. It quietly ignores the schema and hands
you back prose.

That is a genuinely nasty failure. Nothing fails, nothing warns, the call
succeeds, and then your UI does JSON.parse() on three paragraphs of essay. I
only caught it because I wrote a throwaway script to poke at the API surface
before I wrote any UI, and I printed the raw output instead of trusting the
status code. If I had trusted it, the reveal would have exploded live.

So: research first, then shape. Which turned out to be the better architecture
anyway, because splitting them meant the facts arrive with sources attached.

The model was thinking, and thinking was making it worse

The whole thing was taking twenty six seconds, which is a long time to look at a
loading screen. gemini-2.5-flash reasons before it answers unless you tell it
not to. I turned it off on both calls. Same model, same prompts, measured:

grounded written total
thinking on 11.6s 14.7s 26.3s
thinking off 5.1s 1.0s 6.1s

Four times faster. And the answers got better, which I did not expect. Asked
for a verdict, thinking on, it reasoned its way to:

France ends Morocco's World Cup run

That is a news headline. It is not a reason to love anybody. Thinking off:

Unfinished business and a deeper wound

The first call is looking things up and the second is being asked to write.
Neither one is a reasoning problem, and reasoning at them cost me twenty seconds
and the only good line in the output.

config: {
  responseMimeType: "application/json",
  responseSchema: revealSchema,
  temperature: 1.0,
  thinkingConfig: { thinkingBudget: 0 },   // <- this
}
Enter fullscreen mode Exit fullscreen mode

Showing the receipts

Because the grounded call comes back with grounding metadata, I know exactly
which pages Gemini read and which searches it ran. So the app shows you.

Verified. None of this was invented. Gemini ran 6 live searches and read 6
pages before it made the case.

Expand it and you get the real search strings and links to fifa.com, wikipedia,
fotmob, whatever it actually used. You can go check it. It is the difference
between a story and a fact, and it is the thing I would want if I were the one
being told who to love.

Here is what convinced me it was worth doing. I asked it about Morocco against
France, and it told me about the 2026 quarter final, a match played days ago.

"This was tragically repeated in the 2026 World Cup quarterfinal, with France
again winning 2-0."

No language model knows that from training. It went and looked.

Telling it to pick the loser

The instruction that made the biggest difference to how the thing feels:

Choose the side with the better story for a newcomer: the deeper wound, the
longer wait, the thing still unfinished. Do not default to the more famous or
more successful nation.

Without that line it picks Brazil, France, Germany, every time, because they are
the most written about. With it, it picks Morocco over France and argues for the
Atlas Lions. It picks the Netherlands over Senegal and leads with three lost
finals. That single sentence is the difference between an app that reports and an
app that has taste.

The voice

Gemini writes the swear-in. ElevenLabs performs it.

The reveal lands, the nation's colour floods the screen, and a voice comes in
over the top of it. It is the moment the whole thing exists for, and it does not
work on the page as text.

const stream = await client.textToSpeech.convert(VOICE, {
  text: `[shouting over a stadium crowd] ${script}`,
  modelId: "eleven_v3",
  outputFormat: "mp3_44100_128",
  voiceSettings: { stability: 0.35, similarityBoost: 0.75, style: 0.65 },
});
Enter fullscreen mode Exit fullscreen mode

eleven_v3 takes inline audio tags, so [shouting over a stadium crowd] is
direction for the read, not words that get spoken. Low stability plus high style
is what stops it sounding like an audiobook.

And then I listened to it, and the problem was obvious. A man was shouting about
a stadium, in total silence. He sounded unhinged.

So ElevenLabs builds the stadium too. Their sound effects API takes a text
prompt the same way the voice does, and I asked it for two things: a restless
crowd that murmurs under the scouting screen while the app is thinking, and an
eruption for the instant the nation's name lands.

await el.textToSoundEffects.convert({
  text: "A packed football stadium erupting the instant a goal goes in. " +
        "A sudden enormous crowd roar, then it settles into a sustained wall " +
        "of cheering that slowly falls away. No music, no commentary.",
  durationSeconds: 10,
});
Enter fullscreen mode Exit fullscreen mode

The crowd sits at a third of the volume and never competes with the announcer.
Both cues are generated once and committed, so they cost nothing and start
instantly. Sound is on by default, because a muted reveal is just a poster, and
the control to turn it off is on screen the entire time.

There are three layers under the voice, because I did not want a visitor to
arrive at a silent reveal:

  1. live text to speech of the script Gemini just wrote for that exact matchup
  2. if that fails or the free quota is spent, a pre-rendered stinger for that nation, which ships with the app
  3. if both fail, silence, and the reveal is otherwise unharmed

Same matchup, same words, same audio, so it is cached and only the first visitor
to a given pairing costs anything.

The announcer kept turning up late

eleven_v3 is the expressive one. It also takes 4.4 seconds, so the nation's
name would slam onto the screen and then nothing would happen for four seconds
and then a voice would arrive, to an empty room. Worse, I had put the dramatic
pause after the download instead of measuring it from the start of the
reveal, so the network time and the pause stacked and the announcer was
arriving 5.1 seconds late. I only found that because I scripted a browser to
click through the live site and timestamp the moment the audio element actually
started playing, which is not something a screenshot will ever tell you.

There is also a trap in the audio tags. [shouting over a stadium crowd] is
direction, not dialogue, but only on v3. Every other model reads it out
loud. I nearly shipped an announcer who introduces himself by saying the words
"shouting over a stadium crowd".

So the live voice is eleven_flash_v2_5, which comes back in under a second and
lands on the beat, and the pre-rendered stingers stay on eleven_v3, where
latency does not exist because they are generated offline.

The colours were a lie

The nations carry their real kit colours. Germany's is #111111. New Zealand's
is #0A0A0A. The whole design floods a near-black page with the nation's
colour, so picking Germany flooded near-black with near-black and the giant word
GERMANY simply was not there.

Eight of the forty one nations failed to clear 4.5:1 against the background. The
fix was not to fake the colours: it was to keep the authentic one as the source
of truth and derive the painted one from it, lifting it toward white only as far
as it has to go to be legible. Brazil's yellow comes out untouched. Germany's
black comes out a graphite you can actually read.

The design has one idea

The landing page has no colour in it. Near black, white type, a faint green glow
where the pitch would be. The only colour anywhere is a slow ribbon of all 41
nations drifting past the bottom, every side you could end up on, none of them
yours.

Then you pick, and the reveal floods the entire screen in your nation's colours.

That is the whole visual argument, and it is the product in one gesture: you have
nothing, and then you have something. Everything on the reveal is driven off the
chosen nation's real kit colours through CSS custom properties, so Morocco is
red, the Netherlands is orange, Argentina is that pale sky blue.

Flags are images rather than emoji, incidentally, because Windows does not render
flag emoji at all. It falls back to letter pairs. A judge on a Windows laptop
would have seen "AR" where Argentina's flag should be.


Prize Categories

Best use of Google AI. Gemini is the entire product, not a feature of it. It
runs twice: once grounded in Google Search to establish real, current, checkable
facts, and once under a forced responseSchema to turn those facts into an
argument. The app shows you the pages it read. It knows results from a tournament
that is still being played.

Best use of ElevenLabs. The swear-in is the emotional payoff of the whole
app, and it is built out of two ElevenLabs APIs. Text to speech is the
announcer: Gemini writes the words for that exact matchup, ElevenLabs is the
voice that makes them land, timed to arrive just behind the nation's name.
Sound effects are the stadium he is shouting into: a restless crowd while the
app is thinking, an eruption when the verdict drops. Turn your sound on.


Keeping it standing

The Google AI Studio free tier caps a project at a few hundred requests a
day, across every visitor combined. Two calls per reveal means this app could
serve about 125 people a day in total before handing everyone else an error. Fine
for building. Useless for being read.

So two things. The app runs on Vertex AI, which needs a billing account and
therefore carries real quota. And the well known matchups are generated ahead of
time by the same grounded pipeline, sources and all, and committed to the repo.
Those answer in half a second and cost nothing. They are cached, not faked.

When the budget does run out, the failure hands you a matchup that always works
instead of a dead end. I would rather you get a team from a slightly stale cache
than a stack trace.

What I did not solve

The nations list is curated to 41 sides, so you cannot pick your local club. A
matchup nobody has asked for before takes about twelve seconds, which I dressed
up as a scouting sequence rather than fixed. And the "what is at stake" text goes
stale six hours after it is written, which is a strange thing to have to think
about, and only a problem because the tournament is still being played while you
read this.

One last thing

I should probably admit that I am the target user.

The World Cup is being played in my country. Everyone I know has lost their mind
over it, and for the whole tournament I have been standing in the middle of that
with nothing: no nation, no shirt, no grudge, nobody to shout at. I did not build
this as a demo of an API. I built it because I wanted in and could not find the
door.

I pointed it at Uruguay and Ghana, because I did not know anything about either
of them.

It gave me Ghana.

The share card in Ghana's green and gold. It reads:

I wrote up the longer version of the build, including the two Gemini traps that
nearly shipped, on my own site.

Built by Jonathan Andrei.

Top comments (1)

Collapse
 
jonathansolvesstuff profile image
JonathanSolvesProblems

Let me know if any feedback! :)