Berner Crawl heroes in poison, stun, and burn builds

Last year we shipped Berner Crawl — a permadeath roguelike starring our three Bernese Mountain Dogs — and wrote up the seven engineering problems behind it. This summer we shipped v2. It's a bigger update than the original in places: an elemental build system with themed armour in every combat pose, a loadout optimiser that thinks about whole builds instead of single slots, a poker side-game you can drink your way through, and a floor's worth of hand-written story on every level.

We leaned on AI generation this time — for hundreds of new sprites. The most useful thing we learned wasn't a prompt. It was exactly where AI generation stops being useful, and what you have to build yourself past that line. That's the through-line of this post, because it's the question every client asks us right now: can't AI just do this? Sometimes. Here's how to tell the difference.

Three build-themed heroes facing a wraith in the wraith-forge

1. Themed builds without a combinatorial art explosion

Nine build looks — three classes across poison, stun, and burn armour

v2 gives every hero an elemental build: poison, stun, or burn. Each build has its own armour look, its own stat profile (poison leans magic, stun leans bulk, burn leans damage), and its own status payload on hit. Multiply that out — 3 classes × 3 builds × 5 tiers × 3 combat poses — and you're looking at 135 full-character sprites before you even consider a player who mixes a poison chestplate with burn boots.

We didn't draw 135 sprites. The hero's look is decided at render time from whatever the loadout is mostly made of:

// src/render/buildHeroSprites.ts — the dominant build wins the sprite
export const majorityBuild = (c: Combatant): StatusKind | undefined => {
  const eq = c.equipment;
  if (!c.classId || !eq) return undefined;
  const counts: Partial<Record<StatusKind, number>> = {};
  const bump = (b?: StatusKind) => { if (b) counts[b] = (counts[b] ?? 0) + 1; };
  bump(eq.armor?.itemId  ? ITEMS_BY_ID[eq.armor.itemId]?.equip?.build  : undefined);
  bump(eq.helmet?.itemId ? ITEMS_BY_ID[eq.helmet.itemId]?.equip?.build : undefined);
  bump(eq.boots?.itemId  ? ITEMS_BY_ID[eq.boots.itemId]?.equip?.build  : undefined);
  bump(equippedWeaponStatus(c));                       // the weapon votes too
  const maxN = Math.max(0, ...BUILDS.map((k) => counts[k] ?? 0));
  if (maxN === 0) return undefined;                    // no build gear → base sprite
  return BUILDS.find((k) => (counts[k] ?? 0) === maxN);
};

A 50%-stun loadout shows the full stun sprite. The one exception — the weapon — gets composited separately, so a poison-armoured hero swinging a stun weapon shows that stun weapon recoloured onto the frame instead of forcing a whole new sprite. One dominant signal for the body, one cheap overlay for the outlier. Nine authored looks (above) cover a space that would otherwise need well over a hundred.

Why this matters for client work. Variant explosion is the most common source of silent scope creep we see. A product configurator, an avatar builder, a theming system, a "personalised" landing page — the naive plan renders every combination, and the combinations multiply faster than anyone budgeted for. The fix is almost always the same: collapse to the dominant signal, composite the exceptions. We used it here; we use it for Shopify product-variant previews and for white-label client theming.


2. AI generation, honestly — what it can and can't do

A poison knight in standing, attacking, and falling poses — themed armour in every frame

Here's the part worth the read. We needed each build's themed armour to show not just standing, but attacking and dying too. The old attack frames had reverted to the default armour with an effect slapped on — the poison knight lost his poison plate the moment he swung. We fixed it by feeding each themed standing sprite back through an image model and asking for the same character, same armour, in an attack or a fallen pose. That worked beautifully. The three frames above — stand, attack, death — are all AI-reposed from a single hand-authored source, and the armour carries through every one.

Then we tried to push AI one step further, and hit a wall worth remembering.

Left: AI-generated pieces drift out of alignment. Right: a deterministic re-fit locks the sprite in place.

We wanted true mix-and-match armour — a poison chestplate over burn boots over a stun helmet — by having the model generate each piece so we could layer them. It can't. Every regeneration silently re-frames the character by a few pixels (left, above); the model has no concept of "keep this exactly where it was." Layer two AI-generated pieces and nothing lines up. There is no prompt that fixes this. It's a property of how the models work, not a skill issue.

So the pipeline splits the creative work from the precise work. AI does the part it's good at — inventing a themed pose. Deterministic code does the part it's good at — putting it exactly where it belongs (right, above):

