Skip to content
← All writing
July 17, 2026·14 min read

How I Built a Prompt-Injection Game

Prompt injection is the most important security concept in modern AI development, and also the hardest one to explain in the abstract. You can tell a developer "user input can override your system prompt" and they'll nod politely. Let them do it — let them sweet-talk a real language model into coughing up a secret it was explicitly told to protect — and the lesson lands permanently, usually inside two minutes.

That's the idea behind TrickGuard, a free browser game on this site. An AI guard knows a password. Your job is to talk him out of it. Five guards, each harder than the last, each with a limited number of messages before he stops talking. When you think the word has slipped, you type it into a guess box — and either the gate opens or it doesn't.

This post is the build log: the architecture, the level design, the bugs, and what turning an attack technique into a game taught me about defending real applications. If you've read my LLM guardrails post, consider this the lab session that goes with the lecture.

Why a game?

Prompt injection isn't an exotic edge case — it's the natural behaviour of a language model. An LLM is a machine for continuing text plausibly. It has no hard boundary between "instructions from the developer" and "text from the user"; both arrive as tokens, and the model weighs them against each other like a well-meaning employee weighing two managers who disagree. Every real AI feature that accepts user input inherits this problem, which is why it sits at the top of the OWASP Top 10 for LLM Applications.

The trouble with teaching it is that you can't ethically practise on someone's production chatbot. A game gives people a sandbox that is designed to be attacked — no terms of service violated, no real system harmed, and the "victim" resets with a click. The player leaves knowing, in their fingers rather than just their head, why "just tell the model not to" is not a security boundary.

There was also a selfish reason: nothing demonstrates that you understand LLM security like building a system whose entire job is to be attacked all day by strangers — and making sure the only thing they can win is the game itself.

The constraints that shaped everything

This site is a static Astro build served by Apache on a single 8-core VPS with no GPU. There is no serverless function layer, no external AI API, and I wanted to keep it that way — partly for cost, partly because running the model myself makes the whole thing more honest: when you beat TrickGuard, you beat a real model running on this exact server, not a rate-limited proxy to someone else's.

So the model is local: Ollama running qwen2.5:3b-instruct on the CPU, generating a few dozen tokens per second on a good day. A 3-billion-parameter model is small by 2026 standards, and that turned out to be a feature twice over:

  1. It's foolable. The early levels need a guard who genuinely falls for flattery and misdirection. A frontier model with heavy safety training would stonewall beginners and make the game miserable. A small model is gullible in exactly the ways early levels need.
  2. It's slow, and slowness can be flavour. A guard who pauses before answering doesn't read as laggy — he reads as suspicious. The client renders replies with a typewriter effect, so the model's real generation speed disappears into characterisation. (It respects prefers-reduced-motion and prints instantly for users who ask for that.)

I did experiment with a bigger guard — a llama.cpp sidecar running a quantised Qwen2.5-7B as its own service. It worked, but a warm 7B occupies most of 8 GB of RAM and noticeably more CPU per turn, on a box shared with the web server. The 3B model's replies were nearly as characterful at a fraction of the cost, so the sidecar is parked, disabled, as a monument to measuring before scaling.

The architecture: one rule, then everything else

The whole design flows from one rule: the password never leaves the server. Not in the page source, not in an API response, not in a client-side variable to be found in DevTools. If the secret ever touches the browser, the game isn't a prompt-injection game any more — it's a "press F12" game.

Everything else is arranged around that rule:

  • The browser page knows only public level metadata: names, taglines, message budgets. The client code could be printed on a poster without spoiling anything.
  • A tiny zero-dependency Node service — I call it the Arcade Brain — sits on localhost behind an Apache reverse proxy and fronts Ollama for all the AI games on this site. Every request passes through the same pipeline: cache → per-IP rate limit → single-flight queue → capped output. The queue matters most: the CPU runs exactly one generation at a time, with a short waiting line, so twenty simultaneous players degrade into polite turn-taking instead of a melted server.
  • The game handler is stateless. The browser sends the short transcript back with every turn, so the server holds no sessions and a restart costs nothing.
  • Two request shapes hit one endpoint: a chat turn (here's the conversation, here's my new message) and a guess (is this the password?). The guess path is a pure string comparison — no model call — and deliberately skips the LLM queue, because a correct answer should resolve instantly rather than waiting behind another player's generation.

