Product case studies

I built a production fantasy-football platform in one week, then shipped it to iOS.
Here's how I made the calls.

Knife Fight is a last-team-standing fantasy football app: no weekly matchups, no bye-week luck. Every team scores against the whole league each week, and the lowest total gets cut. I'm the sole product owner. These are four real calls I made building it end to end in July 2026, from web app to a native iOS build with live push.

Product owner · solo, AI-assisted engineering · live at knifefight.io · iOS on TestFlight

Jul 12→20
Zero to iOS
402
Commits
135
Merged PRs
161
Automated tests

Case study 01 · Lifecycle & retention

Every email is a triggered lifecycle message, not a blast

This is my day job. Nineteen emails, every one fired by a real event, each killing a specific drop-off risk. Here's the whole flow.

19 triggered emails double opt-in + verified-only sends frequency-capped, commit-safe

Every email exists because something happened. Drafts. Trades. Waivers. Wins. Losses. The hard part wasn't writing them. It was deciding when they should exist.

knife-fight-lifecycle · the email journey live map
01 · Onboarding
Welcome & confirm Owner
Trigger · signup
Double opt-in. A confirmed address before any other mail: the deliverability foundation everything downstream sits on.
Reset your password
Trigger · reset request
Re-entry for a locked-out returning owner. You can't let that lapse bounce off the login screen.
02 · The draft
The draft order is set
Trigger · order randomized & locked
The one appointment nobody can miss. Your slot and start time, early enough to actually show up.
The draft has begun
Trigger · draft starts
The "it's happening now" trigger that converts intent into attendance.
You're on the clock Owner
Trigger · your pick clock starts
A slow draft spans days; nobody watches it live. The precise interrupt that pulls you back the moment your pick matters.
clock running
10 min left Owner
Last-chance nudge before a miss.
10 min left Commish
Heads-up to step in. One trigger, two audiences.
on miss
We drafted [player] for you Owner
Escalation · 1st missed pick
Transparency plus a warning: one more miss flips autodraft on. The warning is really a re-engagement nudge.
miss again
Autodraft is on for your team Owner
Escalation · 2nd miss in a row
Sets expectations before automating someone's draft. Then the inbox goes deliberately quiet.
Your draft is in: the recap Owner
Trigger · draft ends (enrolled)
After going quiet, one clean recap re-onboards the absent owner into the season with a full roster.
03 · In-season
You're on the line Owner ↻ weekly
Trigger · mid-week, projected to be cut
The single most important retention hook. It fires off the app's live cut-odds engine, a deterministic seeded simulation, so the trigger is a stable projection, not a number that flickers on refresh. Capped once per team per week.
The trade negotiation Owner ↻ counter loop
Trigger · offer · counter · rejection
Three sends cover the haggle: the offer (a 7-day window, surfaced right away), a counter that closes the old deal and opens the amended one (this loop runs as long as both sides keep at it), and a rejection that prompts a rework instead of silence.
A trade needs your review Commish
Trigger · deal accepted, post-commit
The moment the review clock starts, reviewers hear it: both sides of the deal, the executes-at time, and the veto one tap away. Skips a commissioner who accepted their own deal.
Trade completed Owner
Trigger · executed, post-commit
An action prompt: set your lineup, traded players left their slots empty.
You didn't survive the cut Owner
Trigger · eliminated
The hardest moment. A graceful exit that plants a re-engagement seed instead of a dead end.
04 · Win-back
Your league drafts tomorrow
Trigger · ~24h before a scheduled draft
The day-before pull-back: reactivates a dormant owner ~24 hours out, nudging them to queue targets before the room opens. Miss the draft and autopick fills your board.
Come watch the final blades
Not built yet
Keep an eliminated owner engaged as a spectator: the bridge into next season.
Knife Fight is back for the season
Not built yet
The annual reactivation: win back last year's owners when a new season opens.

Illustrative journey board, built for this write-up · drag to pan →

Triggered, never batched

Every message fires off real draft or game state: behavioral, event-driven sends, not a scheduled newsletter. The product's own state machine is the campaign trigger.