# Generate with AI (creative) → then re-fit to a fixed geometry (precise).
# Every frame is trimmed, scaled to the base pose's height, and pinned to the
# same ground line, so stand → attack → death never jump.
magick "$src" -trim +repage -resize x164 \
  -background none -gravity North -extent 254x254 -geometry +0+42 "$out"

The lesson generalises well past pixel art. AI is excellent at generating a plausible new thing. It is unreliable at reproducing an exact prior thing, at pixel-precise placement, and at consistency across a set. The moment your problem needs those, you stop prompting and start engineering — and the two are not in competition. The best result we got came from using each for exactly what it's good at.

Why this matters for client work. "Can't AI just do this?" is the most expensive question a project can leave unanswered. Sometimes the honest answer is yes, and we'll save you weeks. Sometimes the honest answer is it'll produce something that looks right in the demo and falls apart in production — and the real solution is a twenty-line script. Knowing which is which, before the budget is committed, is most of what we're actually selling. We've said "AI is the wrong tool here" to clients often enough that it's become a selling point.


3. An optimiser that scores the whole system, not the parts

Twelve build-gear icons — armour, boots, helmet, and amulet in poison, stun, and burn

v2 added a bag full of build gear — armour, boots, helmets, amulets in every element (above) — and an Optimise Party button. The first version did the obvious thing: for each slot, equip the highest-stat item. It also quietly destroyed every build, because the single best chestplate and the single best boots were often different elements. Players ended up in an incoherent stat-maxed mess with none of the set synergy that makes a build a build.

The rewrite scores the whole loadout — raw stats plus a synergy term that rewards completeness — and picks the best of a full poison set, a full stun set, a full burn set, or pure stats:

// src/inventory.ts
// A matching piece counts toward the set; the weapon completes it.
const itemMatchesBuild = (item: Item, b: string): boolean =>
  item.equip?.build === b || (item.equip?.onHit?.some((e) => e.kind === b) ?? false);

// Collective-impact bonus grows quadratically, so a WHOLE build out-values
// swapping in a slightly higher-stat off-build piece.
const collectiveBonus = (n: number): number => n * n * 4;

// …for each option (poison / stun / burn / pure-stats):
//   raw     = Σ per-item stat scores of the chosen items
//   total   = raw + (isBuild ? collectiveBonus(matchingPieces) : 0)
//   keep the option with the highest total

The quadratic is the load-bearing choice. A one-piece "build" barely registers; a complete five-piece set is worth a lot more than the sum of its slots — which is exactly how the game actually plays. Now the optimiser will even take a slightly weaker on-element weapon to finish the set, because the synergy is worth more than the stat. Hand it a bag and it hands you back a coherent loadout, not a spreadsheet.

A knight in a complete Smoldering burn set with matching gear

Why this matters for client work. Optimising the parts is not the same as optimising the system, and the gap between them is where a lot of software quietly underperforms. Recommendation engines that rank items in isolation, bundle-pricing that discounts each SKU separately, search that scores documents without a sense of the result set — all the same mistake we made in the first optimiser. Score the whole, not the slots.


4. State the player can read at a glance

Barrel the Saint Bernard bartender growing angrier across five frames

The poker side-game got a bar. You can buy a drink from Barrel — the Saint Bernard barkeep above — to bend your luck at the table. One or two rounds sharpen you up; a third and fourth start to hurt; a fifth and you black out and wake up broke. And the luck is real — it isn't flavour text, it biases the actual cards you're dealt:

// src/casino/poker.ts — tavern luck nudges your dealt hole cards
export const applyHumanLuck = (game: PokerGame): void => {
  const luck = game.luck;                        // set by how many drinks you've had
  if (!luck) return;
  const human = game.players.find((p) => p.isHuman);
  if (!human?.hole) return;
  const better = luck > 0;
  const tries = Math.min(6, Math.abs(luck) * 2);  // more luck → more attempts
  for (let h = 0; h < 2; h++) {
    for (let t = 0; t < tries; t++) {
      const j = game.rng.int(game.deck.length);
      const dc = game.deck[j]!, hc = human.hole[h]!;
      // swap toward higher ranks (lucky) or lower (unlucky); the displaced
      // card goes back into the deck, so the shoe stays valid — no duplicates.
      if ((better && dc.rank > hc.rank) || (!better && dc.rank < hc.rank)) {
        human.hole[h] = dc; game.deck[j] = hc;
      }
    }
  }
};

The drink ladder is a five-step table ([0, +1, +2, -1, -2] — sharpen, sharpen, sour, sour, black out), and Barrel's face is wired straight to your drink count: friendly, then unamused, then openly disgusted by round five. No tooltip, no number on screen. The system's entire state is legible from one dog's expression.

