A small studio's portfolio is usually a list of what it built. Ours has a list of why the hard parts were hard, and what each one demonstrates about how we'd approach your project. Berner Crawl — a 3-character roguelike RPG starring our three Bernese Mountain Dogs — is the most technically dense thing we ship publicly. Below are the seven engineering problems behind it, the actual code we wrote to solve each, and what each one says about how we'd attack the same shape of problem on a client engagement.
The whole game is a single-page web app: Vite + React + TypeScript, no backend, state in useReducer, saved to localStorage, deployed to Netlify. Around 100 MB of painted art and AAC audio. You can play it in any browser. Source organisation: combat under src/combat/, casino under src/casino/, wager-event CYOA under src/wager/, item rendering under src/render/, screens lazy-loaded under src/screens/.
The three heroes — Teddy (Knight, tank, stamina-based), Moose (Archer, glass cannon, highest crit), Gabby (Mage, MP-only, only party healer). Each one carries a different mechanical role; the game is unwinnable if any of them dies permanently mid-run. That constraint shapes every decision downstream.
Problem 1 — Difficulty that feels fair across a 5-floor run
In a permadeath roguelike, the player should die because they made a bad call, not because the math snuck up on them. The standard solution — "boss HP goes up, boss damage goes up" — feels punitive without feeling instructive. We split difficulty into three independent axes, each tunable on its own.
Axis 1: a player-chosen tuning table. Picked at run start, locked for the rest of the run.
// src/difficulty.ts
const TUNING: Record<Difficulty, DifficultyTuning> = {
beginner: { enemyHp: 0.70, enemyAttack: 0.85, enemyMagic: 0.85, goldReward: 1.30 },
medium: { enemyHp: 1.00, enemyAttack: 1.00, enemyMagic: 1.00, goldReward: 1.00 },
hardcore: { enemyHp: 1.35, enemyAttack: 1.20, enemyMagic: 1.20, goldReward: 0.85 },
};
Higher difficulty multiplies enemy stats and dampens gold rewards. The economic squeeze matters as much as the combat one.
Axis 2: depth scaling for skirmish nodes. As the player walks deeper into a floor, enemies stay biting even after the party has stocked up. Bosses are skipped — they're hand-tuned per floor.
// src/content/encounters.ts
export const rowDifficultyMult = (row: number): number =>
1.0 + Math.min(8, row) * 0.055;
Row 0 = ×1.00, row 8 = ×1.44. The cap at row 8 means players who explore aggressively don't get punished beyond a known ceiling.
Axis 3: a hand-tuned boss curve. Five floor bosses, each with bespoke HP, abilities, and AI script. The Goblin Scout on floor 1 is 250 HP; the Hollow Dragon on floor 5 is 2,200 HP with a phase shift at 50%.
The damage formula is where these three axes actually compose:
// src/combat/damage.ts
const isMagic = isMagicElement(ability.element);
const offenseStat = isMagic ? actor.stats.magic : actor.stats.attack;
const defense = Math.max(1, isMagic ? target.stats.resist : target.stats.defense);
const raw = ability.power * offenseStat;
const mitigated = raw / (1 + defense / 22); // soft-cap, not flat subtraction
const isCrit = rng.next() < (actor.stats.critRate ?? 0.05);
const critMult = isCrit ? actor.stats.critMult : 1;
const variance = 0.85 + rng.next() * 0.30; // ±15%
const final = mitigated * critMult * variance
* incomingDamageMultiplier(target, isMagic)
* outgoingDamageMultiplier(actor);
return { amount: Math.max(1, Math.round(final)), crit: isCrit };
The soft-cap (raw / (1 + defense / 22)) is the load-bearing line. Compared to flat damage subtraction, it means defense never trivialises an attack but never becomes worthless either.
Why this matters for client work. When a system needs to scale across user skill levels, the right answer is usually "split scaling into independent axes you can tune separately" — not "one giant multiplier." We use this same pattern for SaaS pricing tiers, recommendation algorithms, and content moderation thresholds. Different domains, identical shape.
Problem 2 — Boss AI that feels distinct without machine learning
A common mistake in indie RPGs is making every boss "smart" in the same way. The player learns the AI's optimal pattern by boss two and the rest is a slog. We went the other direction: every boss has its own scripted policy, hand-written to feel different. No probability core, no opponent modeling. Just deliberate, designed behaviour.
// src/combat/enemyAI.ts — Hollow Dragon, the final boss
"boss-dragon": (self, battle, rng) => {
const hpFrac = self.stats.hp / self.stats.maxHp;
const turn = battle.turnIndex;
if (hpFrac < 0.5) { // PHASE 2
if (turn % 3 === 0) {
return { actorId: self.id, abilityId: "enemy.dragonBreath" };
}
if (rng.chance(0.4)) {
return {
actorId: self.id,
abilityId: "enemy.crushingTail",
primaryTargetId: lowestHpAlly(battle).id,
};
}
}
// PHASE 1
if (turn % 4 === 0) {
return { actorId: self.id, abilityId: "enemy.dragonBreath" };
}
return {
actorId: self.id,
abilityId: "enemy.dragonClaw",
primaryTargetId: randomAlly(battle, rng).id,
};
},
In phase 1 (above 50% HP) the dragon mostly slashes random targets with dragonClaw and breathes fire every fourth turn. Below 50%, it gets meaner: every third turn is dragonBreath, and a 40% chance per turn it deliberately picks the lowest-HP ally for crushingTail. The player feels the shift — the dragon stops attacking randomly and starts hunting whoever's weakest.
Why this matters for client work. When clients ask us to "add AI" to a product, the right tool is usually not an LLM. A hand-designed state machine or scripted policy is faster, cheaper, more predictable, and easier to debug. We've used the same pattern for chatbot fallbacks, lead-routing logic, and email automation sequences.
Problem 3 — A Texas Hold'em side game with a real hand evaluator
The casino node lets the player sit down for a multi-hand session of Berner Hold'em — four seats, one human, three AI bots, blinds at 1%/2% of buy-in, side-pot math implemented for all-ins.
The interesting code is the hand evaluator. From a 7-card pool (2 hole + 5 community), pick the best 5-card hand:
// src/casino/handEval.ts
// Score = category × 10^10 + base-16-packed kicker order
// Keeps everything inside Number.MAX_SAFE_INTEGER for fast .reduce(max)
const CAT_MULT = 1e10;
const packRanks = (sorted: number[]): number => {
let n = 0;
for (let i = 0; i < 5; i++) n = n * 16 + (sorted[i] ?? 0);
return n;
};
// …then for each 5-card subset:
// const score = category * CAT_MULT + packRanks(rankOrder);
// return subsets.reduce((best, s) => s.score > best.score ? s : best);
The packing trick — category times a large constant plus rank ordering in base-16 — collapses a hand into a single comparable number. One > operator, no tie-breaking edge cases.
Why this matters for client work. The packing trick generalises. We've used the same idea for SKU sort orders, scoring rubrics in a custom CRM, search-result ranking, and lead-priority computation. Compact numeric encodings are an underused tool.
Problem 4 — A 60-vignette choose-your-own-adventure with permanent consequences
The wager events are the most authored part of the game. Each one is a choose-your-own-adventure vignette painted by hand — a small story, a moral choice, and a permanent in-game consequence that follows the party for the rest of the run. There are 60 vignettes across 5 floors (one floor's worth shown above), with a tier-bucket resolution system that maps the player's three choices to one of eight authored outcomes per event.
The mechanics:
- Step 1 presents three choices (A/B/C). Each one represents a different value — caution / curiosity / aggression — and the acting sibling (knight, archer, or mage) determines whose stats can change from this event.
- Step 2 presents three more choices, with prompts that depend on what Step 1 was.
- Step 3 is the resolving step — another three-choice prompt that locks the outcome.
That's 3 × 3 × 3 = 27 possible paths per event, but they don't map to 27 unique outcomes. They map into one of eight tier buckets:
S+ Legendary positive — best-in-slot weapon, hidden skill slot, blessed passive
A+ Great positive — rare gear, party-wide buff, full layout reveal
B+ Good positive — uncommon gear, +1 permanent stat, consumable cache
C+ Minor positive — common loot, single consumable, small heal
C- Annoying negative — lose a consumable, minor debuff, small gold loss
B- Painful negative — lose equipped gear, moderate 3-floor debuff
A- Severe negative — permanent stat loss, party-wide debuff
S- Devastating negative — kill an unequipped item slot, permanent heavy stat loss
Outcomes draw from a shared master pool of 40 named consequences that vignettes can reference. Defining consequences once and reusing them across events means we can rebalance the game (make legendary loot rarer, soften the worst negatives) by editing a single file:
// src/wager/consequences.ts — shared pool referenced across all 60 events
export const CONSEQUENCES: Record<ConsequenceId, Consequence> = {
// S+ Legendary positive
SL01: { effect: "grant_legendary_weapon", flavor: "{sibling} wields..." },
SL02: { effect: "permanent_stat_boost", stat: "primary", delta: 2, flavor: "..." },
SL03: { effect: "unlock_hidden_skill_slot", flavor: "..." },
// ... 37 more
};
To prevent boring repeats across runs — the same vignettes hitting in the same order — a ring buffer tracks recent runs and biases the draw:
// src/wager/engine.ts
// Events the player has seen recently are weighted DOWN; fresh events surface preferentially.
export const weightForDraw = (event: WagerEvent, seenInRecent: Record<string, number>): number => {
const recencyHits = seenInRecent[event.id] ?? 0;
return 1 / (1 + recencyHits * 2);
// ^ doubles the penalty per recent appearance
};
A vignette the player hasn't seen in the last few runs is roughly 3-5× more likely to surface than one they hit last run. The system has a feel of genuine variety even though the underlying content pool is fixed.
The narrative consequences are durable. When a wager event grants the archer a hidden skill slot, that slot exists for the rest of the run. When a different event permanently scars the knight (-2 max HP for the rest of the run), the knight carries that scar through every subsequent battle and shop and boss. The choices the player made on floor 1 still echo on floor 5.
Why this matters for client work. When a client needs "personalised content" or "dynamic experience," the right answer is usually not a generative model — it's a well-structured authored content pool with smart selection logic. Same approach we use for personalised onboarding sequences, dynamic landing-page variants, and tier-based email automation. Cheaper, more predictable, no AI hallucinations.
Problem 5 — Procedurally rendered item icons with rarity glow (zero PNG cost)
This is the one we're proudest of. The game has 5 tiers of equipment across 3 classes and 5 slots — that's hundreds of distinct items, each with its own visual identity, rarity color, and tier-driven glow. The naive solution is to paint every one as a PNG (500+ files, 5+ MB of assets, an artist's full-time job to maintain). We did the opposite.
Every item icon is drawn at runtime in code, on a 16×16 canvas, using a tiny ASCII-art DSL plus a class+rarity-driven palette:
// src/render/itemIcons.ts
// Palette keys:
// . = transparent
// B = primary (rarity color — bronze/silver/gold/violet/cyan)
// D = primary shadow (darker rarity tone)
// S = highlight (light, off-white)
// M = motif (class accent / glow — drives the visible tier difference)
const ICON_BOW = sp`
....BBB.........
...BSSDB........
..BS...DB.......
..BS....BB......
..BS....SB......
..BS....SBB.....
..BS....SBBM.... <- the M pixels at the bow tip glow with tier color
..BS....SBB.....
..BS....SB......
..BS....BB......
..BS...DB.......
...BSSDB........
....BBB.........
`;
The 16×16 grid is a template string. The single letters are palette indices. The renderer at runtime substitutes real RGB based on the equipped item's class (knight/archer/mage) and tier (1-5).
The "glow" the player sees on rarer items isn't a separate asset, a shader, or a CSS effect — it's literally the M pixels in the icon getting brighter and more saturated at higher tiers:
// src/render/equipment.ts — tier palette logic
// Tier 1 (common): M is a subtle muted accent
// Tier 5 (legendary): M is bright, saturated, and visually pops
const tierPalette = (classId: ClassId, tier: number): Palette => {
const baseHue = HUE_BY_CLASS[classId]; // knight = warm gold, archer = green, mage = violet
const saturation = 30 + tier * 14; // T1: 44%, T5: 100%
const lightness = 45 + tier * 6; // T1: 51%, T5: 75%
return {
B: hslToHex(baseHue, saturation, lightness),
D: hslToHex(baseHue, saturation - 10, lightness - 25),
S: hslToHex(baseHue, 20, 92),
M: hslToHex(baseHue, saturation + 10, lightness + 10), // the glow channel
};
};
A common-tier bow shows as muted bronze with a barely-visible accent at the bow tip. A legendary-tier bow of the same template renders as deep emerald with a bright cyan-green glow at the tip and along the string. It's the same 16-byte sprite. The variation is entirely in the palette.
The file-size savings:
- Hand-painted alternative: ~150 items × 8 KB PNG average = ~1.2 MB of icon assets
- Procedural alternative: ~30 sprite templates in code × 200 bytes each + palette logic = ~6 KB total
That's 200× smaller for the icon system, with no quality compromise — the procedural icons look exactly as good as hand-painted equivalents because they're rendered at native canvas resolution against a per-class baseline that the team's pixel artist still authored.
The maintenance savings are larger. Adding a new sword variant means writing 16 lines of ASCII art in a TypeScript file. The five tier variants render automatically. The three class color schemes apply automatically. No art file to commit, no asset pipeline to maintain, no rebuild step.
Why this matters for client work. When a system needs many visual variants of the same conceptual element — product configurator previews, avatar customization, badge systems, status indicators — procedural rendering is dramatically cheaper than asset libraries. We use this same approach for OLVR's ring configurator (procedural variant previews from a shared base mesh), for an internal CRM's user-status badges, and for a Shopify-store launch where the client needed 80 product-color variants without commissioning 80 product photos.
Problem 6 — Performance on a 100 MB game in the browser
Web games punish you for shipping everything upfront. We solved this with three layers.
Layer 1: aggressive code splitting. Every non-title screen is a React.lazy()'d chunk.
// src/BernerCrawl.tsx
const BattleScreen = lazy(() => import("./screens/BattleScreen"));
const CasinoScreen = lazy(() => import("./screens/CasinoScreen"));
const PokerScreen = lazy(() => import("./screens/PokerScreen"));
const ShopScreen = lazy(() => import("./screens/ShopScreen"));
const WagerEventScreen = lazy(() => import("./screens/WagerEventScreen"));
const InventoryScreen = lazy(() => import("./screens/InventoryScreen"));
// …12 more screens
Players who never visit the casino never download the poker code. The first paint is the title screen and three buttons — about 200 KB.
Layer 2: a seeded RNG for reproducibility. All randomness goes through a single Mulberry32 instance with a seed derived from the run.
// src/rng.ts
export const mulberry32 = (seed: number): RNG => {
let s = seed >>> 0;
return {
next: () => {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
},
chance: (p) => this.next() < p,
};
};
Same seed produces same path DAG, same shop rolls, same casino outcomes, same wager event draws. This lets us debug crash reports by re-running the player's exact path.
Layer 3: Web Audio for soundtrack moods. Four 6-minute music tracks routed through a graph that drops a low-pass filter cutoff to 1100 Hz with Q=3.0 when the player enters the casino — giving the casino music a muffled, late-night-bar feel without shipping a second mix.
// src/audio/MusicPlayer.tsx
const lowPass = audioCtx.createBiquadFilter();
lowPass.type = "lowpass";
lowPass.frequency.value = isCasino ? 1100 : 18000;
lowPass.Q.value = isCasino ? 3.0 : 0.5;
One filter node. Full mood shift.
Problem 7 — Painted art that stays consistent across procedurally-generated runs
The roguelike's runs are procedurally generated — the path DAG, the encounter sequence, the wager event order. But the art has to feel hand-crafted, not procedural. We solved this with an art-registry pattern: every painted backdrop in the game (50 boss arenas, 25 shop interiors, 15 casino backdrops, 15 wager-event vignettes, 5 floor landscapes, 3 endgame illustrations) is registered to a floor and a context, then drawn from a deterministic pool per run.
// src/artRegistry.ts (sketch)
// 10 boss backdrops per floor (rotated by RNG seed). The art is hand-painted
// for the floor's mood, so the player never sees a desert backdrop on the
// ice floor.
export const BOSS_BACKDROPS_BY_FLOOR: Record<number, string[]> = {
1: ["boss-01.jpg", "boss-02.jpg", /* ...8 more for floor 1 */],
2: ["boss-11.jpg", "boss-12.jpg", /* ...8 more for floor 2 */],
3: ["boss-21.jpg", /* ... */],
4: ["boss-31.jpg", /* ... */],
5: ["boss-41.jpg", /* ... */],
};
// 5 shop interiors per floor; 3 casino backdrops per floor.
// Each pool is seeded — same run produces same backdrops, but the
// player sees different ones run-to-run.
The result: every painted asset is reused intentionally, never random-feeling. The player notices that floor 2 always has a snow-forest aesthetic even if the specific scene changes; they never see a tropical-beach boss arena on an icy mountain floor.
Why this matters for client work. Procedural systems that need a hand-crafted feel always benefit from this pattern: author the assets, register them with their context, draw from constrained pools. Same approach we use for testimonial rotation on client landing pages (matched to industry), case-study selection in cold emails (matched to recipient persona), and product-photography variants in Shopify Liquid templates.
The takeaway
A game forces you to solve every category of engineering problem at once: state management, AI behaviour, audio mixing, narrative systems, performance, persistence, procedural rendering, art direction. Done well, it ends up looking suspiciously like every other engagement at a small agency — same seven patterns, different domain.
If you're considering Rough Works for a project that needs custom systems thinking — not just templates and theme work — come talk to us. Berner Crawl is the demo reel.
The complete source is online; the playable build is on Netlify. We'd rather show than tell.

