cd /news/artificial-intelligence/i-replaced-my-dumb-ask-nhl-with-ai-a… · home topics artificial-intelligence article
[ARTICLE · art-57082] src=blog.stackademic.com ↗ pub= topic=artificial-intelligence verified=true sentiment=↑ positive

I Replaced My “Dumb” Ask NHL With AI… and It Actually Slaps

A developer replaced a hardcoded NHL stats app with an AI-powered assistant using Google's Gemini API, allowing users to ask natural-language questions about hockey. The project demonstrates how to structure prompts and implement retry logic for reliable AI responses.

read11 min views1 publishedJul 13, 2026

“Who are the top scorers this season?”

Old app: ❌ hardcoded endpointsjust knows

New app: ✅ AI that

Yeah… we’re doing that.

I turned my Ask NHL screen into a real AI-powered assistant using Google’s Gemini API — and it took way less code than you think 👀

Let’s build it together 🛠️

Most sports apps do this:

But users don’t think in endpoints… they think in questions:

👉 “Who’s the best goalie right now?”

👉 “Who leads in penalty minutes?”

👉 “What happened in today’s games?”

So I said… screw it.

Let’s let AI handle it.

We send a user’s question → to Gemini → get a clean NHL answer back.

That’s it.

But the 🔑 is how you structure it so it’s:

If your prompt sucks… your AI sucks 🤷♂️

Here’s mine:

private fun buildPrompt(question: String): String = """    You are the "Ask NHL" assistant inside an NHL fan app. Answer the user's    hockey question directly and concisely (2-4 sentences max). If the question    isn't about hockey or the NHL, politely say you can only help with NHL topics.    Question: $question""".trimIndent()

Here’s the full request flow (with Retry/Backoff strategy — this is a must!):

