Planet Zoo 2 unlocks globally on 2026-10-13. I wanted a reference site that answers the questions the game itself does not answer before launch: which animals ship, what they need, and which of them can actually share an enclosure. There is no public API for that, so I built one dataset out of the publisher's own pages.

This is a write-up of the extraction, the normalisation, and the one derived table that I think is actually interesting — the co-habitation matrix.

Step 1 — the data is already there, just not as an API

The official Zoopedia pages render server-side, but they are a Nuxt app, so every page ships its own data payload. View source on any animal page and you get the serialised state before hydration.

The shape is roughly this (heavily abridged):

[ ["ShallowReactive", 1], { "animal-1": 2, "zoopedia": 5 }, "Aardvark", ["Reactive", 4], { "biomes": 6, "enclosure": 8 }, ["Savannah"], "HabitatTerrestrial" ] 

It is a flat array where entries reference each other by index, which is the same trick devalue uses to serialise a cyclic object graph. Reading it means writing a small resolver: start at the root index, and every time a value is an integer that is a valid index into the same array, recurse instead of taking it literally.

The resolver is about 20 lines, and it is the whole reason this was an afternoon of work instead of a weekend:

def resolve(payload, idx, seen=None): seen = seen or set() if isinstance(idx, bool) or not isinstance(idx, int): return idx if idx < 0 or idx >= len(payload) or idx in seen: return None seen = seen | {idx} node = payload[idx] if isinstance(node, dict): return {k: resolve(payload, v, seen) for k, v in node.items()} if isinstance(node, list): return [resolve(payload, v, seen) for v in node] return node 

Two gotchas worth naming, because they cost me the most time:

  1. Booleans are integers in Python. True == 1, so an unguarded isinstance(v, int) will happily dereference index 1 for a boolean true. That isinstance(idx, bool) check is load-bearing.
  2. Cycle guards matter. Some entries point back at their parents. Without the seen set you get a recursion error instead of a dataset.

Step 2 — 130 species, normalised

Pulling every Zoopedia slug and running the resolver over each page gives one record per species. I flattened it, because the interesting work later is joining, not nesting:

common_name,slug_en,conservation_status,class,biomes,enclosure_types,content_pack,weight_male_kg,barrier_min_height_m Aardvark,aardvark,LeastConcern,Mammalia,Savannah,HabitatTerrestrial,BaseGame,50,1 

What is in the launch set:

  • 130 species — 124 in the base game, 6 Deluxe-only.
  • Enclosure types, where an animal can require more than one: HabitatTerrestrial 54, Exhibit 30, HabitatAquarium 19, HabitatAquarium + Exhibit 15, HabitatFlying 12.
  • Conservation status: LeastConcern 63 · Endangered 22 · Vulnerable 18 · CriticallyEndangered 12 · NotEvaluated 8 · NearThreatened 5 · Domesticated 1 · DataDeficient 1.

Multi-value fields stay delimiter-separated in the CSV (Savannah;Desert) rather than becoming JSON-in-a-cell, because every consumer I have is a spreadsheet or a pandas call, and neither enjoys parsing JSON out of a string.

Step 3 — the co-habitation matrix, which is the actually fun part

Here is the question the game will not answer for you until after launch: can these two animals live in the same enclosure?

The naive approach is 130 choose 2 = 8,385 pairs. Most are obvious nonsense — a penguin and an elephant share nothing. So filter first:

A pair only survives if both animals share at least one biome and at least one enclosure type.

That cut 8,385 down to 1,221 candidate pairs. Now the hard part: the publisher does not publish a compatibility table. What it does publish, per species, is a Social & Reproduction section — prose about whether the animal is solitary, social, mixed, territorial or aggressive.

So the rules had to be derived from that prose, and the honest version is:

  1. If either animal is documented as solitary → no
  2. If both are documented as social or mixed → likely
  3. Anything else → unverified

    def verdict(social_a, social_b):
    if "solitary" in (social_a, social_b):
    return "no"
    if social_a in {"social", "mixed"} and social_b in {"social", "mixed"}:
    return "likely"
    return "unverified"

Over the 1,221 pairs that gives 376 likely, 106 unverified, 739 no.

The part I want to defend is that third bucket. The tempting move is a binary yes/no, and it would be a lie. unverified is not a rounding error or a hedge — it means the publisher's own documentation does not settle this pair, so the correct advice is "test it in game", not "probably fine". Rolling it into likely would have made the table tidier and worse.

Each surviving pair also keeps the quoted source sentence it was classified from, so a reader can disagree with the classification instead of just trusting it.

Step 4 — shipping it as data, not only as pages

Two decisions there mattered more than any frontend work.

Structured data on every page. The site emits a JSON-LD @graph with WebSite, Organization and a real Dataset node — with distribution pointing at the raw CSV/JSON, sameAs pointing at the mirrors, and an explicit CC BY 4.0 licence. That is what makes the site itself eligible for dataset search surfaces rather than only for normal web search.

A citable DOI. The dataset is archived on Zenodo, so "how do I cite this" has an answer that is not a URL that might rot: 10.5281/zenodo.23263613.

What I would tell someone starting the same thing

  • Extract from the publisher's own pages, and say so. This is second-hand data that is already public, not datamining and not a leaked build. That distinction is the difference between a project people can link to politely and one that gets a takedown.
  • Keep derived data clearly separated from extracted data. The 130 species are the publisher's facts; the verdicts are my inference with a stated rule. Blurring those two categories in one table is how you end up publishing claims you cannot support.
  • Ship a machine-readable copy, not just HTML. The moment there is a CSV and a DOI, other people can cite you — and a citation is a link that does not go stale.
  • Put the unglamorous third option in the schema. unverified was more work than a boolean, and it is the only reason I trust the table.

Links

Data is CC BY 4.0 — take it, reuse it, just credit the site. Planet Zoo and Planet Zoo 2 are trademarks of Frontier Developments plc; this project is unofficial and not affiliated with or endorsed by Frontier.