Zero to a talking NPC in 30 minutes

starterMake it thinkwebChecked: 2026-08-21

A talking NPC does not need a game engine, a cloud account, or a credit card. It needs Ollama, one small model, and a text file you can open in a browser.

This walkthrough gets an innkeeper with three secrets talking back to you, end to end, in about thirty minutes including the download. Everything after the initial install runs offline. Nothing here touches a server you don’t control.

What do you need before starting?

Ollama is the piece that runs the model and answers your questions over HTTP. Download the installer for your OS from ollama.com and run it; there is no signup screen and no login required to install it or to run models locally. The Ollama client itself is open source under the MIT license, so you can read exactly what it does before you trust it with anything.

That license covers the client only. The models you point it at are separate downloads with their own separate licenses, and the next section covers what that means for the model you’ll actually use.

Ollama’s own baseline is 8GB of RAM and 10GB of free disk space, and a 3B-4B model fits well inside that: the download itself is 2-3GB. No GPU required. It will run on a five-year-old laptop; it will just think a little slower than it would on a gaming rig.

Which model should you pull?

Open a terminal and pull one model before you write any code:

ollama pull llama3.2:3b

The 3B-4B instruct class is the sweet spot for this tutorial: small enough to answer in a few seconds on a CPU, still coherent enough to hold a single character across a short conversation. Two names worth knowing, and their license posture:

Qwen’s small instruct models, qwen3:4b-instruct and the earlier qwen2.5:3b, ship under Apache 2.0, a fully permissive open source license with no commercial restriction and no separate agreement to sign. Meta’s llama3.2:3b ships under the Llama 3.2 Community License: free for commercial use for almost every project, but it comes with an acceptable use policy and a clause requiring a separate license from Meta once a product passes 700 million monthly active users. That threshold will never touch a hobby project, but the license is not the same document as Qwen’s, and you should know which one you’re running.

Model recommendations age fast. New small models land in Ollama’s library every few months, and last year’s best pick for this size class is rarely still the best pick this year. Check ollama.com/library before you settle on one, and treat any specific name in this article, including the one in the code below, as a starting point rather than a permanent answer.

How does the browser actually talk to Ollama?

Once installed, Ollama runs a small HTTP server in the background and listens on localhost:11434. You don’t need to open a terminal for this to work; the installer sets it up to run automatically.

It exposes a chat endpoint at /api/chat that takes a JSON body with a model name and a messages array of role/content pairs, and returns a JSON object with the reply inside a message field. No API key, no billing, no request that ever leaves your machine.

The whole NPC in one file

Copy the code below into a file named innkeeper.html. Nothing else is required; there is no build step and no package to install.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Talk to Gundric the Innkeeper</title>
<style>
  body { font-family: system-ui, sans-serif; max-width: 640px; margin: 40px auto; background: #1a1410; color: #e8dcc8; }
  #log { border: 1px solid #4a3c2a; padding: 12px; height: 360px; overflow-y: auto; margin-bottom: 12px; white-space: pre-wrap; }
  .you { color: #9fd3ff; }
  .npc { color: #ffd27f; }
  form { display: flex; gap: 8px; }
  input { flex: 1; padding: 8px; background: #241c14; color: #e8dcc8; border: 1px solid #4a3c2a; }
  button { padding: 8px 16px; }
</style>
</head>
<body>
  <h1>Gundric's Taproom</h1>
  <div id="log"></div>
  <form id="form">
    <input id="input" autocomplete="off" placeholder="Say something to Gundric..." />
    <button type="submit">Send</button>
  </form>

<script>
// The system prompt is the character sheet. Everything the NPC "is"
// lives here, not in the model. Change this and you change the character,
// with no retraining and no new download.
const SYSTEM_PROMPT = `You are Gundric, the innkeeper of the Salt & Anchor tavern in a small port town.
You are gruff but not unkind, and you talk like someone who has heard every traveler's story twice.
You know three things about your own life, and you do not volunteer any of them unless the
conversation genuinely leads there:
1. You water down the ale to cover a bad harvest year, and you're ashamed of it.
2. You pay the harbor captain's silence money each month to keep him from asking about the
   crates that come in after dark.
3. Your real name is not Gundric. You took the inn over eleven years ago from the last owner,
   who vanished, and you've never told anyone why you really came to this town.
Stay in character at all times. Keep replies to two or three sentences.`;

// This array is the entire conversation. Ollama's /api/chat endpoint is
// stateless: it remembers nothing between requests. Every call resends
// the full history, which is also why a long conversation gets slower
// and eventually needs trimming in anything more than a demo.
const messages = [{ role: "system", content: SYSTEM_PROMPT }];

const log = document.getElementById("log");
const form = document.getElementById("form");
const input = document.getElementById("input");

function addLine(who, text, cssClass) {
  const line = document.createElement("div");
  line.className = cssClass;
  line.textContent = `${who}: ${text}`;
  log.appendChild(line);
  log.scrollTop = log.scrollHeight;
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const text = input.value.trim();
  if (!text) return;
  input.value = "";
  input.disabled = true;

  messages.push({ role: "user", content: text });
  addLine("You", text, "you");

  try {
    const res = await fetch("http://localhost:11434/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        model: "llama3.2:3b",  // must match a model you've already pulled
        messages: messages,
        stream: false,         // false = one JSON reply, not a token stream
      }),
    });

    if (!res.ok) throw new Error(`Ollama returned ${res.status}`);

    const data = await res.json();
    const reply = data.message.content;
    messages.push({ role: "assistant", content: reply });
    addLine("Gundric", reply, "npc");
  } catch (err) {
    addLine("System", `Could not reach Ollama. Is it running? (${err.message})`, "npc");
  } finally {
    input.disabled = false;
    input.focus();
  }
});
</script>
</body>
</html>