Deliverability discipline

Double opt-in on signup, and lifecycle mail only goes to verified addresses. Protecting sender reputation is a growth decision, not a compliance chore.

Frequency capping

"You're on the line" sends at most once per team per week, tracked in a durable ledger that survives server restarts. The highest-value message is the one most protected from fatigue.

The quiet-inbox principle

After two autodraft notices, the inbox goes silent on purpose. One end-of-draft recap covers the rest. Restraint is part of the design, not an oversight.

Commit-safe sends

Notifications fire only after the database transaction commits, never on a rollback. No owner ever gets a "trade completed" email for a trade that didn't happen. Trust is a deliverability asset.

Role-based segmentation

The same trigger reaches different people with different copy: the 10-minute warning nudges the owner and alerts the commissioner. Audience, not just event, shapes the message.

The journey went multi-channel: push is live

For most of the build every message was an email, because email is the only channel a web app owns. The iOS app changed that: push now ships the urgent moments, verified end to end on a real device. The strategy was never "everything everywhere." Email stays the durable record, real-time interrupts moved to push, and SMS and an in-app inbox are the next layers.

Lifecycle momentChannelsWhy that channel
You're on the clock Push SMS Time-critical and can't be missed. Push interrupts in the moment; SMS is the draft-day fallback with the highest open rate.
The draft has begun Push The "get in the room" moment, fired to every owner the second picks open.
You're on the line Push Real-time, mid-game urgency. A push during the window it references drives far more re-opens than an email read hours later.
10 minutes left Push A short-fuse nudge only has value if it arrives instantly, and push has none of email's latency.
The trade loop: offer / counter / rejected / review / completed Push In-app Every step lands on both channels, including the commissioner's review notice the moment a deal is agreed. An in-app inbox is the pressure valve if trade volume ever turns it into noise.
Eliminated / draft recap In-app Reference material, read on the owner's schedule, deliberately not a push. Restraint keeps the pushes above worth opening.
Win-back: "your league drafts tomorrow" Push SMS The highest-leverage message on the board. Live on email and push, firing ~24h out; SMS is the draft-day fallback still ahead.
Spectator re-engagement (post-cut) Push In-app An eliminated owner is still retainable. Keeping them watching the final blades carries them into next season.

Solid = live today · outlined = still ahead

Alongside the channels, an in-app layer: a first-run onboarding checklist, nudges tied to product state (set your lineup, get a claim in before the Scramble), and survival-streak milestones that give a still-alive owner something to come back for.

The craft, made real

Every message ships from one responsive template: a hero, one call to action, copy that reflows cleanly down to a 320 px phone. The full set of nineteen is one click away.

The Knife Fight welcome email resizing responsively from 600 to 360 pixels
Welcome email, 600 → 360 px. One template, no breakpointed forks.
The on-the-clock email with an animated pick clock draining down
On-the-clock email. The pick clock drains as your turn ticks down. Frame one is built to stand alone, so clients that freeze GIFs (Outlook) still show a legible clock.
server/src/lib/email.ts · renderEmail()
// one fluid shell: % width, capped + centered
<table width="100%"
       style="max-width:600px; margin:0 auto">
  …
  // hero + images scale with the column, never overflow
  <img width="600"
       style="width:100%; max-width:600px; height:auto">
  …
  <td class="kf-card" style="padding:36px 40px">
</table>

// tighten only on small screens; the inline
// styles stay the baseline everywhere else
<style>
  @media only screen and (max-width:600px) {
    .kf-card  { padding:24px 20px !important }
    .kf-title { font-size:23px  !important }
  }
</style>
One fluid shell, one media query: the recipe every send reuses.
Appendix → The lifecycle email library All nineteen triggered emails: the real subject line, what fires it, and the actual rendered HTML.

Retro: the discipline I'm proudest of is what the flow doesn't send. It would've been easy to add a "someone drafted a player" email, the kind of engagement theater that trains people to mute you. Every message here earns its place by interrupting a real drop-off moment.

Case study 02 · Live operations

Autodraft that can actually draft

