Skip to the build
duck yeahBack to the adventure

Behind the ducks.

A duck adventure about growing a business that can keep its promises. Here’s how the story, decisions and records fit together.

What’s under the feathers.

I built this for my MotherDuck Growth Marketer application: a way to show how I approach a tangled operating problem, with room for curiosity, experimentation and a deeply unhelpful Guildmaster.

One story
beats.md holds the scenes, discoveries and choice stakes. The game service sends the same story to the browser and MCP, so an agent and a player can follow the same adventure.
One set of rules
Python determines which choices are possible and what they change. A generated set of those transitions runs in the Cloudflare Worker. Each accepted turn is saved in D1, including the setbacks.
Records you can question
MotherDuck Flights run the Python world generator. SQL tables connect orders, deliveries, costs and the practices the Guild actually used. Accepted campaigns are separately published from D1 into MotherDuck for analysis and replay.
A useful comparison
A replay starts from the original decision and keeps its history. You can compare promises kept, stock left waiting and money available for tomorrow. The trading data is fictional; its consequences follow explicit rules.
Follow one route with SQL

This query reads a generated reference run from the latest published rules version. Deliveries, broken promises, cash and debt stay separate.

WITH latest AS (
  SELECT rules_version
  FROM bad_mother_duckers.game.rules_builds
  ORDER BY published_at_utc DESC
  LIMIT 1
)
SELECT day, delivered, broken,
       trade_margin, closing_cash, closing_debt
FROM bad_mother_duckers.game.daily_ledger
WHERE data_kind = 'fictional_reference'
  AND rules_version = (SELECT rules_version FROM latest)
  AND run_key = 'reference:cautious_repeat'
ORDER BY day;
Where a saved turn goes

D1 stores the accepted move immediately. An operator publishes a batch of those saved checkpoints to MotherDuck and verifies the copy. Gameplay continues independently of that publication; a warehouse replay reflects the last verified batch.

The native Dive is a private view for exploring those published records. The game’s browser and MCP connection both use the shared game service.

Who built what.

I set the question, shaped the narrative, challenged the direction and reviewed the work. Claude Code and Codex wrote the code and SQL. I can read and modify SQL; I’m not writing it from scratch yet.

The source shows how a choice becomes an accepted move, a saved record and the next scene.

Browse the project on GitHub, including earlier scripts, editorial reviews and the illustration prompts.

One style. Room to play.

Gilt looking grandiose.
Grandiose
Gilt looking defensive.
Defensive
Gilt looking hopeful.
Hopeful

The cast shares a simple visual vocabulary: broad bodies, compact black eyes, flat orange bills, charcoal outlines, sky blue, mint and coral. The scene changes with the story; the portrait changes with the line.

Reference the character. Describe the moment. Generate, look, adjust. A concerned dockmaster should still look like the same dockmaster. A swan that was broken a moment ago should stay broken.

Read an early ranger prompt

One step in developing the illustration style, using the selected references from that pass.

Use case: illustration-story. Create a polished cartoon website illustration using the supplied approved duck art as the visual authority. Preserve the broad continuous head-neck-belly unibody, compact dark graphic pie-cut eyes, long broad flattened orange duck bill, orange webbed feet, thick smooth charcoal contours and clean white or mint fills. Expressive poses are allowed; wings should remain readable rounded feathered wings, with natural crossed contour loops where appropriate, never human fingers or a weird fist. Flat sky blue, mint, coral and orange with very restrained shading. No labels, captions, typography, watermark, frame, scenery or baked-in colored background. Genuine transparent background and generous transparent padding all around. All costume edges, feet and props fully inside canvas. White body and white costume marks stay opaque. This is a new illustration, not a collage of the reference panels. Draw ONLY ONE ranger duck from image 1; image 2 supplies accepted drawing cohesion only, no other cast members. Full-body three-quarter pose facing slightly screen-left, head lowered to examine ONE small unfolded cream map held in front of its breast. The map has just a simple charcoal dotted trail and a coral X, no letters, no detailed text. Rounded feather wings support the paper edges from underneath without human fingers, fists or extra limbs. Green pointed ranger cap with coral-red feather, mint-green short cape, small orange clasp, sky-blue cross-body satchel with orange button. Keep these costume identifiers visible; do not add a bow, weapon or armor. Keep face visible above the map and both complete connected orange webbed feet visible below it. Curious and ready to choose a route, not celebrating a result. Compact square composition with clear space around the hat feather and feet.
The three ducks flee an overenthusiastic, fire-breathing dragon.

Generated with the built-in image generator. Original masters and earlier candidates are retained; the website uses the selected WebP exports.

The useful wrong turns.

The first version gathered evidence but didn’t give its reader a useful next move. My review was blunt:

“We didn’t make an outcome that had any meaningful value to any one particular user.”