/** * Talks to Gemini to answer free-text NHL questions from the "Ask NHL" screen. */class AskNhlRepository @Inject constructor(    private val geminiApi: GeminiApi,) {    /**     * Sends [question] to Gemini and returns the generated answer text.     * Wrapped in a Result so the ViewModel can cleanly show an error state     * instead of crashing or leaking a raw exception into the UI layer.     *     * Retries transient failures (rate limits, 5xx, network blips) with     * exponential backoff before giving up — Gemini free-tier flash throws     * 503 "overloaded" fairly often, so one failed attempt isn't treated     * as a real failure yet.     */    suspend fun askQuestion(question: String): Result<String> {        return try {            val answerText = withRetry {                val request = GeminiRequest(                    contents = listOf(                        GeminiContent(parts = listOf(GeminiPart(text = buildPrompt(question))))                    )                )                val response = geminiApi.generateContent(                    apiKey = BuildConfig.GEMINI_API_KEY,                    request = request,                )                // Gemini nests the answer text pretty deep: candidates[0].content.parts[0].text                response.candidates                    ?.firstOrNull()                    ?.content                    ?.parts                    ?.firstOrNull()                    ?.text            }            if (answerText.isNullOrBlank()) {                Result.failure(IllegalStateException("Gemini returned an empty response"))            } else {                Result.success(answerText.trim())            }        } catch (e: Exception) {            // Anything that escapes withRetry (a non-retryable HTTP error,            // or retries exhausted) lands here as a Result.failure instead            // of propagating up and crashing the caller.            Result.failure(e)        }    }    /**     * Runs [block] up to [maxAttempts] times with exponential backoff     * between failures.     *     * maxAttempts is 2 (not 3): with a 20s per-attempt timeout (see     * NetworkModule), 2 attempts caps the worst-case user wait around ~40s     * plus a small backoff delay. A 3rd attempt rarely rescues a request     * that's failed twice and just makes the user wait longer for the     * same likely outcome — not worth it for a chat-style UI on MVP.     *     * Only retries error types that are likely transient — a 401 or a     * malformed request will fail identically every time, so those are     * rethrown immediately instead of wasting an attempt on them.     */    private suspend fun <T> withRetry(        maxAttempts: Int = 2,        initialDelayMs: Long = 1_000,        maxDelayMs: Long = 8_000,        block: suspend () -> T,    ): T {        var lastError: Throwable? = null        repeat(maxAttempts) { attempt ->            try {                // Success — return immediately, skipping any remaining attempts.                return block()            } catch (e: HttpException) {                // A 429 with a *daily* quota ID means retrying is pointless —                // the limit doesn't reset for hours, not for a backoff delay.                // Read the error body once here and bail immediately instead                // of burning an attempt (and the user's time) on a retry that                // cannot succeed.                if (e.code() == 429 && e.isDailyQuotaExhausted()) {                    throw e                }                val retryable = e.code() in setOf(429, 500, 502, 503, 504)                if (!retryable) throw e                lastError = e            } catch (e: IOException) {                // No connection / timeout / DNS failure — always transient,                // always worth a retry.                lastError = e            }            // Exponential backoff: 1s, 2s, ... capped at maxDelayMs.            // Jitter (random extra delay up to 25% of backoff) spreads out            // retries from multiple concurrent requests so they don't all            // retry in lockstep and cause another spike.            val backoff = (initialDelayMs * 2.0.pow(attempt))                .toLong()                .coerceAtMost(maxDelayMs)            val jitter = Random.nextLong(0, backoff / 4 + 1)            delay(backoff + jitter)        }        throw lastError ?: IllegalStateException("Gemini request failed with no captured error")    }    /**     * Wraps the raw user question with instructions so Gemini stays     * on-topic and keeps answers short enough to fit the UI.     */    private fun buildPrompt(question: String): String = """        You are the "Ask NHL" assistant inside an NHL fan app. Answer the user's        hockey question directly and concisely (2-4 sentences max). If the question        isn't about hockey or the NHL, politely say you can only help with NHL topics.        Question: $question    """.trimIndent()}/** * Checks whether a 429's error body indicates the *daily* quota (RPD request per minute) was * exhausted, as opposed to a per-minute rate limit (RPM), which IS worth * retrying. Google's error body includes a "quotaId" string that contains * "PerDay" for daily quota violations — that's the signal we key off of. * errorBody() can only be read once per HttpException, so this reads and * discards it; callers shouldn't try to read the body again afterward. */private fun HttpException.isDailyQuotaExhausted(): Boolean {    val body = response()?.errorBody()?.string() ?: return false    return body.contains("PerDay", ignoreCase = true)}/** * Converts a failure from askQuestion() into copy that's safe to show a * user. Lives near the repository rather than in the ViewModel, since * "what does this error mean to a user" is closer to a networking concern * than a UI-state concern. */fun Throwable.toAskNhlUserMessage(): String = when (this) {    is HttpException -> when {        code() == 429 && isDailyQuotaExhausted() ->            "Ask NHL has hit its free daily limit — check back tomorrow!"        code() == 503 || code() == 429 ->            "NHL brain is a little overloaded right now — give it a sec and try again."        code() == 401 || code() == 403 ->            "Something's wrong on our end, not you. We're on it."        else -> "Couldn't get an answer for that one. Try rephrasing?"    }    is IOException -> "Looks like you're offline — check your connection and try again."    else -> "Something went sideways. Try again?"}
/** * Retrofit interface describing the Gemini REST endpoint. * Retrofit generates the actual HTTP implementation of this interface at * runtime — you never implement it yourself, just declare the shape of the * request/response. * * Docs: https://ai.google.dev/api/generate-content */interface GeminiApi {    /**     * @POST marks this as a POST request. The {model} in the URL is filled     * in by @Path("model") below — Retrofit substitutes it before sending.     * @Header x-goog-api-key sends the API key in the request header for     * secure authentication, as required by the Gemini REST API.     */    @POST("v1beta/models/{model}:generateContent")    suspend fun generateContent(        // Switched from gemini-3.5-flash (20 RPD on free tier) to        // gemini-3.1-flash-lite, which carries a much more generous free-tier        // quota on this project — 500 RPD / 15 RPM vs. 20 RPD / 5 RPM.        // Confirmed via live AI Studio rate-limit dashboard, not assumed.        @Header("x-goog-api-key") apiKey: String,         @Path("model") model: String = "gemini-3.1-flash-lite",        @Body request: GeminiRequest,    ): GeminiResponse}/** * Top-level request payload sent to Gemini. * @JsonClass(generateAdapter = true) tells Moshi to generate JSON * serialization code for this class at compile time — fast, no reflection. * Make sure to -keep your models in proguard for R8 Minification purposes. */@JsonClass(generateAdapter = true)data class GeminiRequest(    // Gemini expects the prompt wrapped in a list of "turns" to support    // multi-turn conversations. We only ever send a single turn.    val contents: List<GeminiContent>,    val generationConfig: GeminiGenerationConfig = GeminiGenerationConfig(),)/** * A single turn in the conversation. */@JsonClass(generateAdapter = true)data class GeminiContent(    // A turn is split into "parts" (text, image, etc). We only ever send    // one text part.    val parts: List<GeminiPart>,    // "user" = our prompt. Gemini also accepts "model" for prior AI    // replies in real multi-turn chat — we're not doing that, so this is    // always "user".    val role: String = "user",)/** * A single chunk of content within a turn — just the raw prompt text. */@JsonClass(generateAdapter = true)data class GeminiPart(    val text: String,)/** * Controls HOW Gemini generates its response (not what it's asked). * * - temperature: randomness/creativity (0.0 = deterministic, 1.0 = very *   random). Kept low since we want factual, consistent hockey answers. * - maxOutputTokens: hard cap on response length. With thinking disabled *   (see thinkingConfig below), this budget is now spent entirely on the *   visible answer, so 400 comfortably covers a 2-4 sentence response. * - thinkingConfig: disables Gemini's internal reasoning pass. Without *   this, gemini-3.5-flash (a "thinking" model) spends a chunk of *   maxOutputTokens on invisible reasoning before writing the actual *   answer — in testing this ate 382 of our 400 token budget, leaving *   only 14 tokens for the real response and cutting it off mid-sentence *   (finishReason: MAX_TOKENS). Simple factual Q&A like "who won X in Y" *   doesn't need deep reasoning, so turning it off is free speed + a *   meaningful token/cost savings — worth it for MVP on free tier. */@JsonClass(generateAdapter = true)data class GeminiGenerationConfig(    val temperature: Double = 0.4,    @Json(name = "maxOutputTokens") val maxOutputTokens: Int = 400,    val responseMimeType: String = "text/plain",    val thinkingConfig: GeminiThinkingConfig = GeminiThinkingConfig(),)/** * thinkingBudget = max tokens the model may spend on internal reasoning * before producing the visible answer. 0 disables thinking entirely. */@JsonClass(generateAdapter = true)data class GeminiThinkingConfig(    @Json(name = "thinkingBudget") val thinkingBudget: Int = 0,)/** * Top-level response from Gemini. */@JsonClass(generateAdapter = true)data class GeminiResponse(    // Nullable + defaulted because a failed/blocked request can come back    // with no candidates at all (e.g. safety filter tripped). "candidates"    // is a list because Gemini can return multiple response variants — we    // only ever look at the first one.    val candidates: List<GeminiCandidate>? = null,)/** * A single generated response option. */@JsonClass(generateAdapter = true)data class GeminiCandidate(    // Mirrors the request's Content/Part shape.    val content: GeminiContent? = null,    // Why generation stopped: "STOP" (normal), "MAX_TOKENS" (cut off),    // "SAFETY" (blocked), etc. Not branched on yet, but useful later if    // you want different handling for truncated vs. blocked vs. success.    @Json(name = "finishReason") val finishReason: String? = null,)
fun onQuestionSubmitted(question: String) {        if (question.isBlank()) return        // Start cycling "thinking" messages immediately, before the network        // call even starts — this also sets the initial  state.        startThinkingTicker()        viewModelScope.launch {            geminiRepository.askQuestion(question).fold(                onSuccess = { answer ->                    // Stop the ticker now that we have a real result —                    // otherwise it'd keep overwriting uiState every 500ms                    // even after we've already set Success.                    thinkingTickerJob?.cancel()                    _uiState.value = AskNhlUiState.Success(question, answer)                },                onFailure = { throwable ->                    thinkingTickerJob?.cancel()                    // toAskNhlUserMessage() converts the raw exception (HTTP                    // code, IOException, etc) into copy that's safe to show                    // a user — e.g. a 503 becomes "overloaded, try again"                    // instead of a stack trace or raw error string.                    _uiState.value = AskNhlUiState.Error(throwable.toAskNhlUserMessage())                },            )        }    }

We’re not just building AI… we’re building experience.

Simple. Clean. No overengineering.

@Composablefun AskNhlScreen(navController: NavController, viewModel: AskNhlViewModel = hiltViewModel()) {    val uiState by viewModel.uiState.collectAsState()    var inputText by remember { mutableStateOf("") }    val keyboardController = LocalSoftwareKeyboardController.current    val focusManager = LocalFocusManager.current    fun submit() {        if (inputText.isNotBlank()) {            viewModel.onQuestionSubmitted(inputText)            keyboardController?.hide()            focusManager.clearFocus()        }    }    fun onPromptSelected(prompt: String) {        inputText = prompt        viewModel.onQuestionSubmitted(prompt)    }    NhlMainScaffold(        topBar = {            MultiWidgetOnClickToolBar(                handleBackNavigation = { navController.popBackStack() },                title = "ASK NHL (Beta)",                actions = {},            )        },        navController = navController,        bottomBarType = BottomBarType.AD_BANNER,    ) { padding ->        Column(            modifier = Modifier                .fillMaxSize()                .padding(padding)                .imePadding(),        ) {            LazyColumn(                modifier = Modifier                    .weight(1f)                    .fillMaxWidth()                    .padding(horizontal = 16.dp),                verticalArrangement = Arrangement.spacedBy(8.dp),            ) {                when (val state = uiState) {                    is AskNhlUiState.Idle -> {                        item {                            Text(                                text = "WHERE DO YOU\nWANT TO START?",                                fontFamily = FontFamily(Font(R.font.nhlitalic)),                                fontWeight = FontWeight.Bold,                                fontSize = 36.sp,                                lineHeight = 32.sp,                                modifier = Modifier.padding(top = 26.dp),                            )                            Spacer(Modifier.height(16.dp))                        }                        items(viewModel.suggestedPrompts) { prompt ->                            PromptChip(prompt = prompt, onClick = { onPromptSelected(prompt) })                        }                    }                    is AskNhlUiState. -> {                        item { CircularProgressIndicator() }                    }                    is AskNhlUiState.Success -> {                        item {                            Spacer(Modifier.height(16.dp))                            Text(text = state.question, style = MaterialTheme.typography.bodyLarge)                            Spacer(Modifier.height(8.dp))                            Text(text = state.answer, style = MaterialTheme.typography.titleLarge)                        }                    }                    is AskNhlUiState.Error -> {                        item { Text(text = "Something went wrong: ${state.message}") }                    }                }            }            NhlOutlinedText(                value = inputText,                onValueChange = { inputText = it },                label = "Ask about the NHL",                trailingIcon = {                    if (inputText.isNotBlank()) {                        TextButton(onClick = { submit() }) {                            Text(text = "Go", color = Color.Black)                        }                    }                },                modifier = Modifier.padding(all = 16.dp),                keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),                keyboardActions = KeyboardActions(onDone = { submit() }),                shape = RoundedCornerShape(28.dp),            )        }    }}@Composableprivate fun PromptChip(prompt: String, onClick: () -> Unit) {    SuggestionChip(        onClick = onClick,        label = {            Text(                text = prompt,                style = MaterialTheme.typography.titleLarge,                modifier = Modifier.padding(all = 8.dp),            )        },        shape = CircleShape,        border = SuggestionChipDefaults.suggestionChipBorder(            enabled = true,            borderWidth = 1.dp,        ),        colors = SuggestionChipDefaults.suggestionChipColors(            containerColor = Color.Transparent,        ),    )    Spacer(Modifier.height(12.dp))}

Let the AI shine ✨

This isn’t just a feature… it’s a shift in how we build apps.

Instead of:

“What endpoints do I need?”

Start asking:

“What questions do my users want answered?”

If you’re building Android apps and NOT experimenting with AI yet…

You’re already behind 👀🔥

Grab the beta/MVP on GooglePlay. A sleek Android experience crafted with:

👨👨👦👦 Join the fun Android group on ➜ LinkedIn

Best,

RAFA ROZAY 🍾

🧠🔥 I Replaced My “Dumb” Ask NHL With AI… and It Actually Slaps 🤯🏒 was originally published in Stackademic on Medium, where people are continuing the conversation by highlighting and responding to this story.

── more in #artificial-intelligence 4 stories · sorted by recency
── more on @google 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/i-replaced-my-dumb-a…] indexed:0 read:11min 2026-07-13 ·