Bad autopick is a running joke: kickers in round four, no clue what a bye week is. I needed one a room of real people would trust to pick for them.

8 state-machine tests 2-strike auto-enrollment picks fire in ~10s once enrolled
On The Clock
PICK 34 · ROUND 3
LIVE
#34Cutthroat Crew RB · 0:07 left
#33Bone Saw Six → J. Dobbins, RBBot
#32Edge Lords → D. London, WR
#31Blunt Force → T. McBride, TE
BONE SAW SIX1 STRIKE · ENROLLED

Illustrative UI mockup, built for this write-up

01The problem

A live draft with 8 to 18 people has one failure mode: someone doesn't show, and the room sits dead on a clock. Every platform ships an autopick fallback, and every player has a story about how bad it is. I wanted one a commissioner could trust.

02The solution

The autopick encodes real roster judgment, not a best-available list: fill starting slots first, don't stash an injured player over a healthy one at equal value, keep the bench balanced. A genuine strategy function, unit-tested on its own.

But one missed pick isn't the failure mode; a person gone for the rest of the draft is. Miss two picks in a row and you're auto-enrolled: picks fire in about ten seconds, and the room keeps moving without anyone stepping in.

03Key tradeoffs

  • The doctrine started too conservative: it wouldn't take a Questionable player over a healthy one even at equal value, and in a test draft that left a genuinely good player undrafted. I relaxed it so only real structural risk gets avoided.
  • Enrollment needed an audit trail, not a flag. A ledger records the exact pick that triggered it, so undoing a pick rewinds only that pick, not the draft.
  • Real commissioners surfaced a gap no tuning could fix: sometimes a human has to step in. I added overrides (force a pick, toggle autodraft for any owner) routed through the same functions the automatic system already uses, so there's no second code path to drift.

04What shipped

Bot tags on every auto-fired pick, a strike-based enrollment system, ~10-second auto-fire, and commissioner override powers for force-picks and per-owner autodraft toggling.

05Outcome & status

Live in production, verified with 8 state-machine tests plus a live end-to-end draft run. One bug worth owning: a boolean stored as raw 0/1 made the UI render the literal string "0" next to every hand-drafted pick in production. Caught it, fixed it at the type boundary, added a regression test.

Retro: the lesson wasn't the algorithm. It was resisting a separate override system. Reusing the automatic path's own functions meant the override couldn't drift out of sync.

Case study 03 · The moat feature

Waivers aren't a side feature here, they're the whole game

Every eliminated team's whole roster hits the wire every week. A highest-bid-wins system misses what actually matters here: when you spend beats how much.

priority-group rounds, not flat bids up to 4 runs/week drop chains up to 64 deep
The Scramble Report
RUN 2 OF 4 · WED 10:00 ET
J. Jacobs RB · GBBlunt Force$473 outbid
D. London WR · ATLEdge Lords$312 outbid
T. McBride TE · ARICutthroat Crew$264 outbid
J. Dobbins RB · LACBone Saw Six$80 outbid

Illustrative UI mockup, built for this write-up

01The problem

In a normal league, waivers are a once-a-week afterthought. Here they're the whole game: a full roster clears every week, all at once. Every platform ships the same flat FAAB auction, and it misses the real tension: a $20 bid placed early can beat a $95 bid placed late, because by round two the player's already gone. I wanted the mechanic to teach that, not a help article.

02The solution

Claims are ranked into priority groups: the whole league's round-one claims process together before any round-two claim is looked at, then round two, then round three. "Rank ruthlessly" isn't a tip, it's the rule the processor runs. That one mechanic became the feature's headline pitch.

03Key tradeoffs

  • Every claim carries a fallback drop chain, so it never fails for lack of a roster spot. The chain was capped at a hardcoded number that silently truncated valid plans on larger rosters. Fixed by tying the cap to the real roster-capacity math instead of a magic number.
  • Waivers run up to four times a week, not once, so a cleared roster doesn't sit dead in a queue waiting on the one weekly window.
  • Every run posts a box score (who won which player, for how much, who got outbid) to the league feed. A deliberate call to turn a backend batch job into something people read and argue about.