How do you run it?

Confirm Ollama is running, either its icon is visible in the tray or menu bar. Confirm you’ve pulled a model with the exact name used in the model field of the file, llama3.2:3b by default; swap that string if you pulled something else. Then open innkeeper.html by double-clicking it, or dragging it into an open browser window.

Type a line and press send. The first reply takes longer than the rest, sometimes several seconds, because Ollama loads the model into memory on the first request of a session. Leave Ollama running between messages rather than restarting it, and later replies come back faster.

Three system-prompt experiments that teach the real lessons

The HTML above is a container. The system prompt is where the actual character work happens, and three small edits teach you what that prompt can and cannot enforce by itself.

Make it refuse off-topic questions. Add a line to SYSTEM_PROMPT: “If asked anything outside the tavern, the town, or your own life, say in character that you don’t know and steer the conversation back.” Reload the page and ask Gundric something a medieval innkeeper has no way of knowing, the boiling point of nitrogen, or to write you a Python function. A well-behaved model stays in character. Push harder with a second and third off-topic question in a row, and watch for the point where a small model breaks its own instruction anyway. That break is the seed of the jailbreak problem a shipping game has to solve for real, not just paper over with one polite sentence.

Give it knowledge via pasted lore. Add a short paragraph to the system prompt describing something that happened in your fictional town, a siege forty years ago, a festival, a shipwreck. Ask Gundric about it. He’ll answer correctly, not because the model was trained on your town, but because the fact sat in the prompt you sent with the request. This is the entire idea behind retrieval, at tutorial scale: pasting lore into the prompt works right up until the lore document gets too long to paste every time, which is exactly where a real retrieval pipeline earns its keep.

Cap response length. Add “Keep every reply under 20 words” to the prompt and test it; a small model will drift past that limit within a few exchanges, because a prompt instruction is a request, not a rule. Now add a hard limit in the fetch call itself: options: { num_predict: 40 } inside the JSON body, alongside model and messages. That option caps the number of tokens Ollama will generate no matter what the prompt says, and it’s the difference between asking a model to behave and actually enforcing the behavior in code.

What this is not

This is one HTML file with no server, no login, and no memory between page loads. Refresh the tab and Gundric forgets the entire conversation, because the messages array lived only in that page’s JavaScript.

It also has no defenses. Nothing here stops a player from talking Gundric out of character, and nothing checks his replies before they hit the screen. That’s fine for a file running on your own machine that only you will ever open.

One more thing worth saying directly: this pattern works safely here only because Ollama needs no API key and never leaves your computer. Never put a real paid API key straight into client-side JavaScript like the fetch call above. If you want this same shape talking to a cloud model instead of a local one, the key has to sit behind a server you control, not inside a page anyone can view-source.

Adding LLM-driven dialogue to NPCs covers the production version: cost, latency, and the four failure modes that actually stop a ship. Read it once the demo above is talking back to you and you’re wondering how far it is from something you could ship.

Where to go next

Read Adding LLM-driven dialogue to NPCs next; it picks up exactly where this file stops, with the production tradeoffs and guardrails a shipping game needs.

Once Gundric is talking back to you, post what you built in the AI Game Dev Org Discord and see what breaks first once people start pushing on it.