The next version became an experiment lesson. There was useful work in it, but it asked too much before getting to the point. The adventure gave the work a purpose: get the lamps lit, then keep them lit.

The first game still hid too much of its meaning in the Appendix. That led to the shared script and visible loading sheet: what happened, why it matters, and what the next choice changes.

Browse the earlier planner experiments

These belong to the earlier application demo.

The workload calculation · JavaScript
/** Explicit planning arithmetic. No observed treatment effect is estimated here. */
export function modelHistory({ eligibleAccounts, historicalRate, upliftPoints, reviewMinutes, capacityHours, targetAccounts }) {
  const values = [eligibleAccounts, historicalRate, upliftPoints, reviewMinutes, capacityHours, targetAccounts];
  if (values.some(value => typeof value !== 'number' || !Number.isFinite(value))) throw new Error('Enter a number for every planning assumption.');
  if (!Number.isInteger(eligibleAccounts) || eligibleAccounts < 0 || historicalRate < 0 || historicalRate > 1 || reviewMinutes <= 0 || capacityHours < 0 || targetAccounts <= 0) throw new Error('Counts, rates and workload must be within their stated ranges.');
  const modeledRate = historicalRate + upliftPoints / 100;
  if (modeledRate < 0 || modeledRate > 1) throw new Error('This assumed change would put qualification below 0% or above 100%.');
  const additionalAccounts = eligibleAccounts * upliftPoints / 100;
  const reviewHours = eligibleAccounts * reviewMinutes / 60;
  return {
    additionalAccounts, reviewHours, modeledRate,
    requiredLiftPoints: eligibleAccounts ? targetAccounts / eligibleAccounts * 100 : null,
    withinCapacity: reviewHours <= capacityHours,
    meetsValueTarget: additionalAccounts >= targetAccounts,
    noAudience: eligibleAccounts === 0,
  };
}
The audience query · SQL
-- Read-only summary of the exact candidate replay. Runner embeds the candidate SELECT.
WITH candidates AS (
{{candidate_sql}}
), segment_rows AS (
  SELECT 'task' AS segment_family, team_task_at_decision AS team_task,
    NULL::VARCHAR AS evaluation_intent, historical_outcome FROM candidates WHERE eligible
  UNION ALL
  SELECT 'intent', NULL::VARCHAR, evaluation_intent_at_decision, historical_outcome FROM candidates WHERE eligible
  UNION ALL
  SELECT 'task_and_intent', team_task_at_decision, evaluation_intent_at_decision, historical_outcome FROM candidates WHERE eligible
), segment_outcomes AS (
  SELECT segment_family, team_task, evaluation_intent, COUNT(*) AS eligible_accounts,
    COUNT(*) FILTER (WHERE historical_outcome IN ('qualified','not_qualified')) AS mature_accounts,
    COUNT(*) FILTER (WHERE historical_outcome = 'qualified') AS qualified_accounts,
    COUNT(*) FILTER (WHERE historical_outcome IN ('pending','missing','invalid')) AS unresolved_accounts,
    CAST(COUNT(*) FILTER (WHERE historical_outcome = 'qualified') AS DOUBLE)
      / NULLIF(COUNT(*) FILTER (WHERE historical_outcome IN ('qualified','not_qualified')),0) AS qualification_rate
  FROM segment_rows GROUP BY segment_family, team_task, evaluation_intent
)
SELECT COUNT(*) AS total_accounts,
  MIN(trial_started_at) AS first_trial_at, MAX(trial_started_at) AS last_trial_at,
  COUNT(*) FILTER (WHERE validation_issue IS NOT NULL) AS invalid_input_accounts,
  COUNT(*) FILTER (WHERE outcome_validation_issue IS NOT NULL) AS invalid_outcome_accounts,
  COUNT(*) FILTER (WHERE eligible) AS eligible_accounts,
  COUNT(*) FILTER (WHERE NOT eligible) AS held_accounts,
  COUNT(*) FILTER (WHERE eligible AND historical_outcome IN ('qualified','not_qualified')) AS mature_eligible_accounts,
  COUNT(*) FILTER (WHERE eligible AND historical_outcome = 'qualified') AS historical_qualified_accounts,
  COUNT(*) FILTER (WHERE eligible AND historical_outcome IN ('pending','missing','invalid')) AS unresolved_eligible_outcomes,
  CAST(COUNT(*) FILTER (WHERE eligible AND historical_outcome = 'qualified') AS DOUBLE)
    / NULLIF(COUNT(*) FILTER (WHERE eligible AND historical_outcome IN ('qualified','not_qualified')),0)
      AS historical_qualification_rate,
  (SELECT to_json(LIST(struct_pack(segment_family := segment_family, team_task := team_task,
      evaluation_intent := evaluation_intent, eligible_accounts := eligible_accounts,
      mature_accounts := mature_accounts, qualified_accounts := qualified_accounts,
      unresolved_accounts := unresolved_accounts, qualification_rate := qualification_rate)
      ORDER BY segment_family, team_task, evaluation_intent)) FROM segment_outcomes) AS segment_outcomes_json,
  (SELECT to_json(map_from_entries(LIST(struct_pack(key := segment, value := accounts))))
    FROM (SELECT team_task_at_decision AS segment, COUNT(*) AS accounts FROM candidates WHERE eligible GROUP BY team_task_at_decision)) AS task_segments_json,
  (SELECT to_json(map_from_entries(LIST(struct_pack(key := segment, value := accounts))))
    FROM (SELECT evaluation_intent_at_decision AS segment, COUNT(*) AS accounts FROM candidates WHERE eligible GROUP BY evaluation_intent_at_decision)) AS intent_segments_json,
  to_json(map_from_entries(LIST(struct_pack(key := eligibility_reason, value := reason_count))
    FILTER (WHERE first_in_reason = 1 AND eligibility_reason <> 'eligible'))) AS exclusion_counts_json