04What shipped

Grouped, round-based claim processing; fallback drop chains; up to four processing runs a week; the Scramble Report box score in the league feed; mobile touch-reordering for priority groups.

05Outcome & status

Live in production, and the product's real differentiator: the one feature I rebuilt from scratch instead of reusing what every other app ships, because the flat-bid version couldn't tell the story the format needed.

Retro: the best decision here wasn't a feature. It was refusing the default mechanic because it didn't match the story the format needed to tell.

Case study 04 · Growth / 0-to-1

Solving the ghost-town problem without a cash prize

Public leagues only work if strangers join, and strangers only join a league that looks alive. I ruled out the obvious fix, real money, on purpose.

3 league sizes seeded 2 draft-timing modes soft-launched to friends only
Open Leagues
LOBBY
Live · +24h
The Crucible K4V
4 of 6 seats
Slow · starts on fill
Last One Standing 7WT
1 of 2 seats
Arena · Slow
The Gauntlet P12
0 of 12 seats

Illustrative UI mockup, built for this write-up

01The problem

Public leagues have a cold-start problem: nobody joins an empty league, but a league needs people to stop being empty. I also drew a line before any code: no real money, on legal and liability grounds. That ruled out the usual fix, since a cash prize does the work of making an empty league feel worth joining.

02The solution

A "house" commissioner account (no human owner, never plays) seeds a standing pool of open leagues, and a driver runs each through its whole lifecycle with no person involved: fill, draft, run the week, finalize, reseed. It reuses the same commissioner code paths a real human already exercises, so there's no separate "fake league" to keep in sync.

03Key tradeoffs

  • Two draft-timing modes, because "start immediately" is wrong for half the audience. Slow drafts start on fill, with a long per-pick clock that pauses overnight. Live drafts deliberately do not start on fill (2 of 6 seats online is a ghost town); they schedule a start 24 hours out in a friendly window, so a league that fills at 3 AM gets a Saturday-morning slot.
  • The floor-of-three guard from earlier applies here too. I didn't duplicate the fix, I reused the same guard.
  • Growth surfaces have to reach people already signed in, not just new visitors. So the "open leagues" module sits on the logged-in dashboard, not only a separate lobby page.

04What shipped

A self-driving house-league pool across three sizes and two endgame modes, two draft-timing strategies, a public lobby page, and a home-dashboard preview module.

05Outcome & status

Soft-launched: live and running real house leagues today, but deliberately not promoted past friends. Moderation and a policy for "what happens when a public league never fills" are still open, and I wrote both down as explicit questions rather than shipping around them.

Retro: the most useful decision here was writing down what I deliberately didn't finish, and why, instead of letting "soft-launched" quietly become "done." A portfolio full of only finished work hides how you actually scope.

A closer look · Using your own product

Two dots meant "new." Only one of them meant "yours to do."

Not a feature I set out to build. A thing I noticed by actually living inside the product across sessions, then had to name so it would hold for every dot after this one, not just these two.

Gallery

Before · both blue, both say "new"

Gallery

After · amber says "yours," blue says "new"

Illustrative mockup of the two header signals, built for this write-up

01The catch

I'd asked for a way to reach the commissioner trade tools from the header menu, since I'd lost time hunting for it in an earlier session. It shipped: a menu shortcut with a dot when a trade needed my sign-off, styled to match the blue unread dot the chat drawer already used. Clean, consistent, shipped, merged.

Then I actually used it. A trade came in, and now two dots lit up at once, the menu icon and the chat icon, both the same blue. I sat there for a second not sure which one mattered more, before realizing they were answering two different questions with one color: chat's dot means "something new happened," the menu's dot means "something needs your decision, on a clock." Same paint, different stakes. That's not a nitpick, that's the product lying to you a little.

02The fix

Not a new color invented from scratch. The app already used amber elsewhere for a trade sitting in its review window, so amber already meant "act on this" in one corner of the product, it just hadn't been carried through everywhere it applied. I named the split so it would generalize: blue for anything new to read, no obligation attached. Amber for anything with your name on it and a clock running. Red stays reserved for the actual survival stakes of the game, on the line, cut, so it never gets diluted into ordinary admin noise.

