---
title: An AI Crazy Dave You Can Talk To in Plants vs. Zombies
subtitle: How we connected a language model to the original Plants vs. Zombies engine. The lawn snapshot the model sees, the JSON it may send back, and the C++ checks that run before anything on the lawn changes.
description: How Yumina wired a language model into the original Plants vs. Zombies engine running as WebAssembly. One HTTP call per line, a lawn snapshot in, a validated JSON reply out, eight favors the game approves in advance, a tin whose contents are fixed before you buy, and a 64 KiB notebook.
author: Yumina
date: 2026-10-18
category: engineering
tags: Plants vs. Zombies, AI NPC, game mod, Yumina engine
lang: en
url: https://yumina.io/blog/ai-crazy-dave-plants-vs-zombies
canonical: https://yumina.io/blog/ai-crazy-dave-plants-vs-zombies
---

# An AI Crazy Dave You Can Talk To in Plants vs. Zombies

*How we connected a language model to the original Plants vs. Zombies engine. The lawn snapshot the model sees, the JSON it may send back, and the C++ checks that run before anything on the lawn changes.*

What if an AI controlled Crazy Dave, and some of what happens on the lawn? That was the question we started with. PvZ Online is the original Plants vs. Zombies, compiled from its C++ source to WebAssembly and running in your browser on yumina.io.

We connected Dave to a language model. Open him mid-level and the game pauses. He looks at your lawn, says what he thinks of it, remembers what you told him three levels ago, and sometimes offers a favor. He can give you 50 sun. He cannot give you 5,000.

That last line is the whole design. The model gets a snapshot of the game and a menu of eight favors, and the game checks everything he proposes before it happens. This post is how we wired a language model into a 2009 game engine that was never built for one, and what broke on the way.

## How the model is wired in

The game is C++ compiled to WebAssembly. It knows nothing about language models. The whole connection is one HTTP call per line of dialogue.

1. When you talk to Dave, the game builds a JSON snapshot of the lawn and posts it to the Yumina server with what you typed. The body is capped at 24 KiB.
2. The server adds Dave's persona, the current chapter, your notebook for this campaign and up to twelve saved lines of your earlier conversation. Then it calls the model on the account's own provider: the free default, a paid model on your credits, or your own key.
3. The reply has to be a small JSON object. The server validates it and drops anything the game did not allow. What goes back to the game is text and at most one offer.
4. The game's own C++ code checks the board, the price, the eligibility and the once-per-level flag again before anything moves, then writes the result into the save file.

Nothing in the model's reply runs as code, on the server or in the game.

## What we built

![Level 1-1 of PvZ Online. Crazy Dave stands on the lawn next to a speech bubble that asks: First time playing Plants vs. Zombies? Two buttons, First time and I know this game, and a reply box that reads Why do you ask. The model pill in the corner says gemini-3.8-flash.](/blog-assets/ai-crazy-dave-plants-vs-zombies/first-question.jpg "The first thing Dave asks on level 1-1. Two quick answers, or type your own.")

Dave talks in Adventure mode when you are signed in to Yumina. He uses the game's own animation and mumbling, so nothing about him is generated voice. There are five written chapter scenes that frame the campaign, and those cost nothing because they never call a model.

When you open him, the lawn pauses behind a paper speech bubble. He writes two to four lines, 70 to 220 characters in English or Spanish, 30 to 100 in Chinese. You type back. His mood shows next to his name: curious, cheerful, worried, sulking, scheming or touched. Below the bubble sit a model picker, his notebook, and two buttons: give him sun or bonk him.