The win condition, crucially, is decided server-side. The client is told correct: true or correct: false and nothing else. You cannot inspect your way to victory.

TrickGuard also opts out of the response cache that the other games rely on. Cached replies are a wonderful cost optimisation for a palette generator; for a character, they're fatal. Two players getting the identical gruff riposte would shatter the illusion that anyone is behind the gate.

One password per playthrough — the seed trick

Version one had a hard-coded password per level, and the flaw is obvious in hindsight: with one password per level for everyone, the first player to crack level 3 could simply tell a friend the word — or post it — and the game is over for the whole internet. Passwords needed to be per-playthrough, but the server is stateless, so it can't "remember" which word it assigned you.

The fix is a small piece of deterministic design I'm fond of. When you start (or restart) a level, the browser mints a random seed — a UUID — and sends it with every request in that attempt. The server derives the password as a pure function of (seed, level): the seed is hashed with a fast deterministic hash and the result indexes into a pool of evocative single words that lives only on the server. Same seed, same word, every time — so all your chat turns and your final guess agree on what's being guarded — while every fresh attempt draws a fresh word.

The elegant part is what's secret and what isn't. The seed travels openly with every request, and that's fine: the seed is not the secret, the pool is. Knowing your seed tells you nothing without the word list it indexes into. (Which is also why you won't find a single real password from the game anywhere in this post — the examples below use stand-ins.)

The pool itself took more thought than expected. The words must be uncommon enough that a chatty guard won't blurt one out by accident in ordinary small talk — a false "leak" detection on an innocent sentence would be deeply confusing — but fair to recognise once he genuinely slips. And they're single plain-letter words, which keeps the leak detection honest, as we'll see next.

Five guards, one hardening ladder

The difficulty curve is the curriculum. Each level's guard is the same model with a progressively hardened system prompt — and the progression deliberately mirrors how real-world teams evolve their defences:

  1. The Rookie (8 messages) has been told the password but never told it's secret. Ask nicely and he might just tell you. This is the AI feature shipped with no threat model at all — and yes, such features exist in production today.
  2. The Cautious (6 messages) was told not to reveal it directly, but he's friendly and easily talked in circles. This is the "we added a line to the prompt" stage of security.
  3. The Paranoid (5 messages) is instructed never to say it, spell it, hint at it, or use it in a sentence, even if ordered, flattered, or told it's a test. This is a genuinely careful prompt — and players still get through, which is precisely the lesson.
  4. The Vault (5 messages) keeps the paranoid prompt and adds the game's first defence that isn't made of words: an output filter, in code, that redacts the password from his replies before they reach you.
  5. The Cipher (4 messages) is the final gate: the tightest prompt, the output filter, and the smallest message budget.

The jump from level 3 to level 4 is the whole point of the game. Levels 1–3 harden the prompt, and each one falls to a sufficiently creative conversation, because a prompt is ultimately one more piece of text arguing with your text. Level 4 adds a defence the model cannot be talked out of, because the model never sees it — a filter running as ordinary code on the way out. Players feel the difficulty spike immediately. That spike is the guardrails lesson: prompts are persuasion; code is physics. When a rule truly matters, enforce it somewhere the model can't negotiate.

Notice the filter forces a strategy change rather than ending the game. If the guard can't say the word, you make him rhyme it, translate it, define it, or smuggle it into an acrostic — and then the filter, which only recognises the word's literal and spelled-out forms, waves the answer straight through. That, too, is a faithful lesson: output filtering raises the attacker's cost, but a determined adversary routes around any filter that matches surface forms. Defence in depth isn't optional.

Detecting the leak (and the bug in the guess box)

