SmolCat: An AI Cat Companion
A retro tamagotchi-style virtual cat that chats back — React 19 on the front, Azure Functions and Gemini on the back, hosted entirely on student-tier free credits because apartment rules said no to the real thing.
Ever since I was a kid, I've loved cats. I wanted one in my apartment, but budget limits and apartment rules had other plans. Around the same time I'd been itching to experiment with the Gemini API — and there it was, the perfect collision of the two: an AI cat companion. As cringe as that sounds, I had a lot of fun and learned a lot of stuff, and that was the whole point.
SmolCat is a retro-inspired virtual pet living at smolcat.me. You feed it, pet it, put it to sleep, rename it, flip through pixel-art skins — and talk to it. The chat runs through a Gemini-powered Azure Functions backend that answers in character, based on how hungry, happy, and tired the cat actually is right now.
Contents
- The Care Loop
- Chatting with the Cat
- Sign-In and Persistence
- Running It Locally
- Deployment
- Results
- Roadmap
- Lessons Learned
The Care Loop
The frontend is React 19 + TypeScript on Vite, rendering a handheld-console UI with plain CSS per component — no UI framework. GitHub Copilot was genuinely handy for iterating on the frontend design.
The cat runs on three stats, all clamped 0–100 and all "high = good":
| Stat | Default | Decay | Boost |
|---|---|---|---|
| Fullness | 50 | −12/min | Feed: +15 |
| Happiness | 70 | −6/min | Pet: +12 |
| Energy | 60 | −6/min | Sleep: +20 (and +5 fullness) |
A live tick applies decay every 10 seconds (−2 fullness, −1 happiness, −1 energy), and every change is written to localStorage under smolcat.stats — so refreshing the page no longer resets the cat. Come back after time away and the elapsed minutes are applied as offline decay, capped at 60 minutes (MAX_DECAY_MINUTES), so a cat left overnight is hungry, not permanently miserable.
The mood that all of this produces drives the pixel cat's animation, and there are three switchable skins (the choice persists in localStorage too). All of the stat logic lives in src/useCatState.ts and src/catState.ts.
Chatting with the Cat
The fun part. Sending a message posts the current stats, session ID, cat name, and message to POST /api/chatWithCat — an Azure Functions v4 handler that builds a system prompt from the cat's live state and calls gemini-2.5-flash-lite via @google/genai.
The system prompt is where the personality lives. The cat speaks "in a cute, short, slightly sassy manner", always includes cat sounds, keeps replies under 20 words so they fit in a chat bubble, and follows mood rules driven by the stats:
- Fullness below 20: HANGRY. It ignores your topic and demands food, in caps lock.
- Energy below 20: falling asleep — slurred words, yawns, or just "Zzz...".
- Happiness above 80: affectionate — purring, 😸, actually helpful.
Each session's last 8 messages are loaded from Azure Table Storage and replayed as conversation history, so the cat remembers what you just said. Anonymous visitors get a browser-generated UUID v4 session ID (persisted in localStorage), and are rate limited to 5 messages per minute — a fixed-window limiter backed by its own table, returning a 429 when exceeded — so a stray script can't burn the Gemini quota. Signed-in users chat without limits. Notably, the limiter fails closed: if the rate-limit storage is unavailable, anonymous chat is blocked rather than left open.
Sign-In and Persistence
Google login comes almost for free from Azure Static Web Apps: staticwebapp.config.json wires up the Google identity provider and rewrites /login and /logout to the built-in /.auth/ routes. After login, SWA injects an x-ms-client-principal header into every API request — validated at the platform level, so the Functions code just decodes it to get a trustworthy user ID.
For signed-in users, stats sync to the server: loaded once from GET /api/getCat on startup (with server-side decay applied for time away) and saved to POST /api/updateCat with a 3-second debounce on every change. The server sets lastUpdated itself, so clients can't rewind the decay clock. State lives across four Table Storage tables: CatStates, CatChatHistory, CatSessions, and CatRateLimits. Chat sessions are bound to the account that first used them — reusing someone else's session ID gets a 403.
Running It Locally
Two apps: the Vite frontend at the repo root and the Functions app in api/. Install both, then give the API its settings in api/local.settings.json:
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "node",
"GEMINI_API_KEY": "your-gemini-api-key"
}
}Run the two halves in separate terminals:
# Terminal 1 — API (from api/)
npm run build
npm start # Functions host on http://localhost:7071
# Terminal 2 — frontend (from the repo root)
npm run dev # Vite on http://localhost:5173Vite proxies /api/* to the Functions host, so the frontend talks to the local backend transparently. Azurite fills in for Table Storage locally, and the API's decay and rate-limit logic is covered by vitest unit tests (npm test in api/).
Deployment
This is where the student offers earned their keep. The whole thing runs on Azure Static Web Apps — frontend and API deployed together by a GitHub Actions workflow on every push to main (app location /, API location api, output dist). Azure hosting comes out of Azure student credits, and the smolcat.me domain came free with the GitHub Student Developer Pack. Total infrastructure cost: $0. The site is live now and funded through February 2027.
The only secrets the app needs in Azure are GEMINI_API_KEY, AzureWebJobsStorage, and the GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET pair for auth.
Results
SmolCat is live at smolcat.me. The care loop, mood-aware Gemini chat, Google sign-in, cross-device stat sync, and anonymous rate limiting all work in production. The decay and rate-limit logic — the two pieces most likely to silently misbehave — are pinned down by 15 vitest unit tests.
Roadmap
- Merge anonymous local progress into the server state on first sign-in, so a cat raised before logging in isn't abandoned.
- Richer cat animations and more moods.
- Expanded AI memory and personality controls.
- Frontend component tests alongside the existing API unit tests.
Lessons Learned
- Duplicated constants are a contract. The decay rates exist twice — client tick and server catch-up — and are documented as MUST-stay-in-sync. When state is computed in two places, the sync requirement belongs in writing, right next to the numbers.
- Never trust the client with the clock. Letting the server own
lastUpdatedis one line of design that closes an entire category of stat-cheating. - Rate-limit anything that touches a paid API — and fail closed. An open AI endpoint is a quota bill waiting to happen; blocking anonymous chat when the limiter's storage is down costs a little availability and saves the quota.
- Student tiers are enough to ship something real. Azure credits, a free
.medomain, and Static Web Apps' built-in auth covered hosting, HTTPS, CI/CD, and Google login without a single invoice. - Fun is a valid spec. I built this to learn and to have fun, and both shipped.
Want to say hi to the cat? It's live at smolcat.me — and the whole thing is open source at Manixh-S/SmolCatAI if you'd rather read the code than pet the cat.