{"slug": "i-replaced-my-dumb-ask-nhl-with-ai-and-it-actually-slaps", "title": "I Replaced My “Dumb” Ask NHL With AI… and It Actually Slaps", "summary": "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.", "body_md": "“Who are the top scorers this season?”\n\nOld app: ❌ hardcoded endpointsjust knows\n\nNew app: ✅ AI that\n\nYeah… we’re doing that.\n\nI 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 👀\n\nLet’s build it together 🛠️\n\nMost sports apps do this:\n\nBut users don’t think in endpoints… they think in questions:\n\n👉 “Who’s the best goalie right now?”\n\n👉 “Who leads in penalty minutes?”\n\n👉 “What happened in today’s games?”\n\nSo I said… screw it.\n\n**Let’s let AI handle it.**\n\nWe send a user’s question → to Gemini → get a clean NHL answer back.\n\nThat’s it.\n\nBut the 🔑 is how you structure it so it’s:\n\nIf your prompt sucks… your AI sucks 🤷♂️\n\nHere’s mine:\n\n```\nprivate 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()\n```\n\nHere’s the full request flow (with Retry/Backoff strategy — this is a must!):\n\n```\n/** * 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?\"}\n/** * 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,)\nfun 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())                },            )        }    }\n```\n\nWe’re not just building AI… we’re building **experience**.\n\nSimple. Clean. No overengineering.\n\n```\n@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))}\n```\n\nLet the AI shine ✨\n\nThis isn’t just a feature… it’s a **shift in how we build apps**.\n\nInstead of:\n\n“What endpoints do I need?”\n\nStart asking:\n\n“What questions do my users want answered?”\n\nIf you’re building Android apps and NOT experimenting with AI yet…\n\nYou’re already behind 👀🔥\n\nGrab the beta/MVP on [GooglePlay](https://play.google.com/store/apps/dev?id=7614313297301862853&hl=en_US). A sleek Android experience crafted with:\n\n👨👨👦👦 Join the fun Android group on ➜ [LinkedIn](https://www.linkedin.com/groups/14584550/)\n\nBest,\n\nRAFA ROZAY 🍾\n\n[🧠🔥 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.", "url": "https://wpnews.pro/news/i-replaced-my-dumb-ask-nhl-with-ai-and-it-actually-slaps", "canonical_source": "https://blog.stackademic.com/i-replaced-my-dumb-ask-nhl-with-ai-and-it-actually-slaps-85e83a011dfa?source=rss----d1baaa8417a4---4", "published_at": "2026-07-13 09:00:09+00:00", "updated_at": "2026-07-13 09:09:10.037370+00:00", "lang": "en", "topics": ["artificial-intelligence", "generative-ai", "ai-tools", "large-language-models", "developer-tools"], "entities": ["Google", "Gemini API", "NHL"], "alternates": {"html": "https://wpnews.pro/news/i-replaced-my-dumb-ask-nhl-with-ai-and-it-actually-slaps", "markdown": "https://wpnews.pro/news/i-replaced-my-dumb-ask-nhl-with-ai-and-it-actually-slaps.md", "text": "https://wpnews.pro/news/i-replaced-my-dumb-ask-nhl-with-ai-and-it-actually-slaps.txt", "jsonld": "https://wpnews.pro/news/i-replaced-my-dumb-ask-nhl-with-ai-and-it-actually-slaps.jsonld"}}