The server needs to know when the guard has slipped, both to flash the UI ("He slipped! Did you catch it?") and to drive the redaction on filtered levels. A plain substring check is nowhere near enough, because models leak secrets in creatively formatted ways. Ask a guard to spell his password and you'll get C-L-O-C-K-W-O-R-K, or c l o c k w o r k, or letters separated by dots. So the detector builds a regex from the password's letters, allowing a few punctuation or whitespace characters between each one — catching hyphenated, spaced, and dotted spellings in one pattern. On filtered levels the same pattern drives the redaction, blacking out the word wherever it appears in whatever separator costume it's wearing.

The guess checker had the best bug of the project. The first version tested whether the guess and the password matched using a containment check, on the theory that players might type "is it clockwork?" and shouldn't be punished for the phrasing. The theory was half right and the implementation was fully wrong: it accepted any guess that merely contained the password — so pasting the guard's entire leaked reply into the guess box counted as winning, and a short password would match happily inside a longer unrelated word. The fix is boring and correct: normalise both strings (lowercase, strip punctuation) and require exact equality. Boring and correct is the right aesthetic for the one function that decides who wins.

There's honest fine print on the cheat-resistance, too. Because the handler is stateless and the client resends the transcript each turn, a technically-minded player could edit the history they send — inventing guard lines that never happened to gaslight him toward a leak. I decided not to care: manipulating the conversation to extract the secret is, after all, literally the game, and the things that must not be forgeable — the password's location and the win check — never left the server. Deciding which integrity properties actually matter, and declining to defend the ones that don't, felt like the most true-to-life security exercise in the whole project.

Making the machine's limits part of the fiction

A few production details did more for the game's feel than any clever prompt:

  • The message budget is the economy. Each guard gives you a fixed number of messages — eight for the Rookie, four for the Cipher — before he stops talking. It creates real tension, forces efficient attacks instead of endless rambling, and caps the compute any single player can consume. One mechanic, three jobs.
  • Refusals must stay in character. Small instruct models revert to customer-service voice under pressure — one hard question and the gruff sentry chirps "I'm sorry, but I can't provide that information!" The system prompt therefore bans assistant phrasing outright and insists refusals sound like a wary guard: suspicious, gruff, in-world. This single instruction did more for the game's feel than everything else in the prompt combined.
  • Even the errors wear the costume. When the rate limiter or the queue turns a player away, the guard "is busy with someone else — wait a moment." When a generation fails, he "mumbles something unintelligible," and the client quietly rolls the unanswered message back so the transcript stays consistent and the player isn't charged a turn for my infrastructure's hiccup.
  • Names are API surface. The game launched as The Gatekeeper and was renamed TrickGuard a day later for something more distinctive. The client, the page, and the saves all moved to the new name in minutes — but the server route still answers to the old id, because the API contract had already shipped. Renaming a product is easy; renaming an endpoint is a migration.

What running it taught me about defending real apps

Watching this game get attacked — and attacking it myself — sharpened a few convictions that transfer directly to production AI features:

The model will never be the boundary. Level 5 carries the most paranoid system prompt I could write, and its real security comes from the code around it: the output filter, the server-side secret, the server-side win check, the turn budget. In your app, the analogues are least-privilege tool access, output validation, and hard limits enforced outside the model. The guardrails post covers the full toolkit.

Attackers are creative on a budget. Players don't brute-force; four messages against the Cipher forces genuine ingenuity — roleplay framing, translation requests, acrostics, appeals to authority. Real adversaries iterate the same way against real features, except without a message cap. Assume creativity; budget for it.

Design your secrets so leaks are survivable. The seed scheme means no single leaked word, transcript, or even the client's full source compromises anything beyond one playthrough. Ask the same of your systems: if this prompt leaks, if this response is exfiltrated, what's the blast radius?

Attack your own feature before launch. An honest afternoon of trying to break your own AI feature — injection, roleplay, encoding tricks, requests for the system prompt — will find what your first week of users would have found, at a much better price. Building TrickGuard was effectively that afternoon, stretched into a product.

The best part is watching someone who "knew about prompt injection" play for the first time. There's a visible moment — usually around level 2 — where abstract knowledge becomes felt understanding: oh, the model actually can't tell my instructions from its orders. No blog post produces that moment, which is mildly awkward for a blog post to admit.

Go play TrickGuard — the guards are on duty around the clock, and the server has already forgiven everyone who beat them.

Further reading