FROM (
  SELECT *, COUNT(*) OVER (PARTITION BY eligibility_reason) AS reason_count,
    ROW_NUMBER() OVER (PARTITION BY eligibility_reason ORDER BY account_id) AS first_in_reason
  FROM candidates
)
The three-step interaction · TypeScript
import { modelHistory } from '../lib/duck-yeah/history-planner.mjs';

const play = document.querySelector<HTMLElement>('[data-first-play]');
if (play) {
  const tabs = [...play.querySelectorAll<HTMLButtonElement>('[role=tab]')];
  function selectTab(index: number, focus = false) {
    tabs.forEach((tab, i) => {
      tab.setAttribute('aria-selected', String(i === index));
      tab.tabIndex = i === index ? 0 : -1;
      document.getElementById(tab.getAttribute('aria-controls')!)!.hidden = i !== index;
    });
    if (focus) tabs[index].focus({ preventScroll: true });
  }
  tabs.forEach((tab, index) => {
    tab.addEventListener('click', () => selectTab(index));
    tab.addEventListener('keydown', event => {
      const next = event.key === 'ArrowRight' ? (index + 1) % tabs.length
        : event.key === 'ArrowLeft' ? (index + tabs.length - 1) % tabs.length
        : event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : null;
      if (next !== null) { event.preventDefault(); selectTab(next, true); }
    });
  });
  play.querySelectorAll<HTMLButtonElement>('[data-play-next]').forEach(button => button.addEventListener('click', () => {
    selectTab(tabs.findIndex(tab => tab.id === `play-tab-${button.dataset.playNext}`), true);
  }));

  const minutes = document.querySelector<HTMLInputElement>('#play-minutes')!;
  const capacity = document.querySelector<HTMLInputElement>('#play-capacity')!;
  const hours = document.getElementById('play-hours')!;
  const call = document.getElementById('play-call')!;
  const bar = document.getElementById('play-capacity-bar')!;
  const count = Number(play.dataset.eligible);
  const format = (n: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }).format(n);
  function updateWorkload() {
    if (!minutes.validity.valid || !capacity.validity.valid) {
      hours.textContent = 'A couple of numbers, then we’re off.';
      call.textContent = 'Use 1–120 minutes per review and 0–10,000 available hours.';
      bar.style.width = '0%';
      return;
    }
    const result = modelHistory({ eligibleAccounts: count, historicalRate: 0, upliftPoints: 0,
      reviewMinutes: minutes.valueAsNumber, capacityHours: capacity.valueAsNumber, targetAccounts: 1 });
    const canReview = Math.min(count, Math.floor(capacity.valueAsNumber * 60 / minutes.valueAsNumber));
    const needed = document.createElement('span');
    needed.textContent = `${format(result.reviewHours)} hours of work.`;
    const available = document.createElement('span');
    available.textContent = `${format(capacity.valueAsNumber)} hours available.`;
    hours.replaceChildren(needed, document.createTextNode(' '), available);
    call.textContent = canReview === 0 ? 'No room for reviews yet. I’d keep this batch self-serve.'
      : result.withinCapacity ? `All ${format(count)} accounts fit. Time to agree on the test.`
      : `I’d narrow the batch to ${format(canReview)} ${canReview === 1 ? 'account' : 'accounts'} before sending.`;
    bar.style.width = `${Math.min(100, capacity.valueAsNumber / result.reviewHours * 100)}%`;
  }
  minutes.addEventListener('input', updateWorkload);
  capacity.addEventListener('input', updateWorkload);
  updateWorkload();
}

const team = document.querySelector<HTMLInputElement>('#play-team-email');
const teamLine = document.getElementById('play-team-line');
if (team && teamLine) {
  const update = () => { teamLine.hidden = !team.checked; };
  team.addEventListener('change', update);
  update();
}