![Crazy Dave fills the left of the screen, pot on his head. The header reads Crazy Dave, Curious, with a notebook icon and a settings icon. His bubble says: GREETINGS, NEIGHBOR! The name's Crazy Dave. But you can just call me Crazy Dave. That street sounds wrong today. Too much shuffling. You hear it too? Plant a Peashooter on the grass. Below: a Talk to Dave box, a Send button, a pill reading Gemini 3.8 Flash with the credit balance, and two buttons, Give 50 sun and Bonk his pot.](/blog-assets/ai-crazy-dave-plants-vs-zombies/dave-chat.jpg "Level 1-1, the lawn paused. Dave's mood is Curious. The pill shows the model and your credit balance. Give him sun or bonk his pot.") If you turn proactive comments on, he may also speak up unprompted, at most twice per level and at least 90 seconds of game time apart, without pausing the game.

He runs on the account's own model choice. The default is the free model, which costs nothing. A paid model bills the player's Yumina credits, or the player's own key if they brought one. A reply that never reached the player is never charged.

## What Dave can see

Before every reply the game sends Dave a snapshot of the lawn. Nothing else about the game reaches him.

- The level, the wave, how many waves there are, how hard this lawn is, and your sun.
- Each of the five rows: its plants, its zombies, and whether the mower is still there.
- Your seed bank, and every plant on the lawn with its row, column and health.
- The zombies he can see, by type and count.
- Fog, storm, graves and craters, and whether it is day, night, pool, fog or roof.
- The last eight things that happened: a wave cleared, a mower used, a plant lost.
- His notebook for this campaign, up to twelve saved turns.

He does not see what the fog hides or what the next wave will bring. During a storm the zombie list is nulled, because you cannot see them either. The agency doc puts it in one line: fog conceals enemies from this context, and future spawns are unknown.

## What Dave is allowed to do

The reply is a small JSON object, and the schema is the fence around him:

```ts
const modelReply = z.object({
  text: z.string().trim().max(900),
  offer: z.unknown().optional(),
  memoryQuote: z.unknown().optional(),
  behavior: z.enum(["stay", "leave", "silent"]).default("stay"),
  feeling: z.unknown().optional(),
});
```

Text, at most 900 characters. One optional offer. One optional quote of something you said, at most 180 characters, which only survives if it appears word for word in your last message and does not look like a secret. A behavior: stay, leave for 45 seconds, or stay silent, and silence is only legal when nobody asked him anything. A feeling, which moves his affection by at most 3 and his irritation by at most 10 per exchange.

The offer has to be one of eight kinds, and the game decides which of the eight are on the table right now:

| Offer | When the game allows it | What it does |
|---|---|---|
| Sun gift | your sun is under 100 | gives you 50 sun |
| Cherry Bomb trade | you have unlocked it and hold 100 sun or more | one temporary Cherry Bomb packet, for up to 150 sun |
| No-shovel challenge | a wave is still coming | 75 sun if you clear the next wave without digging |
| Sunflower gift | you have given Dave sun before | plants two Sunflowers in free squares |
| Sun shower | same, on the newer save format | drops three 25-sun coins |
| Wall-nut gift | same | plants one Wall-nut in the front column |
| Lawn mower gift | same, and a mower is missing | puts it back |
| Suspicious tin | he has one on offer right now | see below |

Every offer works once per level, tracked as a bit in the save file, and none of them exist on conveyor-belt stages. The prompt tells him: at most one offer, usually none.

If the model writes an offer that is not on the list, or a price outside the allowed range, the offer is dropped and the text is kept. If it writes a command in braces inside its text, the braces are stripped. The code comment says it plainly: generated text is never a command.

## The suspicious tin

Dave's old trick in the original game was a mystery tin. We kept it and made it honest.

About two in three eligible levels, somewhere between 60 and 120 seconds into play, Dave invites you to look at a tin. The invitation fades after 8 seconds and the offer expires after 30 seconds of play. Levels 1 to 4, conveyor stages, vase puzzles and the final boss never get one.

The tin costs 50 sun and you need at least 150 in hand, so you always keep 100 for defense. Inside is 75 sun half the time, 150 sun a quarter of the time, and nothing a quarter of the time. On average you come out 25 sun ahead. When it is empty, Dave says: "Ah. That was the sound-effects model."

What is inside is decided before you buy. The outcome is a salted hash of the level and the campaign cycle, not a roll of the game's dice. It is written into the save file, in an optional chunk numbered 23, the moment the offer appears. Reloading the level does not reroll it. You can also say "open it first" and pay nothing, in which case Dave keeps whatever was inside and the sale is over.

Dave never spends your sun. If you type "buy it" into the chat, a separate conservative parser decides whether you meant it, and that parser can only enable the purchase, never choose it. The game then checks the price, your sun and the once-per-level flag again before anything moves.

## Dave remembers, within limits

Each campaign has a notebook. It keeps up to 24 turns of conversation, 80 observed milestones, 8 notes you pinned, 8 exact things you said, 8 tin receipts and 30 chapter markers. The whole notebook has to fit in 64 KiB, the database column's limit. When it passes 48,000 bytes the game trims old turns first, then old receipts, then older relationship notes.

Two details made this hold up under real use. The notebook row carries a revision number, and every write says which revision it read. If another tab wrote first, the write fails with a 409 and the client retries from the new state. And the model is called outside any database transaction. Only after the reply arrives does one transaction commit three things together: the notebook update, the credit debit and the usage row. The comment above that code explains why: otherwise an unpaid reply could be retrieved from memory after billing failed.

## Nothing the model says runs

Providers sometimes wrap JSON in a Markdown fence even when asked for raw JSON. The parser accepts a complete fence or raw JSON and nothing else. It never hunts for braces inside prose and never repairs a cut-off object:

```ts
// Some providers wrap JSON despite json_object. Accept only a complete wrapper,
// never extract arbitrary braces from prose or repair a truncated command.
const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n```$/i.exec(trimmed);
```

A reply over 12,000 characters is rejected. A reply the provider cut short because it hit the token limit is rejected with its own error code. In every rejection the player is not charged, the game stays playable, and a retry is one click. The usage row still gets written, tagged as unheard, so we can count how often it happens.

Two limits bound the cost of a chatty player: eight requests a minute per account and one request in flight at a time. Each server process also caps how many Dave calls it runs at once, and there is a single environment variable that turns Dave off everywhere if a provider misbehaves.

## What broke

**Reasoning ate the reply.** On 2026-09-13 at 05:55 UTC a reply came back as not JSON. The provider had spent 765 of 796 completion tokens on reasoning, under an 800-token cap we had set to keep Dave brief. The cap had been 500 at launch, then 800. The fix was to stop guessing. Dave now inherits the account's own output limit. The request deadline is the normal 600-second ceiling instead of 20 seconds. A cut-off reply has its own error code and no charge.

**The free model hit its daily cap, twice in a row.** On 2026-09-22 two Dave requests in a row went to the free pool and were told the daily cap was spent. Only then did they fall back to the next model. Players got their reply, but each one waited through a wasted round trip first. The server now remembers that exact answer for 60 seconds and goes straight to the fallback. The testing note from that day has a line we like: "No paid rerolls were used to choose an attractive reply."

**A local model's gift was accepted with a cut-off reply.** With Ollama, a reply that looked valid but ended on a `length` stop was accepted, offer and all. Terminal stops are now rejected before the offer is read.

**Dave invented a Potato Mine.** In one English reply he described a Potato Mine incident on a lawn that had only two Peashooters. This one is not fixed. The scene context was cut from 27,060 to 21,292 characters so the lawn stands out more, and a stricter candidate is being tested locally. We would rather say so than pretend.

## Rules we follow now

1. **The model gets a menu, not a hand.** Eight offers, computed by the game before each line, checked again at acceptance.
2. **Outcomes are decided before the player commits.** The tin's hash is written to the save when the offer appears.
3. **Parse strictly, fail cheaply.** Fence or raw JSON only, no brace hunting, no charge on failure.
4. **Call the model outside the transaction, then commit reply, debit and ledger together.**
5. **Memory has a byte budget, not a hope.** 64 KiB, trimmed from the oldest turn.
6. **Say what still breaks.** The Potato Mine is in the post because it is in the game.

## Try it

PvZ Online is at [yumina.io/pvz](https://yumina.io/pvz/). Adventure mode with Dave needs a Yumina account; Versus works for guests and never calls a model. The default model is free. You can switch to a paid model on your credits or bring your own key from the picker inside the game.

## FAQ

### Does talking to Dave cost anything?

Not on the default free model. On a paid model, one reply costs its tokens in Yumina credits, or nothing extra if you brought your own key. A reply that never reaches you is never charged.

### Can Dave give me unlimited sun?

No. He can propose one of eight favors the game has already allowed, and the game checks the price, your sun and the once-per-level flag before it does anything. The largest gift is 150 sun inside a tin you paid 50 for.

### Does he remember me?

Within one campaign, yes: up to 24 turns, eight exact quotes, your pinned notes and his tin receipts, all inside a 64 KiB notebook. A new campaign starts a new notebook.

### Why is he sometimes slow or silent?

Reply time is the model's. A small fast model answered in 4 to 9 seconds in our samples. A large one took over 30. He stays silent only when he spoke up on his own and had nothing to add. If he never answers, nothing was charged and you can ask again.