Then I asked for the same language on the leagues list on the home page, commissioner or not: your pick's up, your draft is live, a trade's waiting on your answer. Same amber, same rule, one more surface, because a rule you only apply once isn't a rule.

03Why this is the job

Anyone can spec "add a notification dot." The part that's actually the work is sitting inside your own product long enough to notice that two of your own dots contradict each other, then fixing it as a system instead of a patch, so the fix holds for the dot you haven't built yet either.

Retro: I didn't catch this in a design review. I caught it by using the thing I shipped, the same way a real commissioner would, and trusting that "this feels slightly off" was worth stopping for. That instinct is most of what a good PM is actually for.

The method

The hard part was never the code.

I can read code and reason about a system, but I don't hand-write production TypeScript, and this platform still shipped: a live web app and a native iOS build from one codebase. Cheap implementation moved the bottleneck to the thing I already do for a living: deciding what to build, sequencing it, and knowing when it's right. The one thing I can't do, catch a subtle bug in a diff, I hand to a reviewer that reads every change before it ships.

The loop Decide, sketch the UILovable Build itClaude Code Review the PRCodeRabbit Verify & merge→ deploy ↻ run ~135 times
Every green merge to main deploys, automatically:
knifefight.io
The production web app. Live the moment a PR merges.
GitHub → Railway → Cloudflare
iOS · TestFlight
The same app, wrapped for the phone with native push.
Capacitor → Xcode → APNs

One loop, run a few hundred times across a single week. The web app ships on every merge; the iOS build is the same code, wrapped and pushed to TestFlight.

You bring the judgment
The differentiator
  • The idea, and the taste to know it's worth building
  • Every product call: what ships, what waits, what gets cut
  • Sequencing 100-plus PRs so the thing actually converges
  • UX and UI direction: the feel, the flow, the pixel-level "not that, this"
  • The copy and the brand voice
  • Judging when it's right, and when it isn't
Product sense, not syntax. The part that took years to earn, and the part AI can't hand you.
Your AI crew does the work
Three specialists
  • Claude Code builds it: the implementation, tests, infra, debugging
  • CodeRabbit reviews every PR, and catches the bugs I can't, because I'm not an engineer
  • Lovable is my UI sandbox: sketch a screen and react to it before it's real
Three specialists. Not one of them decides what to build.
The tools plug in
Wired once
Build
Node · React · Vite · Tailwind
Ship
GitHub · Railway · Cloudflare
Data & mail
Sleeper · ESPN · Resend
iOS
Capacitor · Xcode · APNs · TestFlight
Ops
UptimeRobot · nightly backups
A one-time setup, not a skill you keep needing.

If you can run a campaign, hold a roadmap, and tell good from bad, you already have the scarce part. The tools are a one-time setup. Deciding what's worth building, and refusing to ship until it feels right, is the work, and no model does that for you yet.

How this got built

Solo, AI-assisted, and honest about both.

None of this is hidden, and that's deliberate. Solo, AI-assisted engineering is a real way to build in 2026: my judgment, the model's execution. I'd rather show exactly how it worked than pretend a bigger team made it. One person, blank repo to production, in a week.

The whole thing runs as a single process against one SQLite file: no external database, no separate services to keep alive. A deliberate choice to keep running costs near zero while it's a side project, with a written plan for what breaks first if it stops being one.

Before trusting any of this with a real NFL Sunday, I built a synthetic chaos-testing harness: it boots the real production server against mocked score feeds and runs one deliberately chaotic game day (feed outages, a stat correction, a canceled game), asserting the things that must hold no matter what. It caught a launch-blocking bug before a single real game touched it.

AI made building cheaper. It didn't make product judgment cheaper. That's still the work.

Knife Fight is live at knifefight.io Visit the product →
Case studies written by Devin Entmacher, July 2026. Every number on this page is pulled directly from the project's git history and test suite.