Case Study · Live

Pokopia Habitat Planner — A recommendation system on a self-healing dataset.

A side project for the Switch 2 game Pokémon Pokopia. Built out of necessity, but the underlying engineering is the same recommendation pattern I work with professionally. Different domain, identical problem at the right level of abstraction.

Status · Live Type · Side project HTML Vanilla JS CSS Python 3 requests · BeautifulSoup ~1,500 LOC

In Pokémon Pokopia, up to four Pokémon share a habitat, and each Pokémon has a set of "favourite" item categories. Picking a complementary roster (so the items you place make as many residents happy as possible) is a small combinatorial puzzle. The app makes that puzzle solvable at a glance.

307
Pokémon
607
Items
43
Favourite categories
~1,500
Lines of code

The brief

I needed to plan habitats efficiently in-game. The native interface tells you each Pokémon's favourite categories one at a time. Fine for two residents, painful at four. I wanted a tool that, given a roster, would surface the items most residents would care about, ranked by overlap, with light filtering on category and item type.

The data model

Underneath, the structure is a directed graph between three node types: Pokémon, favourite categories, and items. A Pokémon points at its favourite categories; a category contains many items; an item belongs to one category and optionally one type tag (Decoration, Toy, Relaxation, Road, or Untagged).

The UX challenge is that nobody wants to see a graph. They want one ranked list: "which items, given my roster, make the most residents happy?"

The flattening is the recommendation logic:

# For each item, count how many roster members
# share the favourite category that contains it.
# Rank descending. Tie-break on category name.

function rankItems(roster, items, categories) {
  return items
    .map(item => ({
      ...item,
      overlap: roster.filter(p => p.favourites.includes(item.category)).length
    }))
    .filter(item => item.overlap > 0)
    .sort((a, b) => b.overlap - a.overlap || a.category.localeCompare(b.category));
}

The user never has to think about the graph. They add Pokémon to slots; the page re-ranks immediately; the top of the list is "this item makes 4 of your 4 residents happy", working downward to "1 of 4". That's the whole product.

The engineering

Frontend — single file, no build, no framework

One index.html with vanilla JavaScript and CSS custom properties for theming. A custom anchored ARIA combobox for searching Pokémon, full keyboard navigation, and colour-coded type pills. Data layer is a single data.js file that wraps the dataset onto window.POKOPIA_DATA for the page to consume.

No bundler, no transpiler, no node_modules. The whole thing is two HTML/JS files and a folder of sprites. A user could clone it and double-click index.html and the tool would run.

Data pipeline — a self-healing scraper

The dataset comes from serebii.net via a Python scraper (scrape_pokopia.py) using requests and BeautifulSoup. What makes it self-healing:

Why this matters

The same patterns I rely on at work: idempotent re-runs, separation of "fetch raw" from "parse structured", local caching for resilience. A side project is a legitimate place to practice the boring fundamentals that make data pipelines reliable. None of this is unique to Pokémon; it's how I'd build a scraper for anything.

Decisions worth mentioning

No framework, on purpose

A planner for a Pokémon game does not need React. The whole frontend is one index.html with HTML, CSS, and vanilla JS in a single file, and would have been bigger and slower in any framework. Static-first means it loads instantly, runs offline after first paint, and doesn't bit-rot when the framework ecosystem moves on. For a personal tool I want to still work in five years, that's a feature, not a constraint.

Graph model in storage, ranked list in the UI

I deliberately split the data model from the user-facing representation. The graph lets me re-derive any view (which categories does this roster overlap on? which items appear in multiple categories? which Pokémon care about this specific item?) without restructuring the data. The UI flattens to a single ranked list because that's the actionable answer. If I add a different view later (a per-Pokémon view, a comparison view), the same data layer supports it.

Local sprite copies, downloaded once

I considered hot-linking sprites from serebii but didn't, both for politeness (don't be a freeloader on someone else's bandwidth) and resilience (if their CDN changes, my tool keeps working). 307 PNGs is ~30 MB; trivial to bundle.

Takeaways

01

Build for yourself first

I built this because I needed it. Wanting it badly enough to build is half the work.

02

The pattern travels

Item-roster overlap ranking is the same pattern as a recommendation row. Different data, identical thinking.

03

Boring infra wins

Idempotent caching and clean fetch/parse separation aren't sexy. They're why the scraper still runs months later without babysitting.

04

Static beats clever

Two files and a folder. No backend, no framework. Loads instantly, hosts free, ages well.