Why this matters for client work. Two patterns hide in here. The first is reactive UI that communicates state without a manual — the same instinct behind a good status badge, an availability indicator, a form that shows you exactly why it won't submit. The second is weighted probability that stays valid — biasing an outcome (a recommendation, an A/B split, a feature rollout) without breaking the invariants underneath it. We swapped cards toward a better hand while guaranteeing the deck never dealt a duplicate; the same discipline keeps a personalisation engine from ever showing an out-of-stock product.


5. Story that's authored — and validated like code

A knight standing before the enormous golden eye of the Hollow Dragon

Every system above is scaffolding for one thing: making you care. v2 gives the descent a reason. The three dogs are a working breed whose family was taken by the thing waiting at the bottom (above), and the whole update is built around that grief. The card sharp who follows you down floor by floor doesn't just deal poker — his needling is about how no pot you win will bring anyone back. There are fifteen hand-written vignettes now: one battle, one poker night, one branching dialogue on every floor, tied together by a recurring cast.

We wrote all fifteen by hand. The temptation with modern tooling is to generate the copy too. We didn't, for the same reason we don't generate a client's brand voice: the words are the product, not the packaging. But because the story is structured data, we can validate it like data. Each vignette is a small graph — scenes with choices that point at other scenes or at outcome nodes:

// src/wager/scriptedEvents.ts — a vignette node (abridged)
oath: {
  kind: "scene",
  speaker: "wardkeeper",
  text: "\"Speak the oath, hound, or be judged by it.\"",
  choices: [
    { text: "Speak it — whatever it is.", to: "swear" },
    { text: "No oaths. Move it aside.",   to: "fight" },
    { text: "Ask what oath it keeps.",    to: "explain" },
  ],
},

Before any of it ships, a check walks every graph and fails the build on a dangling choice, an unreachable node, a missing consequence, or an enemy that doesn't exist — the narrative equivalent of a broken link, caught in CI instead of by a player thirty minutes into a run. Fifteen graphs, every edge verified automatically. Authored by a human, guarded by a machine.

Why this matters for client work. Copy is not decoration you sprinkle on at the end — the sentence that converts is as engineered as the checkout that processes the payment, and we write both. And content that lives as structured, validated data is content you can reorder, re-theme, and A/B test without touching a template, and never publish a link to nowhere. It's how we'd build a multi-step onboarding flow, a decision-tree support tool, or a CMS the marketing team can't accidentally break.


6. How we know it works — before you do

There's a quiet claim running under every section of this post: these systems actually behave the way we say they do. We don't ask you to take that on faith, and we don't take it on faith ourselves. We drive the real game in a headless browser and assert on what actually renders — the same discipline we bring to every client build. A few measurements from this update:

  • The optimiser was checked by handing it a bag containing every item in the game and confirming it both assembled complete sets and never lost or duplicated an item: 436 items in equals 421 back in the bag plus 15 equipped, exactly, every run.
  • The luck mechanic was verified statistically — we dealt hundreds of hands at each drink level and measured the average rank of the player's cards. Stone-sober lands near 8 (the deck's true average); two drinks in it climbs to about 12; hammered, it collapses to around 3. The feature does what the fiction promises, and we tuned it down from an even stronger first pass because "always aces" isn't luck, it's a cheat.
  • The sprite fixes were confirmed by loading actual battles, triggering each pose, and reading back which image the browser painted — not by eyeballing a screenshot and hoping.

"It works on my machine" is not shipping. The gap between a convincing demo and a product that survives real users is automated verification, and we write those checks as we write the feature — never bolted on afterward.

Why this matters for client work. Most of what goes wrong in software goes wrong quietly, in the space between "looked fine in the review" and "a real user did the thing we didn't test." The teams that ship reliably aren't smarter; they just verify against reality instead of intention. That habit is unglamorous, it's most of the value, and it's baked into how we work.


The takeaway

v2 is a game update. It's also a compact demonstration of what clients actually hire us to get right: use AI where it genuinely wins, engineer deliberately where it doesn't, optimise systems instead of parts, treat content as validated data, and verify everything against reality before it ships. The Berner dogs are just the most fun way we've found to prove it.

If you're weighing whether AI belongs in your next build — and where it stops earning its keep — that's exactly the conversation we like having. Berner Crawl is the demo reel; the playable v2 is live, and we'd rather show than tell.

Keep reading

Work with Rough Works

Need a partner for your next site? We're a Vancouver-based digital agency building websites and AI-enhanced experiences for brands across Canada and the United States. Start a project →