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

> Source: <https://blog.stackademic.com/i-replaced-my-dumb-ask-nhl-with-ai-and-it-actually-slaps-85e83a011dfa?source=rss----d1baaa8417a4---4>
> Published: 2026-07-13 09:00:09+00:00

“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 Loading 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.Loading -> {                        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](https://play.google.com/store/apps/dev?id=7614313297301862853&hl=en_US). A sleek Android experience crafted with:

👨👨👦👦 Join the fun Android group on ➜ [LinkedIn](https://www.linkedin.com/groups/14584550/)

Best,

RAFA ROZAY 🍾

[🧠🔥 I Replaced My “Dumb” Ask NHL With AI… and It Actually Slaps 🤯🏒](https://blog.stackademic.com/i-replaced-my-dumb-ask-nhl-with-ai-and-it-actually-slaps-85e83a011dfa) was originally published in [Stackademic](https://blog.stackademic.com) on Medium, where people are continuing the conversation by highlighting and responding to this story.
