<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>From the lab | Stalefish Labs</title><link>https://stalefishlabs.com/read/</link><description>Essays, build notes, and deep dives on indie app development, game design, weather engines, physics simulations, and more.</description><generator>Hugo 0.155.2</generator><language>en-us</language><lastBuildDate>Tue, 12 May 2026 20:47:00 +0000</lastBuildDate><atom:link href="https://stalefishlabs.com/read/index.xml" rel="self" type="application/rss+xml"/><item><title>Building an Automated Analyst</title><link>https://stalefishlabs.com/read/2026-05-12-building-an-automated-analyst/</link><pubDate>Tue, 12 May 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-05-12-building-an-automated-analyst/</guid><description>How a five-stage pipeline turns RSS feeds into structured intelligence assessments, and why the distinction between summary and assessment shaped every technical decision.</description><content:encoded>&lt;p&gt;The &lt;a href="https://stalefishlabs.com/use/citizens-daily-brief/"
&gt;Citizen&amp;rsquo;s Daily Brief&lt;/a&gt; is a daily brief for the people, modeled after a similar intelligence brief that has been delivered to the U.S. President since 1946. Like the PDB, the CDB isn&amp;rsquo;t a news aggregator. It doesn&amp;rsquo;t summarize articles. It doesn&amp;rsquo;t curate links. It produces &lt;em&gt;assessments&lt;/em&gt; — structured analytical judgments about what happened in the world, what it means, how confident we are, and what to watch next. That distinction sounds like marketing copy, but it&amp;rsquo;s actually the architectural decision that shaped every line of code in the CDB pipeline. And it&amp;rsquo;s a specific decision given that this project deliberately leans into the strengths of AI.&lt;/p&gt;
&lt;p&gt;So why the big distinction between summary and analysis? A summarizer asks: &amp;ldquo;What does this article say?&amp;rdquo; An analyst asks: &amp;ldquo;Given everything we&amp;rsquo;re seeing across all sources, what&amp;rsquo;s actually happening, and how sure should we be?&amp;rdquo; That second question is the one I personally found hard to answer by just bouncing around news feeds each day trying to learn what&amp;rsquo;s going on in the world. But I wanted that daily assessment, the citizen&amp;rsquo;s version of what the U.S. President gets each day. Building the news analyst turned out to be a fundamentally different engineering problem than building a summarizer.&lt;/p&gt;
&lt;h2 id="the-five-stages"&gt;The Five Stages&lt;/h2&gt;
&lt;p&gt;The automated CDB information pipeline runs five stages, Monday through Saturday, completing before most Americans wake up. Here&amp;rsquo;s what each one does and why it exists.&lt;/p&gt;
&lt;h3 id="stage-1-ingestion"&gt;Stage 1: Ingestion&lt;/h3&gt;
&lt;p&gt;Every morning, there&amp;rsquo;s an automated news fetcher that pulls from thirty-four curated RSS feeds spanning government sources, international outlets, wire services, specialist publications, and outlets from across the political spectrum. It grabs headlines, metadata, and summaries for anything published in the last 28 hours (overlapping with the previous cycle to catch late-publishing sources). The idea is to span enough disparate news sources to get a feel for what&amp;rsquo;s truly notable and worth assessing.&lt;/p&gt;
&lt;p&gt;The 28-hour window is deliberate. Different outlets publish on different schedules. Wire services update continuously. Government sources often publish late in the afternoon. International outlets operate on different time zones. A 24-hour window would miss stories that broke at the boundary. The overlap means some stories appear twice in the raw input, but deduplication handles that downstream.&lt;/p&gt;
&lt;p&gt;The fetcher normalizes everything into a common record format: headline, outlet name, outlet type, editorial perspective, publication timestamp, summary text, and source URL. Each source carries an editorial perspective label, which is a structural grouping that identifies whether the outlet operates from a wire, public media, broadcast, left-leaning, right-leaning, business, international, specialist, or other editorial position. I thought a lot about this piece, and wavered mightily on the left/right leaning part in particular. But the perspective identification is what allows the rest of the pipeline to not know or even care whether a record came from a BBC RSS feed or a Fox News Google News filter. It just sees structured records with perspective metadata.&lt;/p&gt;
&lt;p&gt;After normalization, the fetcher runs a wire syndication detection pass. When AP or Reuters publishes a story, other outlets frequently republish the same wire copy, sometimes verbatim, sometimes with minor modifications. You&amp;rsquo;d be surprised at how much news is duplicated across sources. The fetcher identifies likely syndicated wire articles through byline credit patterns (&amp;quot;(AP)&amp;quot;, &amp;ldquo;By Associated Press&amp;rdquo;, etc.) and title similarity matching against known wire headlines. It&amp;rsquo;s worth identifying and tagging syndicated articles so that downstream perspective counting doesn&amp;rsquo;t inflate independence scores.&lt;/p&gt;
&lt;h3 id="stage-2-clustering-and-significance-scoring"&gt;Stage 2: Clustering and Significance Scoring&lt;/h3&gt;
&lt;p&gt;Stage 2 is where things get interesting and the analysis piece kicks in. It&amp;rsquo;s also really the most novel facet of the CDB architecturally. The normalized records are sent to Claude Sonnet with a prompt that asks it to do two things: group related stories into clusters, and score each cluster&amp;rsquo;s significance across five dimensions. The end result is a composite significance score that provides a means of whittling down daily news into what makes the brief.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Source volume&lt;/strong&gt; — How many outlets are reporting this? A story covered by twelve sources is likely more significant than one covered by two, though not always.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Source diversity&lt;/strong&gt; — How many distinct &lt;em&gt;editorial perspectives&lt;/em&gt; cover this story? This is the most important dimension and carries the most weight in the composite score. A story reported by AP (wire), Fox News (right_leaning), NPR (public_media), and the Wall Street Journal (business) has four perspectives — genuinely cross-spectrum significance. A story reported by Fox News, the Daily Wire, Breitbart, the New York Post, and the Washington Examiner has five outlets but one perspective. The pipeline counts perspectives, not URLs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Official action&lt;/strong&gt; — Did an institution actually do something? A bill was signed, a rate decision was announced, a court ruling was issued — these are actions with concrete consequences, not just stories about stories.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Breadth of impact&lt;/strong&gt; — How many people does this affect? A trade policy affecting global supply chains scores higher than a local regulatory change, even if the local change is more dramatic. There&amp;rsquo;s a balance here, and admittedly since the CDB is patterned on the PDB, it deliberately skews American, at least initially.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Novelty&lt;/strong&gt; — Is this genuinely new, or is it the latest increment of an ongoing story? Novelty matters because the brief should tell you what changed today, not re-litigate what&amp;rsquo;s been developing for weeks.&lt;/p&gt;
&lt;p&gt;Each dimension gets a sub-score from one to ten. The composite significance score combines the five sub-scores using a weighted formula: source diversity at 30%, official action at 25%, breadth of impact at 25%, source volume at 10%, and novelty at 10%. The weighting is deliberate — editorial diversity is the strongest signal of genuine significance, followed by institutional action and breadth of impact. Raw source volume is intentionally the lowest weight because volume without diversity can just mean amplification within a single editorial ecosystem.&lt;/p&gt;
&lt;p&gt;The LLM returns &lt;em&gt;all&lt;/em&gt; identified clusters, not just the top selections. Each cluster is marked as selected or unselected, with the top five to nine clusters selected for the brief. The full ranked list, including stories that didn&amp;rsquo;t make the cut, is preserved and published separately. This wider list exists because downstream consumers may need access to the complete significance-ranked picture, not just the curated top nine. A good example of this is the FICINT Fictional Intelligence feature, which uses the full ranked list of news stories to track if/when a theme tips into requiring a dossier addition.&lt;/p&gt;
&lt;p&gt;That full ranked list of stories is important because it effectively provides &amp;ldquo;receipts&amp;rdquo; to validate why a story made the brief. The LLM provides analytical judgment within a framework, but the framework provides consistency. The sub-scores are visible. The weighting formula is documented. The full cluster list is preserved. If you want to audit why a story made the brief and another didn&amp;rsquo;t, the data is there. The CDB is all about &amp;ldquo;showing our work&amp;rdquo; — there&amp;rsquo;s nothing hidden, no ulterior motives.&lt;/p&gt;
&lt;h3 id="stage-3-synthesis"&gt;Stage 3: Synthesis&lt;/h3&gt;
&lt;p&gt;For each selected cluster, Claude Sonnet writes the structured brief item. This is where the &amp;ldquo;assessment, not summary&amp;rdquo; distinction becomes concrete. The prompt asks for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Headline&lt;/strong&gt; — What happened, in one line&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What changed&lt;/strong&gt; — The specific new development (not background, not context, just the delta)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Why it matters&lt;/strong&gt; — Analytical judgment about significance and implications&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What to watch&lt;/strong&gt; — Forward-looking: what would confirm or challenge this assessment&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Why this made the brief&lt;/strong&gt; — A structured receipt explaining inclusion: how many sources, how many editorial perspectives, which perspectives, and what triggered the significance score&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And then there&amp;rsquo;s the trust infrastructure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Common ground&lt;/strong&gt; — Facts that all sources agree on (the verified baseline)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Key disagreements&lt;/strong&gt; — For stories where sources disagree, the specific points of disagreement (not vague &amp;ldquo;sources differ&amp;rdquo; but &amp;ldquo;wire services report X while government sources cite Y&amp;rdquo;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Open questions&lt;/strong&gt; — Things we don&amp;rsquo;t know yet (explicit uncertainty)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Timeline&lt;/strong&gt; — The sequence of events as reported across sources, including when a prolonged news story began&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Source attributions&lt;/strong&gt; — Which sources contributed what, with citation roles (primary, supporting, analysis, context, contradicting)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The prompt constraints are tight. Each field has length limits. &amp;ldquo;What changed&amp;rdquo; must be one to three sentences describing only the new development. &amp;ldquo;Why it matters&amp;rdquo; can&amp;rsquo;t restate the headline. &amp;ldquo;What to watch&amp;rdquo; must be forward-looking and specific, not vague (&amp;ldquo;watch for developments&amp;rdquo; is banned). The &amp;ldquo;why this made the brief&amp;rdquo; field follows a mandatory format: &amp;ldquo;Covered by N sources across M editorial perspectives ([list]). [Trigger sentence]. Significance rank: X of Y stories identified.&amp;rdquo; These constraints force the LLM to be precise rather than expansive, and make the inclusion rationale auditable. If you&amp;rsquo;ve spent much time with AI, you may have gathered by now that this isn&amp;rsquo;t just &amp;ldquo;ask ChatGPT to summarize today&amp;rsquo;s news.&amp;rdquo; The CDB is essentially a sophisticated, highly-tuned prompt &lt;em&gt;engine&lt;/em&gt; that focuses on a very specific way to filter and analyze news.&lt;/p&gt;
&lt;h3 id="stage-4-validation"&gt;Stage 4: Validation&lt;/h3&gt;
&lt;p&gt;Not every step in the process is AI-driven. Every synthesized item passes through a validator before publication. This is mechanical, not LLM-based — it&amp;rsquo;s Python code checking structural requirements:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;All required fields present and non-empty&lt;/li&gt;
&lt;li&gt;Field lengths within bounds (headline under 200 characters, etc.)&lt;/li&gt;
&lt;li&gt;Trust signals internally consistent (high confidence requires 2+ sources — you can&amp;rsquo;t be highly confident based on a single report)&lt;/li&gt;
&lt;li&gt;JSON schema conformance&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If an item fails validation, it&amp;rsquo;s dropped. If fewer than three items pass, the entire brief is skipped for that day. This is the &amp;ldquo;publish nothing rather than publish garbage&amp;rdquo; principle encoded in code. It has triggered exactly twice in testing — both times because a source outage produced an unusually thin input set. Both times, skipping was the right call. Again, we&amp;rsquo;d much rather have no brief than a sketchy brief — this project lives and dies by its trustworthiness.&lt;/p&gt;
&lt;h3 id="stage-5-publication"&gt;Stage 5: Publication&lt;/h3&gt;
&lt;p&gt;Items that pass validation are written to the database. A brief record is created, source records are inserted (deduplicated by URL), brief items are linked to their sources through a junction table with citation roles and display order. The brief status flips to &amp;ldquo;published&amp;rdquo; and the website regenerates.&lt;/p&gt;
&lt;p&gt;The full cluster list, including unselected stories, is also persisted to a separate table. This creates a complete record of what the pipeline saw, how it ranked everything, and what it chose to include or exclude. The &lt;code&gt;independent_source_count&lt;/code&gt; field on each brief item reflects the number of distinct editorial perspectives, not the raw number of source URLs.&lt;/p&gt;
&lt;p&gt;The whole pipeline runs in about twenty minutes. Most of that time is LLM inference — one clustering call that sees all source records at once, then individual synthesis calls for each of the five to nine selected clusters. The Python code itself executes in seconds.&lt;/p&gt;
&lt;h2 id="why-not-just-summarize"&gt;Why Not Just Summarize?&lt;/h2&gt;
&lt;p&gt;I keep emphasizing the assessment-versus-summary distinction because it&amp;rsquo;s the single decision that shaped everything else. If the CDB summarized articles, the pipeline would be trivially simple: ingest, concatenate, send to LLM with &amp;ldquo;summarize this,&amp;rdquo; publish the result. No clustering. No significance scoring. No trust signals. No validation. And honestly, no need for a web site or app, just ask the AI yourself.&lt;/p&gt;
&lt;p&gt;Summaries answer &amp;ldquo;what did these articles say?&amp;rdquo; Assessments answer &amp;ldquo;what is happening in the world today and how confident should you be about it?&amp;rdquo; The second question requires comparing sources against each other, identifying agreement and disagreement, scoring significance across multiple dimensions, and being explicit about uncertainty. This approach presents a more interesting software engineering challenge, but in doing so it gets at the heart of what we really want to know as citizens: what happened in the world, and why should I care?&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s what the pipeline does. Each stage exists because assessment demands it. Clustering exists because you can&amp;rsquo;t assess a story without first identifying that twelve different articles are about the same story. Significance scoring exists because you need to decide what&amp;rsquo;s worth assessing. The editorial perspective system exists because significance should reflect genuine editorial diversity, not volume from a single perspective. Validation exists because an assessment with inconsistent trust signals is worse than no assessment at all.&lt;/p&gt;
&lt;h2 id="the-weekly-assessment"&gt;The Weekly Assessment&lt;/h2&gt;
&lt;p&gt;In addition to the Monday-Saturday briefs, there&amp;rsquo;s a deeper Weekly Assessment that publishes on Sundays. The weekly assessment runs on Claude Opus, and is a different beast than the briefs. It receives an entire week&amp;rsquo;s worth of already-synthesized daily items — typically thirty to forty-two items — and produces a four-to-six-thousand-word analytical document. This is long enough that streaming the response matters for both reliability and cost management.&lt;/p&gt;
&lt;p&gt;The weekly prompt asks Opus to do things that Sonnet&amp;rsquo;s daily work can&amp;rsquo;t: trace narrative arcs across the week, identify cross-domain connections (how a trade policy story connects to a labor market story connects to a consumer confidence story), flag developing situations that no single daily brief captured, and note where confidence or agreement shifted between Monday and Saturday.&lt;/p&gt;
&lt;p&gt;This is meta-analysis. The LLM isn&amp;rsquo;t working with raw sources; it&amp;rsquo;s working with already-assessed, trust-scored daily items. Uncertainties roll up cleanly, meaning if a daily item was flagged as &amp;ldquo;developing&amp;rdquo; confidence, the weekly assessment can note whether that uncertainty resolved or deepened as the week progressed.&lt;/p&gt;
&lt;h2 id="the-structured-output-contract"&gt;The Structured Output Contract&lt;/h2&gt;
&lt;p&gt;Every LLM call in the pipeline uses structured output, which means the response must conform to a defined JSON schema. This isn&amp;rsquo;t just a nice-to-have; it&amp;rsquo;s what makes the pipeline reliable enough to run unattended.&lt;/p&gt;
&lt;p&gt;The clustering prompt specifies exactly what fields each cluster should have: a label, source indices, sub-scores for each of the five significance dimensions, a composite score, editorial perspectives present, and selection status. The synthesis prompt specifies the brief item schema down to field types and nullable flags. The LLM doesn&amp;rsquo;t get to decide the shape of its output, only the content within a predetermined structure.&lt;/p&gt;
&lt;p&gt;This is a lesson I&amp;rsquo;ve learned across multiple projects: LLMs are most reliable when you constrain their output format and give them freedom within those constraints. Tell them &lt;em&gt;what to produce&lt;/em&gt; and let them decide &lt;em&gt;what to say&lt;/em&gt;. The alternative, free-form text that you parse afterward, is fragile, inconsistent, and a debugging nightmare.&lt;/p&gt;
&lt;h2 id="bias-auditing"&gt;Bias Auditing&lt;/h2&gt;
&lt;p&gt;The pipeline includes a longitudinal bias detection system that audits historical output for systematic skew. That&amp;rsquo;s a fancy way of saying we try really, really hard to be unbiased. The bias detection system checks topic tag distribution (are certain topics consistently over- or underrepresented?), editorial perspective coverage (are stories from certain perspectives systematically excluded?), selection bias (do unselected clusters skew toward particular perspectives?), trust signal patterns (do certain topics consistently receive lower confidence?), and perspective diversity per story (are most stories seen through only one editorial lens?).&lt;/p&gt;
&lt;p&gt;This isn&amp;rsquo;t a one-time check. It runs periodically against the accumulating output data, flagging any patterns that suggest the pipeline&amp;rsquo;s analytical judgments are drifting in a systematic direction. The flags are quantitative — threshold-based, not vibes-based. If right-leaning perspectives appear in clusters but those clusters get selected at a significantly lower rate than the baseline, that&amp;rsquo;s a flag. If a topic tag that should appear weekly is absent for two consecutive weeks, that&amp;rsquo;s a flag.&lt;/p&gt;
&lt;p&gt;The bias auditor can&amp;rsquo;t prove the pipeline is unbiased — that&amp;rsquo;s an impossible standard. But it can detect systematic &lt;em&gt;drift&lt;/em&gt;, which is the actionable concern. A pipeline that gradually skews over time can be corrected. A pipeline that&amp;rsquo;s never audited for skew will eventually drift without anyone noticing.&lt;/p&gt;
&lt;h2 id="error-handling-fail-loud-fail-safe"&gt;Error Handling: Fail Loud, Fail Safe&lt;/h2&gt;
&lt;p&gt;The pipeline has exactly one retry. If any stage fails, the system waits fifteen minutes and tries the entire pipeline again from the beginning. If the retry fails, the brief is skipped for that day.&lt;/p&gt;
&lt;p&gt;There are no fallbacks. No &amp;ldquo;use yesterday&amp;rsquo;s brief with an updated date.&amp;rdquo; No &amp;ldquo;publish a partial brief with whatever succeeded.&amp;rdquo; The brief is either a complete, validated, internally consistent assessment — or it doesn&amp;rsquo;t exist for that day.&lt;/p&gt;
&lt;p&gt;This philosophy is borrowed directly from the intelligence community. A briefing document that&amp;rsquo;s wrong is worse than no briefing document at all. The real PDB is occasionally late. It is never sloppy.&lt;/p&gt;
&lt;p&gt;The GitHub Actions workflow that runs the pipeline sends no notification on success (success is the default state) and alerts on failure. The system is designed to be boring when it works, which is the kind of boring I&amp;rsquo;m happy to build.&lt;/p&gt;
&lt;p&gt;And speaking of boring, if my tone seemed a bit more clinical in this article, it&amp;rsquo;s because this project occupies a unique slot in the Stalefish Labs portfolio. Building a daily news brief for citizens is serious business, so I&amp;rsquo;ve left most of the fun and games aside while working on this project. I want you to be able to trust it the same way I do. Feel free to &lt;a href="https://stalefishlabs.com/contact"
&gt;reach out&lt;/a&gt; if you have any questions, suggestions, etc.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The &lt;a href="https://stalefishlabs.com/use/citizens-daily-brief"
&gt;Citizen&amp;rsquo;s Daily Brief&lt;/a&gt; is a free daily intelligence briefing from &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/PDB.jpg" type="image/png" length="0"/></item><item><title>The Cell Phone of Games: Notes from Toy Fair 2003</title><link>https://stalefishlabs.com/read/2026-05-05-the-cell-phone-of-games/</link><pubDate>Tue, 05 May 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-05-05-the-cell-phone-of-games/</guid><description>Tall Tales debuted at Toy Fair 2003 during a record blizzard. Fox 5 News dubbed it 'the cell phone of games.' Then two scouts from Pixar mentioned a movie about cars.</description><content:encoded>&lt;p&gt;In February 2003, a blizzard later known as the President&amp;rsquo;s Day Storm shut down most of Manhattan and made it nearly impossible to get to the Jacob Javits Center, where the American International Toy Fair was set up that week. I was supposed to be at booth #6250 in the Specialty Source section on the 1st floor, debuting Tall Tales Pocket Edition. I made it. So did most of the other indie game makers — on foot, dragging suitcases of inventory through snow that was deeper than the wheels on the suitcases. It was my first Toy Fair, my first time designing a custom trade show booth, my first experience learning the ins and outs of NYC union labor&amp;hellip;check with an electrician before you plug or unplug ANYTHING!&lt;/p&gt;
&lt;p&gt;The booth itself was pretty modest: a 10-foot fabric pop-up mural with the three creatures from the game (Bigfoot, the Loch Ness Monster, and our take on the Roswell alien) wrapped around a counter covered in stacks of game boxes. Toy Fair is strange theater. The big toy companies take over entire halls; indie game makers like Stalefish Labs were tucked into a corner of one floor, hawking our wares to whoever wandered by. The buyers from major retailers had appointments scheduled with Hasbro and Mattel in private suites in entirely other buildings. We had whoever was curious enough to walk past Specialty Source.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://stalefishlabs.com/read/2026-05-05-the-cell-phone-of-games/BoothMural.png" alt=""&gt;&lt;/p&gt;
&lt;h2 id="the-cell-phone-of-games"&gt;&amp;ldquo;The Cell Phone of Games&amp;rdquo;&lt;/h2&gt;
&lt;p&gt;A reporter from Fox 5 News in New York stopped at the booth midway through the show. He was doing one of those &amp;ldquo;let&amp;rsquo;s roam Toy Fair and see what&amp;rsquo;s interesting&amp;rdquo; segments. He picked up the Pocket Edition box, asked what made it different, and I gave him the spiel: no board, no dice, no writing, fits in a coat pocket. He turned to the camera and said, on tape, &amp;ldquo;It&amp;rsquo;s so compact and mobile — it&amp;rsquo;s the cell phone of games!&amp;rdquo;&lt;/p&gt;
&lt;p&gt;I have used that quote in approximately every pitch letter, sell sheet, and product description I&amp;rsquo;ve written since. Or at least until the term lost its lustre. It captures something real about the design: I had been trying to make the smallest, most portable game I could. The 2003 cell phone — flip-style, pocket-sized, ubiquitous — was the right shape comparison. The iPhone wouldn&amp;rsquo;t ship for another four years; in 2003, &amp;ldquo;cell phone&amp;rdquo; still meant a thing you could close and forget about. Over the next decade the comparison aged in a funny way, but it did its job.&lt;/p&gt;
&lt;h2 id="two-people-from-pixar"&gt;Two People from Pixar&lt;/h2&gt;
&lt;p&gt;The other thing that happened, which I&amp;rsquo;ve thought about a lot since, was a quiet conversation at the booth with two people from Pixar.&lt;/p&gt;
&lt;p&gt;I don&amp;rsquo;t remember either of their exact titles. I remember that they came by, picked up the cards, and asked thoughtful questions about the artwork. The three creatures had been drawn deliberately stylized as cartoons rather than realistic depictions, and the Pixar people were curious about the design choices: why the alien was shaped the way it was, why Bigfoot stood the way he did.&lt;/p&gt;
&lt;p&gt;The artwork was &lt;a href="https://www.laaker.com"
target="_blank"
&gt;Micah Laaker&lt;/a&gt;&amp;rsquo;s. He had designed all three characters — Bigfoot, Nessie, and the Roswell alien — working out of Iguana Studios in New York. Micah had already won a Gold Award from the London International Advertising Awards in 2002 for the redesign of BattleBots.com, done in collaboration with Adobe Systems. He would go on to lead UX teams at Yahoo and, eventually, direct product design at Google. The Pixar scouts had good instincts.&lt;/p&gt;
&lt;p&gt;They asked if we&amp;rsquo;d ever thought about doing the characters as an animated property: a TV show, a short film. I said we&amp;rsquo;d thought about it and had worked on some ideas but didn&amp;rsquo;t have any of the relationships to pursue it seriously. Hint, hint.&lt;/p&gt;
&lt;p&gt;One of them said something I&amp;rsquo;ve never forgotten: that his team was working on a movie at the moment, couldn&amp;rsquo;t say much about it, except that it involved cars. This was February 2003. &lt;em&gt;Cars&lt;/em&gt; was released in June 2006. I don&amp;rsquo;t know if the people I was talking to were on the &lt;em&gt;Cars&lt;/em&gt; team specifically, or if they were at Pixar in some adjacent role and happened to mention a project that everyone in the building was talking about. I never followed up. They never followed up. I don&amp;rsquo;t know who they were.&lt;/p&gt;
&lt;p&gt;There is, however, a contemporaneous record of the conversation in our files. A cover letter I sent to a buyer at Barnes &amp;amp; Noble, dated February 25, 2003, six days after Toy Fair ended, says, verbatim:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;At Toy Fair we also caught the eye of several media producers, including a representative from Pixar, who thought the characters that appear in the game&amp;rsquo;s artwork have potential for an animated television series or movie. We are in the process of entering discussions with those people now.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That &amp;ldquo;process&amp;rdquo; was more blind ambition; we never spoke with Pixar again. That letter is the exact sort of dressed-up &lt;em&gt;look who else is interested!&lt;/em&gt; name-drop that a plucky indie game maker writes to retail buyers in the hope of getting picked up. But the encounter itself was real, they really were intrigued by the Tall Tales characters, and the cars detail is the part I remember sharpest.&lt;/p&gt;
&lt;h2 id="five-years-later"&gt;Five Years Later&lt;/h2&gt;
&lt;p&gt;Fast-forward five years to October 2008: Pixar premiered a series of animated shorts on Toon Disney called &lt;em&gt;Mater&amp;rsquo;s Tall Tales&lt;/em&gt; — Cars Toons starring the tow-truck character Mater telling whoppers about his life.&lt;/p&gt;
&lt;p&gt;The connection between that series and our 2003 conversation is, almost certainly, none. &amp;ldquo;Tall tales&amp;rdquo; is a stock phrase. Mater is a character explicitly built around exaggerated storytelling. A Pixar scout chatting up a stranger at a toy fair five years before a series airs is not a chain of causation. But it is, at minimum, a strange coincidence, a Pixar team at the booth of a card game called &lt;em&gt;Tall Tales&lt;/em&gt;, talking about animated characters and a project involving cars, and Pixar later releasing a series of cars-character shorts called &lt;em&gt;Tall Tales&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;I think about it the way you think about a near-miss in a parking lot. Nothing happened. But you noticed.&lt;/p&gt;
&lt;h2 id="a-few-other-notes"&gt;A Few Other Notes&lt;/h2&gt;
&lt;p&gt;The snowstorm got bad enough that the &lt;em&gt;Tall Tales Times&lt;/em&gt;, the in-character mock newspaper we made for promotional purposes, has a Q1 2003 issue with a story headlined &amp;ldquo;The Search for Matching Snowflakes Continues,&amp;rdquo; reporting that &amp;ldquo;the biggest snowstorm in New York history prompted a massive search for two identical snowflakes by visitors to the Stalefish Labs booth at Toy Fair, where the old story about no two snowflakes being alike was debunked.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://stalefishlabs.com/read/2026-05-05-the-cell-phone-of-games/tttimes-q12003.png" alt="Tall Tales Times" title="Tall Tales Times"&gt;&lt;/p&gt;
&lt;p&gt;A reviewer for &lt;em&gt;Parent Magazine&lt;/em&gt; stopped by, played a quick game, and offered an unsolicited blurb that has followed the game ever since: &amp;ldquo;I usually don&amp;rsquo;t find games that I like, but I really like this one.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;We sold a respectable number of games at the booth, mostly to buyers from independent toy and game stores. The big retailers passed. Tall Tales never broke through the way we&amp;rsquo;d hoped. The cell phone of games turned out to be more Blackberry than iPhone, and in many ways was the right metaphor for the wrong moment&amp;hellip;the social-card-game category would eventually emerge, with games like &lt;em&gt;Cards Against Humanity&lt;/em&gt;, &lt;em&gt;Codenames&lt;/em&gt;, and &lt;em&gt;Werewolf&lt;/em&gt; dominating dinner tables a decade later. Turns out Tall Tales was a bit early to that party in 2003. It was trying to be the next Cranium in a more compact form factor for a more casual environment, but it was aimed at a niche that hadn&amp;rsquo;t yet been invented.&lt;/p&gt;
&lt;p&gt;The remaining copies of Tall Tales Pocket Edition are &lt;a href="https://stalefishlabs.com/play/talltales/"
&gt;here&lt;/a&gt;.&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/new-york-toy-fair.jpg" type="image/png" length="0"/></item><item><title>Who Briefs the Public?</title><link>https://stalefishlabs.com/read/2026-05-03-who-briefs-the-public/</link><pubDate>Sun, 03 May 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-05-03-who-briefs-the-public/</guid><description>The President's Daily Brief exists because one person needs assessed information to make consequential decisions. The premise of the Citizen's Daily Brief is that all of us are now in that position.</description><content:encoded>&lt;p&gt;I need to start with an honest admission: I thought this project was too ambitious. I assumed I was being naive and borderline arrogant to even consider it. There, I said it.&lt;/p&gt;
&lt;p&gt;The President&amp;rsquo;s Daily Brief is arguably the most consequential document produced daily by the United States government. I had heard of it but didn&amp;rsquo;t know a lot about it, so embarked on a little research project. It&amp;rsquo;s assembled by thousands of analysts across the intelligence community, drawing on classified sources, satellite imagery, signals intelligence, and human networks spanning the globe. It&amp;rsquo;s been delivered to the President every morning since the Kennedy administration, its origins even dating back further to President Truman. Its purpose is singular and weighty: give the most powerful decision-maker in the world a shared understanding of reality so they can make consequential choices.&lt;/p&gt;
&lt;p&gt;And I thought: I should make a public version of that. Entirely automated by AI. And I should be fully transparent in doing so, meaning not even the slightest pretense that this is anything but the robots providing public intelligence.&lt;/p&gt;
&lt;p&gt;Yeah. I know how that sounds. Stick with me, please.&lt;/p&gt;
&lt;h2 id="the-gap"&gt;The Gap&lt;/h2&gt;
&lt;p&gt;The PDB exists because of a specific insight: the person making the most consequential decisions needs more than news. They need &lt;em&gt;assessed&lt;/em&gt; information grounded in reality. Not &amp;ldquo;here&amp;rsquo;s what happened&amp;rdquo; but &amp;ldquo;here&amp;rsquo;s what we think it means, how confident we are, and what we don&amp;rsquo;t know.&amp;rdquo; The format, structured assessment with explicit confidence levels, shown reasoning, and disclosed sources&amp;hellip;isn&amp;rsquo;t a luxury. It&amp;rsquo;s a requirement for making good decisions under uncertainty. And don&amp;rsquo;t forget the &amp;ldquo;grounded in reality&amp;rdquo; part because that&amp;rsquo;s really the main thing that got me thinking about this project: escaping information bubbles.&lt;/p&gt;
&lt;p&gt;And that led to the thinking that the insight of the PDB doesn&amp;rsquo;t apply only to the President. It applies to all of us.&lt;/p&gt;
&lt;p&gt;Every day, we make decisions that are shaped by our understanding of the world. How we vote. How we invest. Where we live. How we talk to our kids about what&amp;rsquo;s happening. Whether we&amp;rsquo;re worried or reassured about the economy, about geopolitics, about the systems that affect our daily lives. These decisions are consequential, if not at the scale of a presidential decision, then certainly at the scale of a life. And summed together, our personal decisions on these matters do eventually impact every facet of the world for better or worse. Maybe not to the same immediate degree as the President, but our individual assessment of the world indeed shapes the world. And ultimately, the sum of our individual assessments circles back to the president because we collectively elect them.&lt;/p&gt;
&lt;p&gt;So what do we have to inform those individual decisions? An algorithmically curated stream of content optimized to keep us engaged, to reinforce each of our precious belief bubbles. Outlet-specific framing where the same event reads as triumph or catastrophe depending on where we encounter it. A media landscape so fractured that two thoughtful, well-informed people can have completely incompatible understandings of the same week. And that&amp;rsquo;s really how this idea came to be.&lt;/p&gt;
&lt;p&gt;I had a confrontational discussion with a friend where we struggled about basic facts. I quickly realized it was impossible to have a constructive, meaningful, or even remotely honest conversation if we didn&amp;rsquo;t at least have some baseline shared reality of what is happening in the world. In many ways our modern ad-based, attention-craving news model is failing us. This is one admittedly ambitious attempt to correct that.&lt;/p&gt;
&lt;p&gt;I realized as individuals with wide-ranging preferences and beliefs, we don&amp;rsquo;t have anything resembling a common briefing. And I think that&amp;rsquo;s a problem worth trying to solve, even naively.&lt;/p&gt;
&lt;h2 id="the-naivety-problem"&gt;The Naivety Problem&lt;/h2&gt;
&lt;p&gt;Let me address this head-on: an LLM reading RSS feeds cannot replicate the intelligence community. It can&amp;rsquo;t access classified information. It can&amp;rsquo;t run human intelligence networks. It can&amp;rsquo;t task satellites. The analytical depth of a thousand trained intelligence professionals working across sixteen agencies is not reproducible by a Python script running on GitHub Actions. Period.&lt;/p&gt;
&lt;p&gt;But here&amp;rsquo;s what I learned when I actually built the thing: the PDB&amp;rsquo;s genius isn&amp;rsquo;t omniscience. It&amp;rsquo;s &lt;em&gt;discipline&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The PDB format imposes a structure on information that transforms how it&amp;rsquo;s consumed. Structured assessment instead of narrative reporting. Explicit confidence levels instead of implied certainty. Shown reasoning instead of assertions. Disclosed sources instead of anonymous authority. A finite daily artifact instead of an infinite stream.&lt;/p&gt;
&lt;p&gt;That discipline is exactly what you can encode in an automated pipeline. And it&amp;rsquo;s exactly what LLMs are good at.&lt;/p&gt;
&lt;p&gt;The first time the pipeline produced a complete daily brief, I expected it to read like a news summary, a cleaned-up version of whatever the RSS feeds contained. It didn&amp;rsquo;t. It read like a briefing document. The items had the shape of assessments, not summaries. They told me what happened, then what it meant, then how confident the system was, then what to watch for next. The evidence panel showed me which sources agreed and disagreed and what questions remained open. After reading the Day One CDB even in its most rudimentary form, I was hooked. Somehow it worked.&lt;/p&gt;
&lt;p&gt;It turns out the distinction between &amp;ldquo;what happened&amp;rdquo; and &amp;ldquo;what it means, how sure we are, and what to watch&amp;rdquo; was a prompt engineering problem with a real answer. Not a perfect answer. But a real one.&lt;/p&gt;
&lt;h2 id="whats-missing-from-public-information"&gt;What&amp;rsquo;s Missing from Public Information&lt;/h2&gt;
&lt;p&gt;I want to be specific about the gap the CDB tries to fill, because &amp;ldquo;news is broken&amp;rdquo; is a tired take and I don&amp;rsquo;t think it&amp;rsquo;s quite right. News isn&amp;rsquo;t broken. Reporting in many ways is as good as it&amp;rsquo;s ever been. Individual articles from good outlets are well-sourced, carefully written, and factually rigorous. What&amp;rsquo;s broken is the &lt;em&gt;layer above&lt;/em&gt; reporting, the synthesis layer that takes all that reporting and says: &amp;ldquo;Given everything, here&amp;rsquo;s what&amp;rsquo;s actually happening.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;That layer used to exist implicitly. When there were three TV networks and a handful of national newspapers, the nightly news served as a rough common briefing. Not because Walter Cronkite&amp;rsquo;s delivery was inherently more truthful or unbiased, but because it was shared. Everyone got the same information in the same format at the same time. You could flip between those three stations and for the most part see a consistent framing of events. That shared baseline, imperfect as it was, enabled a kind of common understanding that we&amp;rsquo;ve lost.&lt;/p&gt;
&lt;p&gt;The CDB tries to rebuild that layer. Not by pretending to be unbiased (the methodology page explains exactly how significance is scored and what the trust signals mean), but by being &lt;em&gt;assessed&lt;/em&gt; in a way that&amp;rsquo;s transparent and shared.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s what that looks like in practice:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Explicit confidence levels.&lt;/strong&gt; Every item says &amp;ldquo;high confidence,&amp;rdquo; &amp;ldquo;moderate confidence,&amp;rdquo; or &amp;ldquo;developing.&amp;rdquo; These aren&amp;rsquo;t feelings. &amp;ldquo;High&amp;rdquo; means multiple independent sources confirm the key facts. &amp;ldquo;Moderate&amp;rdquo; means credible sources but limited independent confirmation. &amp;ldquo;Developing&amp;rdquo; means the situation is fluid and key facts may change. You know exactly what the system thinks it knows and how sure it is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Agreement signals.&lt;/strong&gt; Separately from confidence, every item indicates whether sources broadly agree, have mixed interpretations, or are actively disputed. Confidence and agreement are orthogonal, meaning you can have high confidence in the facts but mixed agreement about what they mean. That&amp;rsquo;s OK because encoding both gives readers something rare: a structured way to understand not just what happened but how the information landscape looks. It doesn&amp;rsquo;t mean the facts differ, it means the interpretations of the facts vary.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why it matters.&lt;/strong&gt; Every item articulates its significance directly: what the development means for the broader picture, who is affected, and why it deserves your attention today. This is the analytical &amp;ldquo;so what?&amp;rdquo; that good reporting often leaves implied. Forcing the system to make that judgment explicit separates news from noise, and gives you something to weigh rather than just absorb.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What&amp;rsquo;s next.&lt;/strong&gt; Every item lists what the system assesses may be coming next, which is also revealing what the system doesn&amp;rsquo;t yet know. This is the most countercultural feature. News rarely says &amp;ldquo;we don&amp;rsquo;t know.&amp;rdquo; The CDB says it on every item, explicitly, as a structured field. Not knowing is information. Please read that last sentence again, it&amp;rsquo;s important.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Common ground.&lt;/strong&gt; The facts that all sources agree on, listed as a checklist. This is the verified baseline, the floor of shared reality beneath any disagreements about interpretation.&lt;/p&gt;
&lt;h2 id="when-it-stopped-feeling-naive"&gt;When It Stopped Feeling Naive&lt;/h2&gt;
&lt;p&gt;It happened faster than I expected. The first complete pipeline run produced something that genuinely read like a briefing document, not a news summary. The items had a shape and a voice that I hadn&amp;rsquo;t explicitly designed, they emerged from the structural constraints I&amp;rsquo;d imposed.&lt;/p&gt;
&lt;p&gt;The &amp;ldquo;what changed&amp;rdquo; field forced the LLM to identify the specific new development, not the background. The &amp;ldquo;why it matters&amp;rdquo; field forced analytical judgment, not description. The &amp;ldquo;what&amp;rsquo;s next&amp;rdquo; field forced forward-looking assessment, not recap. The trust signals, confidence and source agreement, forced transparency about what the system knows and where reasonable readers might still disagree. Together, these constraints produced something that reads the way an analyst thinks: event, significance, confidence, outlook.&lt;/p&gt;
&lt;p&gt;I started reading the CDB&amp;rsquo;s output the way I read well-made analytical documents, with the trust signals informing how I weighted each assessment. An item with high confidence and broad agreement gets filed differently in my mind than one with developing confidence and disputed agreement. Not because one is more important, but because they require different kinds of attention.&lt;/p&gt;
&lt;p&gt;That shift, from &amp;ldquo;reading the news&amp;rdquo; to &amp;ldquo;processing a briefing,&amp;rdquo; is what convinced me the project wasn&amp;rsquo;t naive. It might be incomplete, imperfect, and limited by its sources. But the format itself does something that no news feed, no social media timeline, and no cable news broadcast does: it tells you what the system thinks, how confident it is, and what it doesn&amp;rsquo;t know, in a structure designed for decision-making rather than engagement.&lt;/p&gt;
&lt;p&gt;Another note on the naivete: I&amp;rsquo;m not a journalist or a political analyst, and I&amp;rsquo;m not pretending to be. But I&amp;rsquo;ve spent a significant portion of my career reading, writing, and analyzing dense technical documents: I wrote computer books for over a decade and authored or contributed to more than 50 titles. That&amp;rsquo;s not a flex; it&amp;rsquo;s an acknowledgment that processing structured information for a reader is a skill I&amp;rsquo;ve practiced. Far from the best person to attempt this, but also far from the worst.&lt;/p&gt;
&lt;h2 id="the-honest-version"&gt;The Honest Version&lt;/h2&gt;
&lt;p&gt;Last thing, I could&amp;rsquo;ve written this as a polished origin story, and in some ways so far it is. &amp;ldquo;I identified a gap in the information ecosystem and built a solution.&amp;rdquo; But that&amp;rsquo;s not what happened.&lt;/p&gt;
&lt;p&gt;What happened is I was frustrated. I&amp;rsquo;d read four different outlets&amp;rsquo; coverage of the same event and come away with four incompatible understandings. Not because the reporting was bad, it wasn&amp;rsquo;t, but because each outlet framed the same facts through a different lens, and none of them told me how confident I should be in any of it. I wanted someone to just &lt;em&gt;brief me&lt;/em&gt;. To say: &amp;ldquo;Here&amp;rsquo;s what happened, here&amp;rsquo;s what it means, here&amp;rsquo;s what we know and what we don&amp;rsquo;t.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Then I realized: the format that does exactly this has existed since 1961. It&amp;rsquo;s the President&amp;rsquo;s Daily Brief. And while I can&amp;rsquo;t replicate the intelligence community&amp;rsquo;s capabilities, I can replicate the &lt;em&gt;format&lt;/em&gt;, the discipline of structured assessment with explicit confidence and shown reasoning.&lt;/p&gt;
&lt;p&gt;And once I realized the format existed, the other critical piece fell into place: AI. Even if this project was undertaken by humans, which I&amp;rsquo;m sure a few critical readers will suggest, do you really think a human is going to beat a machine at precisely what machines are good at? IBM&amp;rsquo;s Deep Blue settled this debate back in 1996 when a machine first beat a human in chess. Strategic information processing sits squarely in the wheelhouse of LLMs.&lt;/p&gt;
&lt;p&gt;So I built an LLM-based pipeline. And the first output was better than I expected. Not because the AI was smarter than I thought, but because the &lt;em&gt;format&lt;/em&gt; was more powerful than I&amp;rsquo;d appreciated. Structured assessment with trust signals turns out to be a genuinely different experience from reading news, regardless of who (or what) produces it. I had sorta stumbled into a use case that absolutely played to the strengths of AI.&lt;/p&gt;
&lt;p&gt;The gap between &amp;ldquo;this feels arrogant to attempt&amp;rdquo; and &amp;ldquo;but who else is doing it?&amp;rdquo; is exactly where the CDB lives. It&amp;rsquo;s a Lab project in the truest sense, an experiment built to test whether a format borrowed from the intelligence community can serve the public. The hypothesis is that what&amp;rsquo;s missing from public information isn&amp;rsquo;t more reporting, better algorithms, or less bias. It&amp;rsquo;s discipline. Structured assessment. Shown reasoning. Honest uncertainty.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s what the PDB gives the President every morning. The Citizen&amp;rsquo;s Daily Brief is an attempt to give it to everyone else. Let me know what you think, the web version is live now at &lt;a href="https://citizensdailybrief.org"
target="_blank"
&gt;citizensdailybrief.org&lt;/a&gt;, with mobile apps coming soon.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The &lt;a href="https://citizensdailybrief.org"
target="_blank"
&gt;Citizen&amp;rsquo;s Daily Brief&lt;/a&gt; is a free daily intelligence briefing from &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/IconHeading.png" type="image/png" length="0"/></item><item><title>How Tall Tales Almost Ended Up in Starbucks</title><link>https://stalefishlabs.com/read/2026-05-01-how-tall-tales-almost-ended-up-in-starbucks/</link><pubDate>Fri, 01 May 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-05-01-how-tall-tales-almost-ended-up-in-starbucks/</guid><description>How a failed 2002 Starbucks pitch shaped the design of Tall Tales, a card game built around small tables, simple rules, and quiet evenings with my brother.</description><content:encoded>&lt;p&gt;In the fall of 2001, I was deep into creating a trivia/storytelling game called Tall Tales. I started meeting my brother Steve at a Starbucks near where we lived in Nashville, after his AA meetings let out. It was one of the &amp;ldquo;good periods&amp;rdquo; with my brother, when he was focused on recovery and we could have a reasonably normal relationship. We had ups and downs over the years, and this stretch would prove to be the most meaningful one we shared. Often a few of his sober friends came along for our nightly coffee sessions, and it being summer and nighttime, we&amp;rsquo;d typically sit outside. The tables were small, maybe twenty-four inches across, and we&amp;rsquo;d push two together to get four or five chairs around. The conversation ranged wildly and there was a variety of interesting characters from night to night, and periodically I&amp;rsquo;d pull a half-finished prototype of my game out of the car to playtest.&lt;/p&gt;
&lt;p&gt;Like most indie games, Tall Tales was a side project, and I had been working on it for about four years at that point. The constraint of those little patio Starbucks tables, room for about two coffees and a pastry in the middle (cheese danish!), is the reason Tall Tales is shaped the way it is. I didn&amp;rsquo;t know that when I started bringing it. I figured it out around the third or fourth evening, when I noticed I&amp;rsquo;d been quietly redesigning the game to fit that particular surface.&lt;/p&gt;
&lt;h2 id="cranium-and-the-third-place"&gt;Cranium and the third place&lt;/h2&gt;
&lt;p&gt;The most-played new game in America at that point was Cranium. It had launched in 1998, the work of two ex-Microsoft colleagues, and become a phenomenon largely on the back of an unusual distribution deal: it was sold in Starbucks stores. Howard Schultz&amp;rsquo;s &amp;ldquo;third place&amp;rdquo; thesis, Starbucks as the place between home and work, was at its rhetorical peak. The third place was supposed to have games in it, and Cranium had won the slot. And Schultz was right, Starbucks was precisely that third place for me, my brother, and his friends.&lt;/p&gt;
&lt;p&gt;I was a fan of Cranium, but got frustrated every time we tried to play it at a Starbucks. The board didn&amp;rsquo;t fit on the tables. Its design drew from traditional board games — it was built for a dining room table. We&amp;rsquo;d push two café tables together and the board would still hang off the edge, and someone would inevitably bump it knocking pieces around. The mechanic and the venue weren&amp;rsquo;t aligned. Turns out Cranium was better designed for your first place, not your third place.&lt;/p&gt;
&lt;p&gt;The thing I noticed at the third or fourth evening with Steve&amp;rsquo;s crew was that the prototype I&amp;rsquo;d brought, by then a deck of cards, a die, a stack of creature-puzzle cards, and no board&amp;hellip;&lt;em&gt;fit&lt;/em&gt;. It fit on the table with two coffees and a slice of marble loaf (runner-up to cheese danish) still in play. We could actually play it there. And the game&amp;rsquo;s social mechanics — bluffing, voting, telling whoppers about the worst date you&amp;rsquo;d ever been on — turned out to be exactly the kind of thing that worked in a coffee shop. A board game asks you to focus on the board. A storytelling game asks you to focus on each other. The Starbucks table, where you were already focused on each other, was the right venue. I wasn&amp;rsquo;t &lt;em&gt;adapting&lt;/em&gt; the game to the venue. The venue was telling me what the game already wanted to be. It took a while but I eventually listened.&lt;/p&gt;
&lt;p&gt;I went home and rewrote the design pitch. It wasn&amp;rsquo;t &amp;ldquo;a card-based trivia game&amp;rdquo; anymore. It was &amp;ldquo;a &lt;em&gt;lounge game&lt;/em&gt;,&amp;rdquo; and the entire spec started organizing itself around that idea. No board. No dice. No writing. Two minutes from picking up the box to playing the first card. A footprint that fit the smallest table you could realistically be sitting at. What&amp;rsquo;s funny in retrospect is that I called this version of the game the Pocket Edition, with full plans to produce a full-size version later. It turned out the Pocket Edition became &lt;em&gt;the&lt;/em&gt; game, because it was the size for which the content and game mechanics were naturally tuned.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://stalefishlabs.com/read/2026-05-01-how-tall-tales-almost-ended-up-in-starbucks/BoxRender3D.png" alt="Tall Tales Pocket Edition" title="Tall Tales Pocket Edition"&gt;&lt;/p&gt;
&lt;h2 id="three-letters-to-seattle"&gt;Three letters to Seattle&lt;/h2&gt;
&lt;p&gt;I started writing pitch letters to Starbucks in September 2002.&lt;/p&gt;
&lt;p&gt;There were three of them, to three different people inside the company — one to Orin Smith, who was then the CEO, one to Darren Huston, a senior VP, and a generic one to &amp;ldquo;Coffee Folks&amp;rdquo; at the corporate PO box, in case the more targeted ones bounced. They have aged about the way you&amp;rsquo;d expect a thirty-something&amp;rsquo;s pitch letters to age: a little earnest, a little knowing, a little too proud of phrases like &amp;ldquo;die-hard Starbucks customers and Starbucks shareholders.&amp;rdquo; I did own a little Starbucks stock at the time, so that was fully above-board, if not a bit cheeky. I cited the Cranium comp directly. Starbucks had figured out games could thrive in their setting; I had simply built the game that fit.&lt;/p&gt;
&lt;p&gt;The letter to Darren Huston went further than the others. I told the actual story of why I&amp;rsquo;d designed the game the way I had — the evenings, the small tables, the support meetings, my brother. The pitch was that the design hadn&amp;rsquo;t come from a market analysis of the third-place trend; it had come from being &lt;em&gt;in&lt;/em&gt; the third place, week after week, watching what actually fit and what didn&amp;rsquo;t. We&amp;rsquo;d already proved the use case before we knew it was a use case.&lt;/p&gt;
&lt;p&gt;I made prototype cards in Starbucks-themed artwork. [&lt;em&gt;Insert: detail crop from &lt;code&gt;Prototype/StarbucksCards.ai&lt;/code&gt;.&lt;/em&gt;] The Illustrator file is still in the project folder. They were never delivered — by the time the first real response came back from Seattle, it was clear the answer was going to be no.&lt;/p&gt;
&lt;p&gt;Starbucks evaluated the game and passed. They didn&amp;rsquo;t say it wasn&amp;rsquo;t good. They said it wasn&amp;rsquo;t a fit. I assume the real answer was something more like &amp;ldquo;we already have Cranium and aren&amp;rsquo;t looking to add a second game right now,&amp;rdquo; which is a fair answer. Cranium had been a hit for them. Shelves are finite. Tall Tales went on to ship at the American International Toy Fair the following February without Starbucks distribution, found a small but enthusiastic following at independent toy and game stores, and never broke through the way we&amp;rsquo;d hoped. The lounge-game thesis turned out to be right. The retailer who would have validated it didn&amp;rsquo;t bite.&lt;/p&gt;
&lt;h2 id="why-im-telling-this-now"&gt;Why I&amp;rsquo;m telling this now&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;m telling this story in 2026, as part of putting the last of the Tall Tales inventory up for sale on the Stalefish Labs site — which is a slightly strange context for it. But I&amp;rsquo;ve been carrying the inventory around for decades, and decided maybe it&amp;rsquo;s time to tell the tale of how it came to be, and then let it go. One quiet thing before the link.&lt;/p&gt;
&lt;p&gt;Steve died a few years ago, in an alcohol-related accident, years after any of this. He was sober during those evenings in 2001 and 2002. He was sober for long stretches in the years after. AA recidivism is all too real, recovery is not a clean arc, and his story is not the redemptive one I might have written if the game had succeeded and he had outlived us all. None of that makes Tall Tales a memorial. But the design philosophy that became the spine of the game came directly from sitting at a small table with my brother during one of his sober stretches, and pretending otherwise to sell a card game in 2026 would be dishonest. He&amp;rsquo;s the reason the table-fit constraint exists. He&amp;rsquo;s the reason &amp;ldquo;lounge game&amp;rdquo; is a phrase I made up. The work happened because he was at the table. He was an incredibly funny and articulate person, and I miss him.&lt;/p&gt;
&lt;p&gt;The remaining copies of Tall Tales Pocket Edition are &lt;a href="https://stalefishlabs.com/play/talltales/"
&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If anyone in your life is in recovery and you think a card game might find a place at the table the way it did for us, reach out via the &lt;a href="https://stalefishlabs.com/contact/"
&gt;Stalefish contact page&lt;/a&gt;. I have a small stack set aside for that.&lt;/p&gt;
&lt;p&gt;Of course I kept a few for myself for the personal memories.&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/starbucksletter.png" type="image/png" length="0"/></item><item><title>Anatomy of a Skateboard Ramp: 3D Visualization and Materials Math</title><link>https://stalefishlabs.com/read/2026-04-02-anatomy-of-a-skateboard-ramp/</link><pubDate>Thu, 02 Apr 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-04-02-anatomy-of-a-skateboard-ramp/</guid><description>How circular arc geometry, real-world lumber standards, and an exploded 3D view power a free browser-based ramp design tool.</description><content:encoded>&lt;p&gt;I have a joke that if you ever meet a skateboarder over the age of 40, you&amp;rsquo;re likely looking a reasonably accomplished amateur carpenter. It was historically a DIY sport, and many of us learned how to build ramps more or less on our own, with mixed results. Building a skateboard ramp isn&amp;rsquo;t necessarily expert level carpentry, but there are important details and some common conventions that matter a great deal. For many of us, it was an exercise in optimism meeting carpentry. You start with a vision, a backyard quarterpipe or maybe a garage mini ramp, and quickly drown in questions. How tall, what radius, how many sheets of plywood? What about Skatelite or some other composite surface, even remotely in budget? What lumber lengths do I need? Will it actually look like the thing in my head?&lt;/p&gt;
&lt;p&gt;After I had a friend lament having no clue how much his new dream ramp might cost, I thought wouldn&amp;rsquo;t it be cool to have a little lightweight CAD&amp;rsquo;ish tool for visualizing and obtaining a materials list for skateboard ramps. I built the &lt;a href="https://stalefishlabs.com/experiments/ramp-designer/"
&gt;Ramp Designer&lt;/a&gt; to answer the tricky questions surrounding ramp design, and help my friend come up with a budget for his ramp. It&amp;rsquo;s a free, browser-based tool that generates a real-time 3D model of your ramp and calculates a complete materials list, down to the screw count.&lt;/p&gt;
&lt;h2 id="why-ramp-design-is-harder-than-it-looks"&gt;Why Ramp Design Is Harder Than It Looks&lt;/h2&gt;
&lt;p&gt;Before going any further, it&amp;rsquo;s worth noting that I&amp;rsquo;m specifically talking about curved skateboard ramps where the curve follows a fixed radius. In skateparks you will no doubt find banks, ledges, slants, and maybe even curved ramps with elliptical (varying) transitions, but classic quarterpipes and halfpipes you see in backyards or X-Games Vert are the focus here. Given that, a wooden skateboard ramp follows a certain recipe to allow for the curve via a layered construction: plywood side templates cut to a precise arc profile (radius), structural ribs running across the width, a plywood deck at the top, two layers of surface plywood bent over the ribs, now days mercifully a specialized riding surface on top of that, and a steel coping pipe at the lip. Each layer has its own material, its own fastener requirements, and its own set of constraints. And by the way, I said mercifully about the top surface because modern skate-specific composite surfaces like Skatelike, Ramp Armor, and Gator Skins dramatically improve the safety and usability of ramps - no more rot, and no more splinters!&lt;/p&gt;
&lt;p&gt;The curve itself, the transition, is what makes a ramp a ramp and not a wedge. A good transition follows a circular arc carefully matched to the height of the ramp, and the radius of that arc determines how the ramp feels to ride. A tight radius (small number) produces a quick, steep, snappy transition. A large radius creates a mellow, flowing curve. The relationship between height, radius, and the resulting arc length drives every other calculation in the build. And generally speaking there isn&amp;rsquo;t entirely a right or wrong, just extremes. For example, an extremely tight radius is more like a backyard pool like you might&amp;rsquo;ve seen in the Dogtown and Z-Boys documentary.&lt;/p&gt;
&lt;h2 id="the-arc-circular-geometry-in-practice"&gt;The Arc: Circular Geometry in Practice&lt;/h2&gt;
&lt;p&gt;The transition profile is a circular arc. Given a ramp height &lt;code&gt;h&lt;/code&gt; and a transition radius &lt;code&gt;r&lt;/code&gt;, the arc sweeps from horizontal (the flat approach) to vertical (or near-vertical at the lip). The math is clean:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;θ_max = acos(1 - h/r) // when h ≤ r
arc_length = r × θ_max
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;For mini ramp transitions where the height is less than or equal to the radius, this produces a smooth curve from 0° to θ_max, meaning the curve never makes it to 90°, it never reaches vertical. But some ramps deliberately go vertical, the height exceeds the radius, and that makes them vert ramps. In that case, the curve is a full quarter circle (90°) plus a straight vertical section at the top:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;// Vert ramp (h &amp;gt; r)
arc_length = r × π/2 + (h - r)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The simulator generates the arc as an array of coordinate pairs, sampling the curve at regular angular intervals. These points define the side template profile that gets cut from plywood, and they&amp;rsquo;re the foundation for positioning every rib, surface sheet, and seam line in the 3D model.&lt;/p&gt;
&lt;h3 id="curve-offsetting-for-layers"&gt;Curve Offsetting for Layers&lt;/h3&gt;
&lt;p&gt;A ramp has ribs inset and flush to the transition curve with multiple layers stacked on top, typically two subsurface plywood layers, followed by a third riding surface layer. Each layer needs to follow the same curve, but offset outward by the material&amp;rsquo;s thickness. It&amp;rsquo;s worth noting some metal-framed ramps like Tony Hawk&amp;rsquo;s famous portable warehouse ramp forego the two layers of subsurface and just go with one, but the ramp is engineered specifically to allow that.&lt;/p&gt;
&lt;p&gt;To get the radius to straighten to vert involves offsetting the circular arc, which is straightforward in theory (just increase the radius), but the simulator handles it with a general-purpose &lt;code&gt;offsetCurve()&lt;/code&gt; function that works on arbitrary point arrays. At each point, it computes the perpendicular normal using the slope between neighboring points, then shifts the point outward by the offset distance.&lt;/p&gt;
&lt;p&gt;This approach handles the transition from arc to vertical extension seamlessly, without special-casing the geometry at the inflection point.&lt;/p&gt;
&lt;h2 id="the-materials-engine"&gt;The Materials Engine&lt;/h2&gt;
&lt;p&gt;The materials calculator is where abstract geometry meets the lumber yard. Every dimension in the model maps to a real-world purchase decision, and the calculator accounts for constraints that CAD software ignores.&lt;/p&gt;
&lt;h3 id="lumber-length-rounding"&gt;Lumber Length Rounding&lt;/h3&gt;
&lt;p&gt;You can&amp;rsquo;t buy a 9-foot 2x6. Lumber comes in standard lengths: typically 8&amp;rsquo;, 10&amp;rsquo;, 12&amp;rsquo;, 14&amp;rsquo;, and 16&amp;rsquo;. The calculator rounds every piece up to the nearest available length. A rib that measures 8'3&amp;quot; in the model becomes a 10-footer on the shopping list. This is a small detail that prevents a lot of frustration at the lumber yard. It also helps you use the designer as a playground to experiment with materials sweet spots - wider is always better for skateboard ramps, and being able to know exactly the price difference between +4&amp;rsquo; and +8&amp;rsquo; in width is a big deal.&lt;/p&gt;
&lt;h3 id="sheet-goods-efficiency"&gt;Sheet Goods Efficiency&lt;/h3&gt;
&lt;p&gt;Plywood comes in 4×8 sheets. The calculator optimizes sheet counts by checking whether multiple pieces can be cut from a single sheet. For side templates, if the ramp height is 48&amp;quot; or less and the arc length fits within 96&amp;quot;, two sides can be cut from one sheet. Taller vert ramps need a multiple sheets per side, and the 3D model shows the horizontal seam at 4 feet where the two pieces join.&lt;/p&gt;
&lt;p&gt;Surface plywood is simpler: the calculator computes total surface area (arc length × width), divides by 32 square feet per sheet, and rounds up. Two layers are always used — the inner layer provides structural rigidity while the outer layer creates a smooth riding surface. Currently the tool forces 4x8 sheets, which is correct for plywood but specialty surfaces like Skatelite also come in larger dimensions up to 5x12 - that may be a future addition to the tool.&lt;/p&gt;
&lt;h3 id="rib-spacing-and-t-ribs"&gt;Rib Spacing and T-Ribs&lt;/h3&gt;
&lt;p&gt;Ribs are the structural backbone of the transition. They run perpendicular to the riding direction, spaced at regular intervals along the arc. The simulator offers three spacing options:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spacing&lt;/th&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;6&amp;quot; on center&lt;/td&gt;
&lt;td&gt;Overbuilt — heavy but bomber&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&amp;quot; on center&lt;/td&gt;
&lt;td&gt;Standard — recommended for most builds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&amp;quot; on center&lt;/td&gt;
&lt;td&gt;Budget — lighter, not ideal except for very small ramps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;But ribs aren&amp;rsquo;t uniform. And this is where some ramp builders diverge and have different techniques, but I like this one. Every 4 feet along the arc (where surface plywood sheets butt together), the simulator places a &lt;strong&gt;T-rib&lt;/strong&gt; instead of a standard rib. A T-rib is constructed out of two ribs joined together to form a T, providing more surface for the plywood sheets to meet and form a seam. Think of it as a rib T having a cap perpendicular to the stem, creating a wider bearing surface at the sheet seam. This prevents the plywood edges from telegraphing through the riding surface over time.&lt;/p&gt;
&lt;p&gt;The 3D model clearly renders T-ribs so builders can identify them during assembly. It&amp;rsquo;s somewhat a matter of construction preference where you start the T&amp;rsquo;s, as it has to do with where you start the plywood sheets. What&amp;rsquo;s important is that the T&amp;rsquo;s form a predictable pattern so that at least the first layer of plywood seams always meet on a T, for example every 4'.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://stalefishlabs.com/read/2026-04-02-anatomy-of-a-skateboard-ramp/8bc6aafc-c1f8-49de-a459-037879a48d08_1_105_c.jpeg" alt="Mini ramp framed with ribs and partially layered" title="Mini ramp framed with ribs and partially layered"&gt;&lt;/p&gt;
&lt;h3 id="fastener-estimation"&gt;Fastener Estimation&lt;/h3&gt;
&lt;p&gt;Screws are the most tedious part of a materials list. The calculator estimates counts based on fastener density per component:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Structural screws&lt;/strong&gt; (#10 × 3&amp;quot;): rib-to-side connections, framing joints, deck joists&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Surface screws&lt;/strong&gt; (#8 × 2&amp;quot;): plywood surface layers, riding surface material&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Counts are rounded to the nearest 25 or 50 to match bulk packaging. For outdoor builds, the calculator specifies coated or stainless fasteners and adds a note about corrosion resistance. Screws are definitely the one part of the build where you can use your own judgement if you find something you like in a slightly different length. There&amp;rsquo;s also debate over whether screws in the final riding surface should align hit ribs - manufacturers say yes, practical ramp builders sometimes say no.&lt;/p&gt;
&lt;h3 id="the-full-shopping-list"&gt;The Full Shopping List&lt;/h3&gt;
&lt;p&gt;A complete materials list for a typical 4-foot tall, 8-foot wide quarterpipe might include:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Material&lt;/th&gt;
&lt;th&gt;Quantity&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3/4&amp;quot; Plywood (sides)&lt;/td&gt;
&lt;td&gt;2 sheets&lt;/td&gt;
&lt;td&gt;Side templates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3/8&amp;quot; Plywood (surface)&lt;/td&gt;
&lt;td&gt;4 sheets&lt;/td&gt;
&lt;td&gt;Two layers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2×6 × 8'&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Ribs (includes T-ribs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4×4 × 4'&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Back support posts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2×4 × 8'&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Deck joists, plates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2⅜&amp;quot; Steel pipe × 8'&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Coping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skatelite 4×8&lt;/td&gt;
&lt;td&gt;2 sheets&lt;/td&gt;
&lt;td&gt;Riding surface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#10 × 3&amp;quot; screws&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;Structural&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;#8 × 2&amp;quot; screws&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Surface&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The half pipe configuration doubles most of these quantities and adds flat bottom materials (joists, surface, framing).&lt;/p&gt;
&lt;h2 id="the-3d-model-eight-layers-deep"&gt;The 3D Model: Eight Layers Deep&lt;/h2&gt;
&lt;p&gt;The 3D visualization is built with Three.js and renders the ramp as eight distinct layer groups:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Sides&lt;/strong&gt; — 3/4&amp;quot; plywood transition templates (don&amp;rsquo;t skimp and go thinner with these)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ribs&lt;/strong&gt; — 2×6 or 2×4 structural members following the arc&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Back Frame&lt;/strong&gt; — 4×4 posts and plates supporting the deck&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deck&lt;/strong&gt; — 3/4&amp;quot; plywood platform with joists&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Surface Layer 1&lt;/strong&gt; — 3/8&amp;quot; plywood&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Surface Layer 2&lt;/strong&gt; — 3/8&amp;quot; plywood&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Top Surface&lt;/strong&gt; — Riding material (Skatelite, Masonite, etc.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Coping&lt;/strong&gt; — 2⅜&amp;quot; steel pipe at the lip&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each layer is a separate Three.js group, which enables the explode view, a slider that separates the layers vertically so builders can see the assembly order and understand how the pieces fit together.&lt;/p&gt;
&lt;h3 id="building-the-side-templates"&gt;Building the Side Templates&lt;/h3&gt;
&lt;p&gt;The side templates are the most complex geometry in the model. They&amp;rsquo;re created as &lt;code&gt;THREE.Shape&lt;/code&gt; objects, 2D profiles defined by the arc points, and then extruded to 3/4&amp;quot; thickness using &lt;code&gt;ExtrudeGeometry&lt;/code&gt;. The profile includes the arc curve, a vertical edge at the back, a horizontal edge at the bottom, and the deck platform at the top.&lt;/p&gt;
&lt;p&gt;For ramps taller than 4 feet, the model adds a horizontal seam line showing where two 4×8 plywood sheets would join. This is a visual reminder that tall side templates require multiple sheets and careful alignment during construction.&lt;/p&gt;
&lt;h3 id="positioning-ribs-along-the-arc"&gt;Positioning Ribs Along the Arc&lt;/h3&gt;
&lt;p&gt;Each rib is a rectangular box positioned at a point along the arc and rotated to match the curve&amp;rsquo;s tangent angle at that point. The rotation is critical, a rib that&amp;rsquo;s not rotated perfectly tangent won&amp;rsquo;t square up to the plywood surface, providing far less structural support.&lt;/p&gt;
&lt;p&gt;The tangent angle at any point on the arc is calculated from the slope between adjacent arc points. The rib is then rotated around its center to align perpendicular to the curve, ensuring full contact with the surface plywood.&lt;/p&gt;
&lt;h3 id="surface-sheet-seams"&gt;Surface Sheet Seams&lt;/h3&gt;
&lt;p&gt;Real plywood sheets are 4×8 feet. The simulator renders individual sheets with visible seams between them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Arc seams&lt;/strong&gt; appear every 8 feet along the curve (where sheets butt end-to-end)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Width seams&lt;/strong&gt; appear every 4 feet across the ramp (where sheets sit side by side)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These seams are rendered as thin dark lines on the outermost visible surface. They&amp;rsquo;re not just cosmetic — they help builders plan sheet layout and understand where T-ribs need to be placed for support. I should add, one thing the tool doesn&amp;rsquo;t do just yet is offset the seams on the plywood layers. In practice you would shift layers a fixed amount, say 2&amp;rsquo; in each dimension so that seams overlap and you don&amp;rsquo;t risk future bumps in the ramp.&lt;/p&gt;
&lt;h3 id="the-coping"&gt;The Coping&lt;/h3&gt;
&lt;p&gt;The coping is a steel pipe rendered as a &lt;code&gt;THREE.CylinderGeometry&lt;/code&gt; with metallic material properties (metalness: 0.7, roughness: 0.3). It sits at the lip of the transition, where the arc meets the deck. Getting the coping position right is critical — it&amp;rsquo;s the last thing a skater touches before going airborne, and in the 3D model it helps verify that the transition profile looks correct.&lt;/p&gt;
&lt;p&gt;The framing of how coping joins a ramp, or the coping pocket, is one of the trickiest aspects of ramp building, and varies widely from builder to builder. I choose a fairly straightforward approach here since the goal was more about visualizing the ramp and figuring out materials. I haven&amp;rsquo;t ruled out a future coping pocket tool to break down how exactly to frame it and get the surface and deck pop dialed in.&lt;/p&gt;
&lt;h2 id="design-decisions-that-mattered"&gt;Design Decisions That Mattered&lt;/h2&gt;
&lt;h3 id="indoor-vs-outdoor-toggle"&gt;Indoor vs. Outdoor Toggle&lt;/h3&gt;
&lt;p&gt;The environment toggle switches more than just material names. Outdoor builds use pressure-treated (PT) lumber, which is heavier, more expensive, and requires coated fasteners. The calculator updates every line item: PT 2×6 instead of 2×6, coated screws instead of standard, and adds notes about ground contact treatment for the bottom plates.&lt;/p&gt;
&lt;p&gt;This is a single checkbox that changes 30+ line items in the materials list. Getting it wrong means either building an outdoor ramp with wood that will rot in two seasons, or spending 40% more than necessary on an indoor build.&lt;/p&gt;
&lt;h3 id="section-width-4-vs-8"&gt;Section Width: 4&amp;rsquo; vs. 8'&lt;/h3&gt;
&lt;p&gt;Section width determines how far apart the side templates are spaced. At 4-foot sections, a standard 8-foot-wide ramp has three side templates (both edges plus center) and two sections of ribs. At 8-foot sections, it has two templates (edges only) and one section of ribs. Some people like to overdo it structurally with narrower sections but 8-foot is pretty standard.&lt;/p&gt;
&lt;p&gt;The 4-foot option is labeled &amp;ldquo;overbuilt&amp;rdquo; because it doubles the plywood cost for side templates and adds significant weight. The extra rigidity prevents the surface from developing a noticeable bounce between supports but at the expense that you have to cut and assemble a lot more transitions and perfectly align them.&lt;/p&gt;
&lt;h3 id="the-explode-view"&gt;The Explode View&lt;/h3&gt;
&lt;p&gt;The explode slider was inspired by technical illustrations in woodworking manuals. Dragging it from 0% to 100% lifts each layer progressively — sides stay at the bottom, coping rises to the top, and everything in between fans out proportionally.&lt;/p&gt;
&lt;p&gt;The maximum separation scales with ramp height (2× the height), so a 4-foot mini ramp explodes to a manageable 8-foot spread while an 11-foot vert ramp expands to 22 feet. Surface sheets also spread apart laterally (along the z-axis) so individual sheets are visible even when multiple sheets span the width.&lt;/p&gt;
&lt;h3 id="the-transition-facts-sticker"&gt;The Transition Facts Sticker&lt;/h3&gt;
&lt;p&gt;A small detail: the 3D model includes a procedurally generated &amp;ldquo;sticker&amp;rdquo; on the near side template, about 40% up the arc. It displays the ramp&amp;rsquo;s key specs — height, radius, width, deck depth, surface material, coping presence — and an &amp;ldquo;Overall Vibe&amp;rdquo; rating based on the combination of specs.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s whimsical, but it serves a purpose: it gives the 3D model a sense of personality and makes screenshots immediately informative when shared with friends or posted for feedback. And it is generated using another fun tool called &lt;a href="https://stalefishlabs.com/experiments/transition-facts/"
target="_blank"
&gt;Transition Facts&lt;/a&gt;. Try it out!&lt;/p&gt;
&lt;h2 id="what-was-learned"&gt;What Was Learned&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Lumber math is surprisingly hard.&lt;/strong&gt; The gap between theoretical geometry and real-world lumber is where most ramp builds go wrong. You can calculate a perfect radius in a spreadsheet, but if you don&amp;rsquo;t account for lumber length standards, sheet goods sizes, and realistic fastener quantities, you&amp;rsquo;ll make three trips to the hardware store instead of one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Exploded views teach better than assembly diagrams.&lt;/strong&gt; Static diagrams show you what a ramp looks like. Exploded views show you how it goes together. The slider interaction — dragging layers apart and watching them fan out — builds spatial understanding faster than any set of written instructions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PDF export matters more than expected.&lt;/strong&gt; I added PDF export as an afterthought. It turned out to be the most-requested feature in early testing. People want to take their ramp design to the lumber yard, and a phone-friendly PDF with the 3D view and materials list is exactly the format that works.&lt;/p&gt;
&lt;h2 id="try-it"&gt;Try It&lt;/h2&gt;
&lt;p&gt;The &lt;a href="https://stalefishlabs.com/experiments/ramp-designer/"
&gt;Ramp Designer&lt;/a&gt; runs entirely in your browser. No account, no install, no tracking. Choose quarterpipe or half pipe, set your dimensions, and start designing.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The &lt;a href="https://stalefishlabs.com/experiments/ramp-designer/"
&gt;Ramp Designer&lt;/a&gt; is a free experiment from &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/screenshot-2026-04-15-at-2.51.17-pm.png" type="image/png" length="0"/></item><item><title>The Group Chat Had It Right: Why I Un-Fixed Visible Picks</title><link>https://stalefishlabs.com/read/2026-03-31-the-group-chat-had-it-right/</link><pubDate>Tue, 31 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-03-31-the-group-chat-had-it-right/</guid><description>I hid fantasy picks because that's what apps do. Our group chat game never did, and it was better for it.</description><content:encoded>&lt;p&gt;In a &lt;a href="https://stalefishlabs.com/read/2026-03-06-from-group-chat-to-app/"
&gt;previous article&lt;/a&gt;, I wrote about how moving our F1 fantasy game from a text thread to an app unlocked pick categories that were too tedious to score by hand. The Overtaker pick replaced Fastest Lap in the app, and made the game better by doing things the group chat couldn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;This article is about the opposite: a case where building the app made me &lt;em&gt;worse&lt;/em&gt; at game design, because I reflexively added a restriction that the group chat never needed. It was a lesson in not always doing things just because you can.&lt;/p&gt;
&lt;h2 id="how-it-worked-in-the-text-thread"&gt;How it worked in the text thread&lt;/h2&gt;
&lt;p&gt;Our original game was dead simple. A group of friends who enjoy watching Formula One making lightweight fantasy picks for each race. It was all managed in a text thread and in Notes on my phone. You texted your picks to the group before the race started. Everyone saw everyone&amp;rsquo;s picks the moment they hit the chat. You could change your mind as many times as you wanted, right up until the formation lap. Nobody really tracked or enforced a hard deadline because the gentleman&amp;rsquo;s agreement was enough.&lt;/p&gt;
&lt;p&gt;And here&amp;rsquo;s the thing we mostly took for granted but everyone enjoyed: you could see what your rivals picked. If you were trailing in the standings and neck-and-neck with someone, you might deliberately wait to see what they picked, and then pick a different driver for the podium to create a scoring differential. If you were leading, you might mirror a rival&amp;rsquo;s picks to protect your gap. It wasn&amp;rsquo;t chess, but it was a real layer of strategy on top of &amp;ldquo;who do you think finishes on the podium.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Yet during several years of playing this way, almost nobody actually changed their picks strategically after submitting. The option was always there, but people mostly picked and moved on. What I learned the hard way is the strategic value wasn&amp;rsquo;t in last-second switching. It was in the &lt;em&gt;information&lt;/em&gt;: knowing what you were up against.&lt;/p&gt;
&lt;h2 id="what-i-did-when-i-built-the-app"&gt;What I did when I built the app&lt;/h2&gt;
&lt;p&gt;When I sat down to build Open Wheelers the app, I did what felt obvious: hide everyone&amp;rsquo;s picks until they lock. Blind picks. You submit yours, you see a checkmark next to your leaguemates&amp;rsquo; names confirming they&amp;rsquo;ve picked, but you don&amp;rsquo;t see &lt;em&gt;what&lt;/em&gt; they picked until the window closes and picks lock (the race start). Standard fantasy app behavior. The app suddenly gave me the power to enforce a new rule (blind picks), so why not do it?&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t even really think about it too deeply. Every fantasy platform I&amp;rsquo;d ever used worked this way. Draft picks are secret. Lineup changes are private. Why would you show your poker hand a moment sooner than required? The whole model assumes that seeing someone else&amp;rsquo;s choices gives you an unfair advantage, so the system prevents it. It&amp;rsquo;s such a default assumption that I never questioned whether it was actually right for &lt;em&gt;this&lt;/em&gt; game. And this is even given the knowledge that I deliberately set out to build a contrarian fantasy game!&lt;/p&gt;
&lt;h2 id="what-i-lost"&gt;What I lost&lt;/h2&gt;
&lt;p&gt;During initial testing of the appified game, something felt off. The picks phase was&amp;hellip; quiet. You&amp;rsquo;d open the app, make your picks, close it, and wait. There was nothing to talk about until the race started and picks were revealed. The group text thread used to buzz with reactions to each other&amp;rsquo;s picks. Hot takes. Trash talk. &amp;ldquo;Lewis is struggling with Ferrari but you just can&amp;rsquo;t quit him, eh?&amp;rdquo; That energy was gone, replaced by a sterile, sealed-envelope experience.&lt;/p&gt;
&lt;p&gt;Worse, I&amp;rsquo;d killed the strategic layer entirely. You couldn&amp;rsquo;t differentiate from a rival because you didn&amp;rsquo;t know what they picked. You couldn&amp;rsquo;t mirror someone to protect a lead because their picks were invisible. Every decision was made in a vacuum, which sounds fair but actually just makes the game shallower. Pure prediction skill is fine, but prediction skill plus situational awareness is more interesting. Indeed, sometimes ultimate fairness loses out to quality of life.&lt;/p&gt;
&lt;p&gt;The irony is that I&amp;rsquo;d written a whole article about how the app should unlock &lt;em&gt;more&lt;/em&gt; depth, not less. And here I was, having voluntarily removed a dimension of gameplay that the text thread gave us for free. Indeed, sometimes less is more.&lt;/p&gt;
&lt;h2 id="the-because-i-could-trap"&gt;The &amp;ldquo;because I could&amp;rdquo; trap&lt;/h2&gt;
&lt;p&gt;This is the trap, and I think it&amp;rsquo;s common in software: when you move something analog to digital, you inherit assumptions from existing digital products instead of examining what actually worked about the original. I looked at other fantasy apps and copied their pick visibility model without asking whether it fit our game. Which is admittedly funny because up to this point I literally looked at nothing else in fantasy F1 apps because I wanted Open Wheelers to be its own unique thing.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://stalefishlabs.com/read/2026-03-31-the-group-chat-had-it-right/ao3xru.jpg" alt="“Because I could” is a trap!" title="\&amp;#34;Because I could\&amp;#34; is a trap!"&gt;&lt;/p&gt;
&lt;p&gt;The group chat didn&amp;rsquo;t hide picks because it was good game design, it literally &lt;em&gt;couldn&amp;rsquo;t&lt;/em&gt;. Messages are visible to everyone. But that constraint turned out to be a feature. It created a social, strategic experience that I then engineered away because I had the power to build walls that a text thread couldn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;Sometimes &amp;ldquo;because I could&amp;rdquo; is the right answer. The Overtaker category exists because the app &lt;em&gt;can&lt;/em&gt; compute grid-to-finish deltas automatically. That&amp;rsquo;s a genuine improvement, and the gains were felt immediately. But hiding picks? That was me solving a problem that didn&amp;rsquo;t exist, importing a convention from games with different dynamics, and making the experience worse in the process. I went with effectively the software default when in reality the low-tech constraint was a winner all along.&lt;/p&gt;
&lt;h2 id="what-i-changed"&gt;What I changed&lt;/h2&gt;
&lt;p&gt;The fix was almost embarrassingly simple. Picks are now visible to your leaguemates the moment they&amp;rsquo;re submitted. You can still change yours freely until they lock at the start of the race, just like the text thread. No countdown timers, no change limits, no special mechanics. Just open information and the freedom to act on it.&lt;/p&gt;
&lt;p&gt;Will some people wait to see what others pick before committing? Maybe. Will there be occasional last-minute switches and skulduggery? Probably. But years of running the text thread game proved that this mostly doesn&amp;rsquo;t happen, and when it does, it&amp;rsquo;s &lt;em&gt;fun&lt;/em&gt;. It&amp;rsquo;s a feature, not a bug. &amp;ldquo;Did you see that she switched her podium pick 30 seconds before lights out?&amp;rdquo; is exactly the kind of story a fantasy game should generate.&lt;/p&gt;
&lt;h2 id="the-design-principle"&gt;The design principle&lt;/h2&gt;
&lt;p&gt;Last time, with the Overtaker change, the lesson was that the best mechanics are often the ones too complicated to run by hand. This time it&amp;rsquo;s the complement: &lt;strong&gt;not every analog constraint is a problem to solve&lt;/strong&gt;. Some constraints are load-bearing walls disguised as limitations.&lt;/p&gt;
&lt;p&gt;When you&amp;rsquo;re digitizing a game, or really any experience, the hard part isn&amp;rsquo;t adding capabilities. It&amp;rsquo;s knowing which rough edges to preserve. The text thread&amp;rsquo;s visible picks felt like a limitation of the medium. They were actually a core part of the game.&lt;/p&gt;
&lt;p&gt;Build the features that were impossible before. But before you &amp;ldquo;fix&amp;rdquo; something that wasn&amp;rsquo;t broken, go back and play the original. The group chat might know something you forgot. Box, box.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;&lt;a href="https://stalefishlabs.com/use/openwheelers/"
target="_blank"
&gt;Open Wheelers&lt;/a&gt; is a casual F1 and IndyCar fantasy game for friends, built by &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/f1messages.png" type="image/png" length="0"/></item><item><title>Building Confidence Into Uncertain Verdicts</title><link>https://stalefishlabs.com/read/2026-03-27-uncertain-verdicts/</link><pubDate>Fri, 27 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-03-27-uncertain-verdicts/</guid><description>Why three states beat a percentage, and how a recovery outlook answers the real question: when will it be good?</description><content:encoded>&lt;p&gt;This is the final article in the &lt;a href="https://stalefishlabs.com/read/2026-03-12-weather-engine-intro/"
&gt;Building a Weather Decision Engine&lt;/a&gt; series. The previous articles covered the &lt;a href="https://stalefishlabs.com/read/2026-03-17-drying-model/"
&gt;drying model&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/read/2026-03-20-one-engine-three-apps/"
&gt;multi-app architecture&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/read/2026-03-23-edge-cases/"
&gt;edge cases&lt;/a&gt;, and the &lt;a href="https://stalefishlabs.com/read/2026-03-25-flipping-the-question/"
&gt;Yardwise inversion&lt;/a&gt;. This last one is about the output side, how the engine communicates decisions to people who just want to know if they should go ride.&lt;/p&gt;
&lt;h2 id="why-not-a-percentage"&gt;Why Not a Percentage?&lt;/h2&gt;
&lt;p&gt;The first version of the engine returned a moisture percentage. Users hated it. To be honest I kinda hated it too but it was the first logical thing to represent as meaningful output.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;Your trail is at 47% moisture&amp;rdquo; sounds precise. But what do you do with that? Is 47% rideable? It depends on the surface, your tolerance for mud, whether you care about trail damage, and how far you&amp;rsquo;re willing to drive for a &amp;ldquo;maybe.&amp;rdquo; The percentage outsources the decision to the user, which is the entire thing the app was supposed to handle. Besides, I have no idea what percentage tips me one way or the other toward making a real life ride decision.&lt;/p&gt;
&lt;p&gt;A percentage also implies false precision. The engine&amp;rsquo;s moisture model is a reasonable approximation, not a soil sensor reading. Presenting a number with two significant digits suggests an accuracy that doesn&amp;rsquo;t exist, so it&amp;rsquo;s misleading. The difference between 47% and 49% is noise, not signal.&lt;/p&gt;
&lt;p&gt;Three states (&lt;strong&gt;Yes&lt;/strong&gt;, &lt;strong&gt;Maybe&lt;/strong&gt;, &lt;strong&gt;No&lt;/strong&gt;) work because they match how people actually think about the decision. You&amp;rsquo;re either going (Yes), not going (No), or weighing it (Maybe). The engine&amp;rsquo;s job is to put you in the right decision bucket, not to give you a homework problem.&lt;/p&gt;
&lt;h2 id="the-maybe-state-is-the-product"&gt;The Maybe State Is the Product&lt;/h2&gt;
&lt;p&gt;The Yes and No verdicts are straightforward. The real design challenge is Maybe.&lt;/p&gt;
&lt;p&gt;Maybe exists for conditions where reasonable people would disagree. A wetness score of 0.4 on a dirt trail means it&amp;rsquo;s damp but not muddy. Some riders would go. Others would wait. A veteran on a hardtail who likes playing the drift might like the extra challenge of some surprise slip here and there. A flowier rider on a full-suspension with less tire clearance might not want to bother and risk the occasional wet low spot. There&amp;rsquo;s also some variance in trails opening and closing based on conditions, which is another facet of where local knowledge would tip a Maybe verdict one way or the other.&lt;/p&gt;
&lt;p&gt;The engine can&amp;rsquo;t make that call for you. What it &lt;em&gt;can&lt;/em&gt; do is tell you &lt;strong&gt;why&lt;/strong&gt; conditions are borderline, so you can apply your own judgment.&lt;/p&gt;
&lt;h2 id="rationale-the-why-behind-the-verdict"&gt;Rationale: The Why Behind the Verdict&lt;/h2&gt;
&lt;p&gt;Every assessment includes a structured rationale:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;GroundwiseRationale&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;headline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HeadlineReason&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;decisiveFactor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DecisiveFactor&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;headlineContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HeadlineContext&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DetailReason&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RationaleValues&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;strong&gt;headline&lt;/strong&gt; is the one-line summary: &amp;ldquo;Light rain still drying&amp;rdquo; or &amp;ldquo;Strong drying clearing moisture&amp;rdquo; or &amp;ldquo;Frozen conditions — ice hazard.&amp;rdquo; It&amp;rsquo;s what the user reads first.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;decisive factor&lt;/strong&gt; identifies the single most important reason for the verdict. This is the tie-breaker — the one variable that, if changed, would flip the result. &amp;ldquo;Recent heavy rain&amp;rdquo; or &amp;ldquo;weak drying conditions&amp;rdquo; or &amp;ldquo;surface sensitivity.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;details&lt;/strong&gt; array provides up to six contributing factors, each describing how a specific weather element is affecting conditions, either positively or negatively. Temperature is helping. Humidity is slowing things down. Wind is moderate. These aren&amp;rsquo;t ranked by importance, they&amp;rsquo;re presented as a set of forces that the user can scan to build their own picture, with the verdict-supporting forces appearing first.&lt;/p&gt;
&lt;p&gt;The engine deliberately avoids showing the raw numbers in the rationale. Users don&amp;rsquo;t need to know that drying strength is 0.53 or that the wetness score is 0.38. They need to know &amp;ldquo;drying conditions are moderate — warm but humid.&amp;rdquo; The rationale translates numbers into plain language.&lt;/p&gt;
&lt;h2 id="confidence-admitting-what-you-dont-know"&gt;Confidence: Admitting What You Don&amp;rsquo;t Know&lt;/h2&gt;
&lt;p&gt;Each verdict has a confidence level — Low, Medium, or High. Don&amp;rsquo;t forget that we&amp;rsquo;re highly dependent on the weather source, and can&amp;rsquo;t control the occasional hiccup where a piece of key data is missing. That&amp;rsquo;s where Confidence enters the picture, and it starts at High and gets reduced by specific factors:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Missing timing data → Low confidence.&lt;/strong&gt; If the engine doesn&amp;rsquo;t know when rain ended (the &lt;code&gt;minutesSinceRainEnded&lt;/code&gt; value is nil), it can&amp;rsquo;t model the timing decay that&amp;rsquo;s central to the wetness calculation. It defaults to a 0.5 timing score (assume moderate concern) and drops confidence to Low. The verdict might be right, but the engine is guessing about a critical input.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Patchy rain → Medium confidence.&lt;/strong&gt; When the weather source indicates precipitation has been spotty and inconsistent (&lt;code&gt;patchyRainLikely&lt;/code&gt;), the actual conditions at the user&amp;rsquo;s spot might differ from what the nearest weather station recorded. The engine can&amp;rsquo;t know whether the rain hit your trail or the parking lot a mile away. These are unknown unknowns, well maybe they&amp;rsquo;re known unknowns&amp;hellip;either way we don&amp;rsquo;t know!&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Near-threshold conditions → Medium confidence.&lt;/strong&gt; If the wetness score lands within 0.05 of a verdict boundary (0.25-0.35 near the Yes/Maybe line, or 0.55-0.65 near the Maybe/No line), the engine reduces confidence because a small change in any input could flip the result. This is the engine saying &amp;ldquo;I&amp;rsquo;m calling it Maybe, but it could easily be Yes.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The confidence level affects how the UI presents the verdict. A High-confidence No is &amp;ldquo;Don&amp;rsquo;t ride — conditions are poor.&amp;rdquo; A Low-confidence Maybe is &amp;ldquo;Conditions are uncertain — check conditions on the ground.&amp;rdquo; Same verdict structure, different emphasis.&lt;/p&gt;
&lt;h3 id="what-i-didnt-do-with-confidence"&gt;What I Didn&amp;rsquo;t Do With Confidence&lt;/h3&gt;
&lt;p&gt;I considered making confidence a continuous value (0-1) or adding more levels. I didn&amp;rsquo;t, for the same reason I use three verdict states instead of a percentage: more granularity creates more decisions for the user without adding useful information. The whole point here is to simplify decision making.&lt;/p&gt;
&lt;p&gt;The three levels map to three communication strategies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;High:&lt;/strong&gt; Trust the verdict&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Medium:&lt;/strong&gt; The verdict is our best call, but conditions might surprise you&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Low:&lt;/strong&gt; We&amp;rsquo;re short on data, verify on the ground&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s enough to calibrate expectations without overwhelming.&lt;/p&gt;
&lt;h2 id="recovery-outlook-so-when-will-it-be-good"&gt;Recovery Outlook: &amp;ldquo;So When Will It Be Good?&amp;rdquo;&lt;/h2&gt;
&lt;p&gt;The natural follow-up to a No or Maybe verdict is &amp;ldquo;when will conditions improve?&amp;rdquo; I was initially skeptical about including this but it has turned out to be extremely valuable because as is often the case, I&amp;rsquo;m actually not looking at the app to ride right this moment, I&amp;rsquo;m typically planning ahead for &amp;ldquo;later today.&amp;rdquo; The engine provides a recovery outlook, a qualitative time estimate, to attempt to answer.&lt;/p&gt;
&lt;p&gt;The outlook is a simple 2D lookup: wetness score vs. drying strength.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Wetness / Drying&lt;/th&gt;
&lt;th&gt;Strong&lt;/th&gt;
&lt;th&gt;Moderate&lt;/th&gt;
&lt;th&gt;Weak&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Low (&amp;lt; 0.4)&lt;/td&gt;
&lt;td&gt;Within hours&lt;/td&gt;
&lt;td&gt;A few hours&lt;/td&gt;
&lt;td&gt;Maybe later today&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium (0.4-0.6)&lt;/td&gt;
&lt;td&gt;A few hours&lt;/td&gt;
&lt;td&gt;Later today&lt;/td&gt;
&lt;td&gt;Maybe later today&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High (≥ 0.6)&lt;/td&gt;
&lt;td&gt;Later today&lt;/td&gt;
&lt;td&gt;Maybe later today&lt;/td&gt;
&lt;td&gt;Unlikely today&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The categories are deliberately vague. &amp;ldquo;Within hours&amp;rdquo; means 1-2 hours. &amp;ldquo;Later today&amp;rdquo; means afternoon or evening. &amp;ldquo;Unlikely today&amp;rdquo; means tomorrow at the earliest. The engine doesn&amp;rsquo;t say &amp;ldquo;rideable at 2:47 PM&amp;rdquo; because that precision doesn&amp;rsquo;t exist in the model and it would be ridiculous to pretend that level of accuracy.&lt;/p&gt;
&lt;p&gt;The &amp;ldquo;maybe later today&amp;rdquo; bucket is the uncertainty hedge — conditions might improve, but the engine isn&amp;rsquo;t confident enough to commit. It&amp;rsquo;s the recovery equivalent of the Maybe verdict.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s also a &amp;ldquo;may worsen&amp;rdquo; outlook for cases where the forecast indicates more precipitation. If the next few hours include rain, the engine won&amp;rsquo;t tell you things are improving, even if current drying conditions are strong. This prevents the frustrating experience of waiting for conditions to improve only to get rained on again. And this is fairly common on days where I&amp;rsquo;m pretty sure the trail is day right now but less sure of how the afternoon is going to go. In this regard, the engine isn&amp;rsquo;t just a past weather + surface conditions evaluator, it&amp;rsquo;s also taking a peek into the future (forecast) to give you a sense of if things are trending better, worse, or more of the same.&lt;/p&gt;
&lt;h2 id="risk-categories-more-than-just-wet"&gt;Risk Categories: More Than Just &amp;ldquo;Wet&amp;rdquo;&lt;/h2&gt;
&lt;p&gt;It occurred to me fairly quickly when building Ridewise that what we&amp;rsquo;re dealing with here isn&amp;rsquo;t just the ability to ride, as in muddy or not muddy, but also the risk implications. I&amp;rsquo;m using risk here as a two-way term depending on activity: risk to the user and in some cases risk to the surface. For a concrete skatepark, it&amp;rsquo;s entirely user risk, but for a mountain bike trail or sod soccer field, it&amp;rsquo;s risk to the surface. Actually the mountain bike example might cut both ways but you get the idea. And there&amp;rsquo;s the quality of the activity that I decided was also effectively a risk. So beyond the binary wet/dry question, the engine evaluates three distinct risk categories:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Surface damage&lt;/strong&gt; — Will using this surface in current conditions cause lasting harm? This matters most for natural grass athletic fields (0.9 sensitivity), newly seeded lawns (up to 1.0 with establishment boost), and clay courts (0.85). It matters least for artificial turf (0.05), metal (0.05), and compost (0.3).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Activity safety&lt;/strong&gt; — Is the surface dangerous to use? Wet metal has the highest safety sensitivity (0.95) because it becomes extremely slippery. Composite ramp surfaces like Skatelite and Ramp Armor are also high (0.85), along with metal and concrete. Artificial turf and potting mix are low (0.4) because their textures maintain grip even when wet. Indeed nobody skates on potting mix, but this illustrates how one engine can serve three different apps if you&amp;rsquo;re very careful about the design.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Activity quality&lt;/strong&gt; — Even if it&amp;rsquo;s safe, will the experience be poor? Mud on a dirt trail (0.7 quality sensitivity) makes for a miserable ride. A damp skatepark (0.6) is more debatable, and can range from totally fine (squeegee the ramp and it may dry out) to a complete no-go.&lt;/p&gt;
&lt;p&gt;These three risks are calculated independently and presented alongside the verdict. A Maybe verdict might come with low safety risk but high damage risk — meaning &amp;ldquo;you&amp;rsquo;d be fine riding, but you&amp;rsquo;d rut up the trail.&amp;rdquo; That distinction helps the user make an informed call.&lt;/p&gt;
&lt;h2 id="putting-it-all-together"&gt;Putting It All Together&lt;/h2&gt;
&lt;p&gt;The full assessment output:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;GroundwiseAssessment&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt; &lt;span class="c1"&gt;// Yes / Maybe / No&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Confidence&lt;/span&gt; &lt;span class="c1"&gt;// Low / Medium / High&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rationale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;GroundwiseRationale&lt;/span&gt; &lt;span class="c1"&gt;// Why&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;riskSurfaceDamage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RiskLevel&lt;/span&gt; &lt;span class="c1"&gt;// Harm to surface&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;riskActivitySafety&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RiskLevel&lt;/span&gt; &lt;span class="c1"&gt;// Danger to user&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;riskActivityQuality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RiskLevel&lt;/span&gt;&lt;span class="c1"&gt;// Experience quality&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;recoveryOutlook&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RecoveryOutlook&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="c1"&gt;// When will it improve&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The verdict is the headline. Confidence tells you how much to trust it. The rationale explains why. Risk levels add nuance. Recovery outlook answers &amp;ldquo;when?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;None of these fields exist in isolation. Together, they give the user a complete picture in a few seconds of scanning — enough information to make a confident decision without reading a weather report.&lt;/p&gt;
&lt;h2 id="the-design-principle"&gt;The Design Principle&lt;/h2&gt;
&lt;p&gt;The engine&amp;rsquo;s output design follows one principle: &lt;strong&gt;make the decision for the user, then show your work.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Most users will see the verdict color, read the headline, and decide. That&amp;rsquo;s the 80% case, and it should take three seconds. The rationale, risk levels, and recovery outlook are there for the 20% who want to understand why, or who are in a borderline situation where the details matter. And yes, I want wildly overboard on the proving your work part because I wanted it to be crystal clear that this isn&amp;rsquo;t a pretty veneer over what is essentially a weather app — the Groundwise engine is doing sophisticated modeling in service of a &amp;ldquo;simple&amp;rdquo; yet nuanced and challenging question about rideability/playability/vulnerability (plant vulnerability in Yardwise).&lt;/p&gt;
&lt;p&gt;This is the opposite of how most weather apps work. They give you all the data and expect you to synthesize it into a decision. The Groundwise engine synthesizes first, then offers the data for verification. The user&amp;rsquo;s default is to trust the verdict and act on it. The detail is available but not required.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s what made three states better than a percentage. A percentage demands interpretation. A verdict offers a recommendation. The supporting detail is opt-in, not mandatory.&lt;/p&gt;
&lt;h2 id="thanks-for-reading"&gt;Thanks for Reading&lt;/h2&gt;
&lt;p&gt;This series covered a lot of ground, from the &lt;a href="https://stalefishlabs.com/read/2026-03-12-weather-engine-intro/"
&gt;core concept&lt;/a&gt; through the &lt;a href="https://stalefishlabs.com/read/2026-03-17-drying-model/"
&gt;drying math&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/read/2026-03-20-one-engine-three-apps/"
&gt;multi-app architecture&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/read/2026-03-23-edge-cases/"
&gt;winter edge cases&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/read/2026-03-25-flipping-the-question/"
&gt;the Yardwise inversion&lt;/a&gt;, and now the verdict UX. If you&amp;rsquo;ve built something that turns noisy data into human decisions, I&amp;rsquo;d love to hear about your approach. The threshold and confidence problems are universal.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The Groundwise engine powers &lt;a href="https://stalefishlabs.com/use/ridewise/"
target="_blank"
&gt;Ridewise&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/use/fieldwise/"
target="_blank"
&gt;Fieldwise&lt;/a&gt;, and &lt;a href="https://stalefishlabs.com/use/yardwise/"
target="_blank"
&gt;Yardwise&lt;/a&gt; — all available for iOS from &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;. Find us on &lt;a href="https://bsky.app/profile/stalefishlabs.bsky.social"
target="_blank"
&gt;Bluesky&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/percentagenomaybeyes.png" type="image/png" length="0"/></item><item><title>Flipping the Question: From 'Is It Too Wet?' to 'Is It Too Dry?'</title><link>https://stalefishlabs.com/read/2026-03-25-flipping-the-question/</link><pubDate>Wed, 25 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-03-25-flipping-the-question/</guid><description>The same engine that tells riders to stay home tells gardeners to grab the hose. Watering is its own problem.</description><content:encoded>&lt;p&gt;When I first built the Groundwise engine for the &lt;a href="https://stalefishlabs.com/use/ridewise/"
&gt;Ridewise app&lt;/a&gt;, it answered one question: &lt;strong&gt;is it too wet to ride?&lt;/strong&gt; Low wetness meant good conditions. High wetness meant stay home. Exactly what I needed for mountain biking, skateboarding, and other outdoor wheeled activities where surface conditions impacted by weather mattered.&lt;/p&gt;
&lt;p&gt;Then I started thinking about my garden.&lt;/p&gt;
&lt;p&gt;I have raised beds, a few containers on the patio, and a lawn that I&amp;rsquo;d like to keep alive without drowning it. I don&amp;rsquo;t get obsessive about it, and to be honest my lawn is far from impressive. I&amp;rsquo;m not one of those people who try to maintain golf course grass, far from it. But I&amp;rsquo;ve still regularly done that assessment where I look at the sky, vaguely remember whether it rained, and make the same kind of gut call on watering that I used to make about trails. The variables were familiar: recent rain, temperature, sun exposure, how fast things dry. I was running the same mental model, just asking the opposite question.&lt;/p&gt;
&lt;p&gt;That realization is what turned one app into three. If the engine could quantify moisture on a surface, it could answer both &amp;ldquo;is it too wet to ride?&amp;rdquo; and &amp;ldquo;is it too dry, should I water?&amp;rdquo;&lt;/p&gt;
&lt;h2 id="the-inversion"&gt;The Inversion&lt;/h2&gt;
&lt;p&gt;The engine always calculates a wetness score from 0 (bone dry) to 1 (saturated). The &lt;code&gt;EngineMode&lt;/code&gt; enum controls what that score means:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Wetness concern&lt;/strong&gt; (Ridewise, Fieldwise): Low wetness = Yes (go ride or play). High wetness = No (too wet).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dryness concern&lt;/strong&gt; (Yardwise): Low wetness = Yes (water today). High wetness = No (skip watering).&lt;/p&gt;
&lt;p&gt;The verdict is the same enum, &lt;code&gt;Yes&lt;/code&gt;, &lt;code&gt;Maybe&lt;/code&gt;, &lt;code&gt;No&lt;/code&gt;, but the interpretation flips. This means the consuming app doesn&amp;rsquo;t need to know which mode produced the result. It renders green for Yes, orange for Maybe, red for No, regardless.&lt;/p&gt;
&lt;p&gt;The thresholds shift too:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Yes&lt;/th&gt;
&lt;th&gt;Maybe&lt;/th&gt;
&lt;th&gt;No&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Wetness concern&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 0.30&lt;/td&gt;
&lt;td&gt;0.30 – 0.60&lt;/td&gt;
&lt;td&gt;≥ 0.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dryness concern&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 0.15&lt;/td&gt;
&lt;td&gt;0.15 – 0.45&lt;/td&gt;
&lt;td&gt;≥ 0.45&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Yardwise thresholds are lower because the stakes are asymmetric. This wasn&amp;rsquo;t immediately obvious until testing early versions of the app, when I realized that missing a watering day rarely if ever harms established plants. Riding a muddy trail damages the surface. So Yardwise is more lenient than the other apps, and leans toward &amp;ldquo;maybe check the soil&amp;rdquo; rather than &amp;ldquo;definitely water&amp;rdquo; — this makes the verdict less alarming. The reality is that the cost of a false positive (unnecessary watering) is real (overwatering causes its own problems), while the cost of a false negative (skipping one day) is usually trivial.&lt;/p&gt;
&lt;h2 id="manual-watering-tracking-what-the-weather-doesnt-know"&gt;Manual Watering: Tracking What the Weather Doesn&amp;rsquo;t Know&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s where Yardwise diverges from Ridewise and Fieldwise most sharply. The weather API knows about rain. It doesn&amp;rsquo;t know that you ran the sprinkler for 20 minutes yesterday evening.&lt;/p&gt;
&lt;p&gt;Yardwise lets users log manual watering events with timestamps. The engine converts these into precipitation equivalents that then get added to the rain score alongside actual precipitation, with the same time-weighted decay:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;0-24 hours ago: 0.7x weight (70% contribution)
24-48 hours ago: 0.2x weight
48-72 hours ago: 0.1x weight
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;A watering yesterday contributes about 0.175 inches to the effective precipitation (0.25 × 0.7). That&amp;rsquo;s enough to shift a borderline &amp;ldquo;water today&amp;rdquo; verdict to &amp;ldquo;check soil&amp;rdquo; or even &amp;ldquo;skip.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The decay is important. A watering three days ago shouldn&amp;rsquo;t prevent the engine from recommending water today, the moisture is long gone, especially in containers with fast-draining potting mix. The time-weighted model handles this naturally.&lt;/p&gt;
&lt;h3 id="cold-weather-extends-watering-memory"&gt;Cold Weather Extends Watering Memory&lt;/h3&gt;
&lt;p&gt;One wrinkle: cold weather slows evapotranspiration. A watering event that would normally decay in 48 hours can remain relevant for up to 96 hours during cold weather. The engine extends the effective window based on the same cold factor used for dormancy:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;wateringWindow = 48 + 48 × coldFactor
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;At 34°F, the cold factor is about 0.62, giving a window of roughly 78 hours. At 45°F, it&amp;rsquo;s about 56 hours. This prevents the engine from recommending watering when yesterday&amp;rsquo;s water is still sitting in cold, slowly-evaporating soil. Keep in mind that winter watering is pretty rare, reserved almost exclusively for new plantings (trees, shrubs, etc.) and only during an unusual dry spell.&lt;/p&gt;
&lt;h2 id="establishment-sensitivity-protecting-young-plants"&gt;Establishment Sensitivity: Protecting Young Plants&lt;/h2&gt;
&lt;p&gt;Newly seeded lawns and recently transplanted plants need more water than established ones, and they&amp;rsquo;re far more susceptible to drought. The engine handles this through an establishment sensitivity boost that layers on top of the surface type&amp;rsquo;s base damage sensitivity.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Establishment&lt;/th&gt;
&lt;th&gt;Sensitivity Boost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Established&lt;/td&gt;
&lt;td&gt;+0.0 (default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Newly planted&lt;/td&gt;
&lt;td&gt;+0.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Newly seeded&lt;/td&gt;
&lt;td&gt;+0.5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This boost affects the sensitivity veto, the mechanism that pushes borderline verdicts toward protection. With a newly seeded lawn (base sensitivity 0.4 + boost 0.5 = 0.9 effective sensitivity), a Maybe verdict with wetness below 0.3 gets overridden to Yes: water those seeds.&lt;/p&gt;
&lt;p&gt;For established plants with no boost, the same borderline conditions stay as Maybe: check the soil yourself. The engine doesn&amp;rsquo;t push you to water unless there&amp;rsquo;s a fragile surface that genuinely needs protection.&lt;/p&gt;
&lt;p&gt;This is the same sensitivity veto that Fieldwise uses to protect natural grass athletic fields from being played on when borderline wet. Same mechanism, opposite direction. In wetness-concern mode, high sensitivity + borderline conditions → No (protect from use). In dryness-concern mode, high sensitivity + borderline conditions → Yes (protect from drought).&lt;/p&gt;
&lt;h2 id="dormancy-and-evaporative-demand"&gt;Dormancy and Evaporative Demand&lt;/h2&gt;
&lt;p&gt;Two Yardwise-specific wetness modifiers that were covered in the &lt;a href="https://stalefishlabs.com/read/2026-03-23-edge-cases/"
&gt;edge cases article&lt;/a&gt; deserve a brief recap in the context of the inversion:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cold-weather dormancy&lt;/strong&gt; injects additional wetness into the score when temperatures drop below 55°F. This suppresses watering recommendations by pushing the score toward the &amp;ldquo;skip&amp;rdquo; zone because dormant plants don&amp;rsquo;t benefit from watering and the moisture promotes root rot and fungal growth.&lt;/p&gt;
&lt;p&gt;From the engine&amp;rsquo;s perspective, dormancy is saying: &amp;ldquo;the soil might technically be dry, but the plant doesn&amp;rsquo;t need water right now, so act as if conditions are wetter than they are.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Low evaporative demand&lt;/strong&gt; does something similar on cool, overcast, humid days. Even if the soil surface looks dry, the plant isn&amp;rsquo;t losing much water through transpiration when there&amp;rsquo;s no sun and the air is already saturated. A small wetness boost (up to 0.25) prevents unnecessary watering recommendations.&lt;/p&gt;
&lt;p&gt;Both modifiers only activate in dryness-concern mode. They don&amp;rsquo;t make sense for riders or field sports, cold weather doesn&amp;rsquo;t make a trail less muddy, and overcast skies don&amp;rsquo;t affect whether the ground is rideable or a football field is playable.&lt;/p&gt;
&lt;h2 id="yardwise-surface-types"&gt;Yardwise Surface Types&lt;/h2&gt;
&lt;p&gt;The Groundwise family shares a surface type system, but Yardwise introduces types that don&amp;rsquo;t exist in the riding or sports world:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Potting mix&lt;/strong&gt; (containers) dries at 2.0x — the same rate as concrete. This isn&amp;rsquo;t a coincidence. Potting mix is engineered for drainage, just like concrete. Containers are also typically exposed to wind on all sides, which accelerates drying considerably. The result: containers often need daily watering in summer, even after rain. The engine captures this naturally because the high drying multiplier clears moisture quickly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Amended soil with mulch&lt;/strong&gt; dries at 0.7x — the slowest rate in the system, tied with clay. Mulch deliberately slows evaporation. A mulched garden bed after rain stays moist for days. The engine&amp;rsquo;s residual wetness model (which only activates for absorbent surfaces above 0.25 inches of rain) extends this further, keeping the score in &amp;ldquo;skip watering&amp;rdquo; territory well after a good rain.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fresh seed&lt;/strong&gt; has the highest effective damage sensitivity in the system: base 0.8 plus an establishment boost of 0.5, capped at 1.0. The engine is maximally protective of newly seeded areas, pushing any borderline verdict toward &amp;ldquo;water.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Compost&lt;/strong&gt; is the most forgiving surface: 1.2x drying multiplier and only 0.3 damage sensitivity. Compost generates internal heat from decomposition, which drives some of its own drying. And it&amp;rsquo;s hard to damage, overwatering a compost pile isn&amp;rsquo;t really a concern. The engine mostly stays out of the way for compost, skewing toward &amp;ldquo;skip&amp;rdquo; unless conditions are genuinely dry.&lt;/p&gt;
&lt;h2 id="the-insight-that-made-it-work"&gt;The Insight That Made It Work&lt;/h2&gt;
&lt;p&gt;The hardest part of building Yardwise wasn&amp;rsquo;t the code, it was convincing myself that the inversion was valid. Every instinct I&amp;rsquo;d built while developing Ridewise and Fieldwise said &amp;ldquo;wet = bad.&amp;rdquo; Retraining my thinking to &amp;ldquo;wet = good (for gardening)&amp;rdquo; took effort.&lt;/p&gt;
&lt;p&gt;What made it click was realizing that the engine isn&amp;rsquo;t really about wet or dry. It&amp;rsquo;s about &lt;strong&gt;moisture state relative to the ideal for a given surface and activity.&lt;/strong&gt; For a trail, the ideal is dry. For a garden bed, the ideal is moist (not soaked!). The engine measures distance from ideal in both directions.&lt;/p&gt;
&lt;p&gt;Once I saw it that way, the inversion was mechanical. Same measurements, same model, same pipeline. Just a different definition of &amp;ldquo;good.&amp;rdquo;&lt;/p&gt;
&lt;h2 id="next-in-the-series"&gt;Next in the Series&lt;/h2&gt;
&lt;p&gt;The &lt;a href="https://stalefishlabs.com/read/2026-03-27-uncertain-verdicts/"
&gt;final article&lt;/a&gt; covers the verdict UX, why three states beat a percentage, how the engine communicates uncertainty through confidence levels and contributing factors, and the role of the recovery outlook in managing expectations.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;&lt;a href="https://stalefishlabs.com/use/yardwise/"
target="_blank"
&gt;Yardwise&lt;/a&gt; is available for iOS from &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;. Same engine as &lt;a href="https://stalefishlabs.com/use/ridewise/"
target="_blank"
&gt;Ridewise&lt;/a&gt; and &lt;a href="https://stalefishlabs.com/use/fieldwise/"
target="_blank"
&gt;Fieldwise&lt;/a&gt;, different question.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/wetdryyard.png" type="image/png" length="0"/></item><item><title>Edge Cases That Break Your Weather Model</title><link>https://stalefishlabs.com/read/2026-03-23-edge-cases/</link><pubDate>Mon, 23 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-03-23-edge-cases/</guid><description>Freeze-thaw, snow melt, rain intensity, plant dormancy — the real-world complexity simple weather models miss.</description><content:encoded>&lt;p&gt;This is a continuation of a deep-dive into the weather engine that drives our Groundwise apps. Last we looked into the core &lt;a href="https://stalefishlabs.com/read/2026-03-17-drying-model/"
&gt;drying model&lt;/a&gt;, which handles most days well. Sun, wind, time, surface type — combine them sensibly and you get a reasonable verdict most of the time.&lt;/p&gt;
&lt;p&gt;Then winter arrives and everything breaks. Interestingly, the engine was developed during the epic ice storm of 2026 that absolutely decimated parts of the southeastern U.S., including Nashville, TN where we&amp;rsquo;re based. So I got to experience first-hand a very real edge case as I was building the engine, and literally each day presented new challenges to clarify for example how long it actually takes a trail to recover from a prolonged frozen period.&lt;/p&gt;
&lt;p&gt;This article covers the edge cases that forced the most complex logic in the Groundwise engine, the scenarios where the simple model produces confidently wrong answers.&lt;/p&gt;
&lt;h2 id="freezethaw-360-lines-of-humility"&gt;Freeze/Thaw: 360 Lines of Humility&lt;/h2&gt;
&lt;p&gt;The frozen conditions check is the single largest block of code in the engine. It runs first, before any moisture modeling, because frozen ground is a fundamentally different hazard than wet ground.&lt;/p&gt;
&lt;p&gt;The naive approach would be simple: if the temperature is below 32°F, say No. But that misses almost everything that matters.&lt;/p&gt;
&lt;h3 id="hard-surfaces-vs-ground-different-failure-modes"&gt;Hard Surfaces vs. Ground: Different Failure Modes&lt;/h3&gt;
&lt;p&gt;A frozen concrete skatepark and a frozen dirt trail are dangerous for completely different reasons, and they recover at completely different speeds.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Concrete and asphalt&lt;/strong&gt; develop ice when moisture freezes on the surface. It&amp;rsquo;s a thin layer — dangerous because it&amp;rsquo;s invisible, but it clears relatively quickly once temperatures rise. At 55°F, a frozen skatepark is typically safe within 6 hours. At 50°F, give it 12. At 45°F, 24 hours, or just wait until 3 days after the last rain when there&amp;rsquo;s simply no moisture left to freeze.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dirt and natural grass&lt;/strong&gt; hold moisture internally. When they freeze, the ice is &lt;em&gt;in&lt;/em&gt; the ground, not just on top of it. Thawing releases that moisture all at once, creating a slurry that&amp;rsquo;s worse than the original wet conditions. A ground trail at 45°F needs 72 hours to fully recover from a freeze — not because ice persists that long, but because the thaw creates its own wetness event. It&amp;rsquo;s worth noting that one thing the engine does not attempt to factor in is the counterintuitive scenario where frozen mountain bike trails are sometimes &lt;em&gt;more&lt;/em&gt; rideable when fully frozen due to the combo of frost heave breaking up the surface tread and then a deep freeze solidifying that oatmeal-like top layer into a grippy crunch. If you bundle up, some of the best winter riding here is when a trail fully heaves and freezes, but that seemed like a special enough case to not factor in (at least not yet!), especially when you factor in ride comfort.&lt;/p&gt;
&lt;p&gt;The engine models the concrete/asphalt vs. dirt/grass freeze issue with separate escape conditions for draining vs. absorbent surfaces:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Temperature&lt;/th&gt;
&lt;th&gt;Hard Surface Recovery&lt;/th&gt;
&lt;th&gt;Ground Recovery&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;≥ 55°F&lt;/td&gt;
&lt;td&gt;6 hours&lt;/td&gt;
&lt;td&gt;24 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;≥ 50°F&lt;/td&gt;
&lt;td&gt;12 hours&lt;/td&gt;
&lt;td&gt;48 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;≥ 45°F&lt;/td&gt;
&lt;td&gt;24 hours (or 3+ days since rain)&lt;/td&gt;
&lt;td&gt;72 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;lt; 45°F&lt;/td&gt;
&lt;td&gt;Still frozen&lt;/td&gt;
&lt;td&gt;Still frozen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id="the-near-freezing-zone"&gt;The Near-Freezing Zone&lt;/h3&gt;
&lt;p&gt;32°F isn&amp;rsquo;t a magic line. Ice can persist on surfaces at 34°F, especially in shade. And moisture can pose a freeze risk at 38°F if temperatures are dropping. Throw in wind and it&amp;rsquo;s pretty evident that a simple 32°F freeze check isn&amp;rsquo;t sufficient.&lt;/p&gt;
&lt;p&gt;The engine treats 32-38°F as a caution zone. If there&amp;rsquo;s been recent precipitation (last 24 hours) and the temperature is in this range, the verdict is No with medium confidence. Not because ice is definitely present, but because the risk isn&amp;rsquo;t worth it.&lt;/p&gt;
&lt;h3 id="overnight-freeze-currently-above-freezing"&gt;Overnight Freeze, Currently Above Freezing&lt;/h3&gt;
&lt;p&gt;This is the most common winter scenario and the hardest to get right, especially in a moderate climate like the southeast where it can freeze overnight and then rise to sunny and 50s during the day. So an overnight freeze that changes to 52°F mid-day&amp;hellip;is the trail rideable?&lt;/p&gt;
&lt;p&gt;It depends on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;How long it&amp;rsquo;s been above freezing&lt;/strong&gt; — just crossed 32°F an hour ago? Still frozen. Been above freezing since 9am and it&amp;rsquo;s now 2pm? Probably thawed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What surface you&amp;rsquo;re on&lt;/strong&gt; — concrete sheds faster than dirt.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Whether there&amp;rsquo;s recent precipitation&lt;/strong&gt; — dry freeze is different from wet freeze.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Humidity&lt;/strong&gt; — above 70% humidity with a recent overnight freeze means possible frost even on surfaces that look dry.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The engine tracks &lt;code&gt;hoursBelowFreezing&lt;/code&gt; and &lt;code&gt;hoursSinceThawBegan&lt;/code&gt; to model this progression. A surface that was frozen for 48 hours takes longer to fully thaw than one that dipped below freezing for 4 hours overnight.&lt;/p&gt;
&lt;h3 id="extended-freeze-with-no-recent-rain"&gt;Extended Freeze With No Recent Rain&lt;/h3&gt;
&lt;p&gt;It gets even trickier when you remove precipitation for a while. Here&amp;rsquo;s a counterintuitive case: it&amp;rsquo;s been below freezing for three days, but it hasn&amp;rsquo;t rained or snowed in a week. Is it safe?&lt;/p&gt;
&lt;p&gt;For &lt;strong&gt;hard surfaces&lt;/strong&gt;: probably yes. No moisture means nothing to freeze. The engine still checks humidity (frost can form from humid air alone), but without recent precipitation, hard surfaces are likely clear.&lt;/p&gt;
&lt;p&gt;For &lt;strong&gt;ground surfaces&lt;/strong&gt;: still no. Even without recent rain, ground retains moisture from weeks of accumulated precipitation. A multi-day freeze locks that moisture in. When thaw comes, it releases.&lt;/p&gt;
&lt;h2 id="snow-melt-a-bell-curve-not-a-line"&gt;Snow Melt: A Bell Curve, Not a Line&lt;/h2&gt;
&lt;p&gt;Snow melt seems simple — snow melts, things get wet, then they dry. But the wetness injection from melting snow follows a curve that&amp;rsquo;s nothing like the linear decay used for rain timing.&lt;/p&gt;
&lt;p&gt;The engine models thaw wetness on a bell curve based on melt progress:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Melt Progress&lt;/th&gt;
&lt;th&gt;Wetness Factor&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&amp;lt; 5%&lt;/td&gt;
&lt;td&gt;0.7 → 1.0&lt;/td&gt;
&lt;td&gt;Starting to melt, moisture building&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5-40%&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;Peak wetness, active melt, ground saturated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;40-70%&lt;/td&gt;
&lt;td&gt;1.0 → 0.4&lt;/td&gt;
&lt;td&gt;Most snow gone, drainage beginning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;70%+&lt;/td&gt;
&lt;td&gt;0.4 → 0.2&lt;/td&gt;
&lt;td&gt;Residual moisture, mostly clear&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Melt progress is estimated from hours since thaw began, with the assumption that most accumulations take about 48 hours to fully melt. Temperature above freezing drives the rate, warmer means faster.&lt;/p&gt;
&lt;p&gt;The peak wetness window (5-40% melted) is deliberately wide. During active melt, the ground is receiving a continuous supply of water. Unlike rain, which dumps water and stops, snow melt is a slow, sustained event that can keep surfaces saturated for hours.&lt;/p&gt;
&lt;p&gt;Snow accumulation matters too. Did you know that on average, 10 inches of snow is equivalent to 1 inch of liquid rain (a 10:1 ratio). However, this ratio varies significantly based on temperature, ranging from 5 inches (wet snow) to over 20 inches (dry, powdery snow) of snow for every 1 inch of liquid. But what we really care about is how much snow results in complete saturation. Here&amp;rsquo;s how the engine scales the initial wetness injection for snow:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;snowFactor = clamp(0.5 + snowAccumulation / 4.0, 0, 1)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Half an inch gives a snowFactor of 0.625. Four inches saturates at 1.0. This prevents a light dusting from being treated the same as a significant snowfall.&lt;/p&gt;
&lt;h3 id="when-theres-no-tracked-snow"&gt;When There&amp;rsquo;s No Tracked Snow&lt;/h3&gt;
&lt;p&gt;Sometimes the engine knows there was a freeze/thaw cycle but doesn&amp;rsquo;t have explicit snow accumulation data. In that case, it falls back to estimating initial moisture from freeze severity:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;initialMoisture = 0.3 + 0.3 × freezeSeverity
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Where freeze severity scales from 0.3 (24 hours below freezing) to 0.6 (4+ days below freezing). Longer freezes lock more ground moisture, producing more wetness when thaw arrives.&lt;/p&gt;
&lt;p&gt;The decay uses a half-life model, a baseline of 48 hours, stretched dramatically when overnight temperatures keep dropping below freezing. A thaw that refreezes each night can keep surfaces problematic for over a week, because each night arrests the drying process and each morning restarts the moisture release.&lt;/p&gt;
&lt;h2 id="precipitation-intensity-same-amount-different-impact"&gt;Precipitation Intensity: Same Amount, Different Impact&lt;/h2&gt;
&lt;p&gt;The &lt;a href="https://stalefishlabs.com/read/2026-03-17-drying-model/"
&gt;drying model article&lt;/a&gt; covered the rain pattern multiplier (0.7x for light, 1.1x for steady, 1.5x for downpour), but intensity creates edge cases beyond just the multiplier.&lt;/p&gt;
&lt;p&gt;A quarter inch of light rain over four hours &lt;strong&gt;soaks into absorbent surfaces gradually&lt;/strong&gt;. The ground absorbs it progressively, and drainage keeps up. Surface water is minimal. This is the kind of rain you hear gardeners, landscapers, and farmers wishing for as it&amp;rsquo;s the best watering rain for plants, slow and steady.&lt;/p&gt;
&lt;p&gt;A quarter inch dumped in 20 minutes, on the other hand, &lt;strong&gt;overwhelms drainage&lt;/strong&gt;. Water pools on the surface, runs off trails causing erosion, and saturates the top layer before it can percolate down. The surface is wetter even though the total precipitation is identical.&lt;/p&gt;
&lt;p&gt;The 1.5x downpour multiplier captures this, but it&amp;rsquo;s an approximation. In reality, the impact depends heavily on the surface&amp;rsquo;s infiltration rate, slope, and drainage design — none of which the engine models directly. And it doesn&amp;rsquo;t try.&lt;/p&gt;
&lt;p&gt;This is one of the spots where the engine&amp;rsquo;s approximation is &amp;ldquo;good enough for most cases&amp;rdquo; rather than trying to be physically accurate to the extreme. A purpose-built trail with water bars and crowned surfaces handles a downpour better than a flat trail in a natural depression. The engine treats both as dirt with a 1.0x drying multiplier, which is imprecise but still more useful than ignoring intensity entirely. This is also worth flagging where often a Maybe verdict is truly the best answer because it tells you conditions are borderline, and your own local knowledge may tip the final ride decision. Yes, the app is attempting to assist in the human intuition of weather conditions, but not replace it entirely.&lt;/p&gt;
&lt;h2 id="cold-weather-dormancy-protecting-plants-from-themselves"&gt;Cold-Weather Dormancy: Protecting Plants From Themselves&lt;/h2&gt;
&lt;p&gt;This edge case is Yardwise-specific, but it&amp;rsquo;s one of my favorites because it&amp;rsquo;s a case where the &amp;ldquo;right&amp;rdquo; answer is counterintuitive.&lt;/p&gt;
&lt;p&gt;When temperatures drop below 55°F, most plants enter dormancy. Their water uptake drops dramatically. Watering dormant plants typically doesn&amp;rsquo;t help them, it can all too easily create conditions for root rot and fungal disease. In this scenario the engine needs to &lt;strong&gt;suppress watering recommendations&lt;/strong&gt; even when the soil looks dry.&lt;/p&gt;
&lt;p&gt;The dormancy calculation uses a non-linear curve:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;coldFactor = pow((55 - temperature) / 23, 0.6)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Why &lt;code&gt;pow(x, 0.6)&lt;/code&gt; instead of linear? Because the suppression should be strong even at moderate cold. At 45°F (10 degrees below the 55°F threshold), a linear model gives a cold factor of 0.43. The power curve gives 0.56, which is meaningfully stronger. The difference matters because 45°F is genuinely cold enough to suppress plant activity, and the engine should reflect that.&lt;/p&gt;
&lt;p&gt;The dormancy score gets amplified by:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Freeze history&lt;/strong&gt; — if it&amp;rsquo;s been below 32°F for 24+ hours recently, plants are deeply dormant. Add 0.25 × coldFactor.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Recent cold-weather watering&lt;/strong&gt; — if the user watered during cold weather, that water persists much longer because evapotranspiration is minimal. The residual window extends from 48 hours to up to 96 hours. And yes, evapotranspiration is a real word&amp;hellip;I couldn&amp;rsquo;t resist using it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But there&amp;rsquo;s a warm-day dampener too. If the daytime high reaches 65°F even though it&amp;rsquo;s cold right now, the suppression eases. This handles the classic spring pattern: cold mornings, warm afternoons. Plants &lt;em&gt;are&lt;/em&gt; still somewhat active on those warm afternoon hours, and a warm day drives real evaporation.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;warmDampening = clamp((warmerTemp - 65) / 30, 0, 0.5)
dormancyMoisture *= (1.0 - warmDampening)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;At 80°F daytime highs, the dampening cuts dormancy suppression in half. At 95°F, it&amp;rsquo;s fully halved. The cold mornings still matter, but the warm afternoons are doing real drying work.&lt;/p&gt;
&lt;h2 id="low-evaporative-demand-when-dry-isnt-thirsty"&gt;Low Evaporative Demand: When Dry Isn&amp;rsquo;t Thirsty&lt;/h2&gt;
&lt;p&gt;Another Yardwise-specific edge case: on overcast, humid days with moderate temperatures, the soil might technically be &amp;ldquo;dry&amp;rdquo; by the engine&amp;rsquo;s moisture model, but the plants aren&amp;rsquo;t actually losing much water because evaporative demand is low.&lt;/p&gt;
&lt;p&gt;Cloudy skies reduce solar radiation. High humidity reduces the vapor pressure gradient that drives transpiration. If both conditions are present and the temperature is under 85°F, the engine injects a small wetness boost (up to 0.25) to push borderline &amp;ldquo;water today&amp;rdquo; verdicts toward &amp;ldquo;check soil&amp;rdquo; or &amp;ldquo;skip.&amp;rdquo;&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;cloudFactor = clamp((cloudCover - 0.3) / 0.7, 0, 1)
humidityFactor = clamp((humidity - 0.4) / 0.5, 0, 1)
demandReduction = cloudFactor × 0.55 + humidityFactor × 0.45
boost = demandReduction × 0.25
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The guard at 85°F is important. Hot weather drives real water demand regardless of clouds or humidity. Above that threshold, evaporative demand is high enough that the boost doesn&amp;rsquo;t apply.&lt;/p&gt;
&lt;p&gt;This is a small adjustment, 0.25 at maximum, but it prevents the engine from recommending watering on days when the lawn genuinely doesn&amp;rsquo;t need it. Overwatering is a real problem, especially for casual gardeners who might not realize that a cool, overcast day means their yard is fine.&lt;/p&gt;
&lt;h2 id="the-meta-lesson"&gt;The Meta-Lesson&lt;/h2&gt;
&lt;p&gt;Every one of these edge cases started as a wrong verdict. A friend texted me &amp;ldquo;your app said Yes but the trail was iced over.&amp;rdquo; A beta tester reported &amp;ldquo;it&amp;rsquo;s telling me to water but it&amp;rsquo;s 40 degrees.&amp;rdquo; More personally, I&amp;rsquo;m literally looking at my skateboard ramp covered in ice and the app telling me it&amp;rsquo;s shred-ready. Nope.&lt;/p&gt;
&lt;p&gt;The temptation each time was to add a quick patch, an &lt;code&gt;if&lt;/code&gt; statement for the specific scenario. What I tried to do instead was understand &lt;em&gt;why&lt;/em&gt; the model was wrong and fix the underlying assumption. Usually the answer was: the model was treating something as a smooth continuum when reality has phase transitions and state changes.&lt;/p&gt;
&lt;p&gt;Water doesn&amp;rsquo;t gradually become less liquid as it gets colder. It freezes. Plants don&amp;rsquo;t linearly reduce water uptake as temperature drops. They go dormant. Snow doesn&amp;rsquo;t dry like rain. It melts.&lt;/p&gt;
&lt;p&gt;The edge cases are where the domain expertise lives. The drying model is arithmetic. The freeze/thaw logic is hard-won knowledge about how the physical world actually works.&lt;/p&gt;
&lt;h2 id="next-in-the-series"&gt;Next in the Series&lt;/h2&gt;
&lt;p&gt;The &lt;a href="https://stalefishlabs.com/read/2026-03-25-flipping-the-question/"
&gt;next article&lt;/a&gt; covers the Yardwise inversion, how the engine flips from &amp;ldquo;is it too wet?&amp;rdquo; to &amp;ldquo;is it too dry?&amp;rdquo; and the additional features (manual watering tracking, establishment sensitivity) that make gardening a distinct problem from riding and field sports.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The Groundwise engine powers &lt;a href="https://stalefishlabs.com/use/ridewise/"
target="_blank"
&gt;Ridewise&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/use/fieldwise/"
target="_blank"
&gt;Fieldwise&lt;/a&gt;, and &lt;a href="https://stalefishlabs.com/use/yardwise/"
target="_blank"
&gt;Yardwise&lt;/a&gt; — all available for iOS from &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/mtbfrozenedge.png" type="image/png" length="0"/></item><item><title>One Engine, Three Apps: Sharing a Swift Decision Engine Across Products</title><link>https://stalefishlabs.com/read/2026-03-20-one-engine-three-apps/</link><pubDate>Fri, 20 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-03-20-one-engine-three-apps/</guid><description>Structuring a shared Swift package across three apps with different surfaces, thresholds, and verdicts.</description><content:encoded>&lt;p&gt;In the &lt;a href="https://stalefishlabs.com/read/2026-03-12-weather-engine-intro/"
&gt;previous articles&lt;/a&gt; I&amp;rsquo;ve covered what the Groundwise engine does and &lt;a href="https://stalefishlabs.com/read/2026-03-17-drying-model/"
&gt;how the drying model works&lt;/a&gt;. This article is about the architectural decision that turned one app into three: sharing a single decision engine across Ridewise (trail conditions), Fieldwise (sports field conditions), and Yardwise (watering guidance).&lt;/p&gt;
&lt;p&gt;The pitch sounds clean: &amp;ldquo;one engine, three apps.&amp;rdquo; The reality required some specific design choices to keep it from becoming a tangled mess of conditionals.&lt;/p&gt;
&lt;h2 id="the-shape-of-the-problem"&gt;The Shape of the Problem&lt;/h2&gt;
&lt;p&gt;The initial problem was how to apply human intuition to map recent/current weather conditions onto different kinds of surfaces to assess &amp;ldquo;rideability,&amp;rdquo; the application being that I wanted a simple verdict on if a mountain bike trail or skateboard ramp was sufficiently dry to session. That&amp;rsquo;s it, weather inputs in and surface type in, rideability verdict out. And it worked, the app Ridewise solves the problem, and in building it I created a slick little software engine. The thing is, once you&amp;rsquo;ve built an engine, you start to think about other things it might solve. In this case I realized the Groundwise engine is really answering a question more general than mountain biking or skateboarding, it&amp;rsquo;s answering the fundamental question: &lt;strong&gt;what&amp;rsquo;s the moisture situation at this spot?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;So the idea of three different apps started to coalesce, and their distinguishing characteristics began to emerge:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cares about different &lt;strong&gt;surfaces&lt;/strong&gt; (skateparks vs. soccer fields vs. herb gardens)&lt;/li&gt;
&lt;li&gt;Has different &lt;strong&gt;thresholds&lt;/strong&gt; for what counts as &amp;ldquo;too wet&amp;rdquo; or &amp;ldquo;too dry&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Interprets the engine verdict differently&lt;/strong&gt; (wet is bad for riders, good for gardeners)&lt;/li&gt;
&lt;li&gt;Needs &lt;strong&gt;app-specific logic&lt;/strong&gt; (gardeners track manual watering, riders don&amp;rsquo;t)&lt;/li&gt;
&lt;li&gt;Uses &lt;strong&gt;different language&lt;/strong&gt; in the rationale (&amp;ldquo;trail might be muddy&amp;rdquo; vs. &amp;ldquo;field may hold water&amp;rdquo; vs. &amp;ldquo;soil has enough moisture&amp;rdquo;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The question was whether to fork the engine into three copies, or keep one engine and parameterize the differences. I went with parameterization, and the key that made it work was keeping the customization surface small. As you&amp;rsquo;ll see, this was a key design decision early that paid off big later.&lt;/p&gt;
&lt;h2 id="enginemode-flipping-the-perspective"&gt;EngineMode: Flipping the Perspective&lt;/h2&gt;
&lt;p&gt;One of the most key types in the Groundwise engine is what allows it to pivot between caring about dryness or caring about wetness:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;EngineMode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;wetnessConcern&lt;/span&gt; &lt;span class="c1"&gt;// Ridewise, Fieldwise&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;drynessConcern&lt;/span&gt; &lt;span class="c1"&gt;// Yardwise&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The engine always calculates a wetness score from 0 (bone dry) to 1 (saturated). &lt;code&gt;EngineMode&lt;/code&gt; controls what that score &lt;em&gt;means&lt;/em&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Wetness concern:&lt;/strong&gt; Low score = Yes (go ride), high score = No (too wet)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dryness concern:&lt;/strong&gt; Low score = Yes (water your plants), high score = No (skip watering)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The verdict enum is the same in both modes (&lt;code&gt;Yes&lt;/code&gt;, &lt;code&gt;Maybe&lt;/code&gt;, &lt;code&gt;No&lt;/code&gt;) but the &lt;em&gt;interpretation&lt;/em&gt; inverts. This is a deliberate choice. The consuming app doesn&amp;rsquo;t need to know which mode produced the verdict. It just renders appropriate app-specific colors/symbols for Yes, Maybe, and No.&lt;/p&gt;
&lt;h2 id="sitetype-a-unified-wrapper"&gt;SiteType: A Unified Wrapper&lt;/h2&gt;
&lt;p&gt;Each app has its own domain vocabulary. Ridewise has &amp;ldquo;spots&amp;rdquo; (trails, skateparks), Fieldwise has &amp;ldquo;fields&amp;rdquo; (baseball diamonds, tennis courts), Yardwise has &amp;ldquo;areas&amp;rdquo; (lawns, raised beds). Rather than making the engine accept three different input types, they&amp;rsquo;re wrapped in a single enum:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;SiteType&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;spot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SpotType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Ridewise: trail, skatepark, bikeJumps, pumpTrack...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FieldType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Fieldwise: baseball, soccer, tennis, rugby...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AreaType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Yardwise: lawn, raisedBed, container, compost...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The key thing we&amp;rsquo;re trying to arrive at for wetness/dryness purposes is a surface type. Each case in this enum maps to a &lt;code&gt;SurfaceType&lt;/code&gt;, the thing the engine actually cares about. A &lt;code&gt;SpotType.skatepark&lt;/code&gt; maps to &lt;code&gt;SurfaceType.concrete&lt;/code&gt;. A &lt;code&gt;FieldType.baseball&lt;/code&gt; maps to &lt;code&gt;SurfaceType.naturalGrass&lt;/code&gt;. A &lt;code&gt;AreaType.container&lt;/code&gt; maps to &lt;code&gt;SurfaceType.pottingMix&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The engine never branches on &lt;code&gt;SpotType&lt;/code&gt; or &lt;code&gt;FieldType&lt;/code&gt; directly. It resolves the surface type up front, then works exclusively with surface properties. This is what keeps the core logic free of app-specific conditionals, and ultimately what enables engine reuse across apps with different concerns.&lt;/p&gt;
&lt;h2 id="surface-types-the-parameterization-layer"&gt;Surface Types: The Parameterization Layer&lt;/h2&gt;
&lt;p&gt;The apps currently have seventeen surface types to encode the physical properties that actually matter for moisture modeling:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Each surface type defines:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;dryingMultiplier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt; &lt;span class="c1"&gt;// 0.7 (clay) to 2.8 (metal)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;damageSensitivity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt; &lt;span class="c1"&gt;// 0.0 to 1.0&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isDrainingSurface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Bool&lt;/span&gt; &lt;span class="c1"&gt;// sheds water vs. absorbs&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isAbsorbentSurface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Bool&lt;/span&gt; &lt;span class="c1"&gt;// holds water in material&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;safetySensitivity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt; &lt;span class="c1"&gt;// slip risk when wet&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;qualitySensitivity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt; &lt;span class="c1"&gt;// quality degradation when wet&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The engine uses these properties, never the surface type identity, to modulate its calculations. Drying multiplier scales how fast moisture clears. Damage sensitivity controls the sensitivity veto (should the engine push borderline verdicts toward protection?). Draining vs. absorbent determines whether residual wetness applies.&lt;/p&gt;
&lt;p&gt;This means adding a new surface type is a data change, not a logic change. If someone wanted to add &amp;ldquo;packed gravel&amp;rdquo; for Ridewise, they&amp;rsquo;d define its properties and it would flow through the existing pipeline.&lt;/p&gt;
&lt;p&gt;Also notice a few perhaps surprising things that entered the data model: safety and quality sensititivies. When you think about it, rideability isn&amp;rsquo;t just about &amp;ldquo;too muddy&amp;rdquo; or &amp;ldquo;has puddles,&amp;rdquo; there&amp;rsquo;s also a safety concern and a quality of ride concern. Skateparks are notably impossible to ride with any standing moisture, while mountain bike trails have a fairly wide range of acceptance that sometimes comes down to local rules (is it open?) and personal tolerance (how much mud is just annoying?). So the wet skatepark scenario is truly a safety risk, while the mountain bike scenario is more about am I damaging the trail and am I open to a mud ride.&lt;/p&gt;
&lt;p&gt;As an aside, it&amp;rsquo;s worth noting that I ride a local private mountain bike trail that gets so little traffic that mud isn&amp;rsquo;t a damage concern at all. We tend to invert the normal ride season and ride that trail specifically in less than ideal conditions, so it provided a bit of a logic stress test for the app. Often for this particular trail I&amp;rsquo;ll take a Maybe verdict as a green light given its unique properties.&lt;/p&gt;
&lt;h2 id="threshold-calibration-per-mode"&gt;Threshold Calibration Per Mode&lt;/h2&gt;
&lt;p&gt;When it comes to actually rendering a verdict, the verdict thresholds differ between wetness concern and dryness concern:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Yes&lt;/th&gt;
&lt;th&gt;Maybe&lt;/th&gt;
&lt;th&gt;No&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Wetness concern&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 0.30&lt;/td&gt;
&lt;td&gt;0.30 – 0.60&lt;/td&gt;
&lt;td&gt;≥ 0.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dryness concern&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&amp;lt; 0.15&lt;/td&gt;
&lt;td&gt;0.15 – 0.45&lt;/td&gt;
&lt;td&gt;≥ 0.45&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Yardwise thresholds are shifted lower because the consequences are asymmetric. For a rider, going out on a borderline trail risks damaging the trail surface. For a gardener, missing one watering day rarely kills plants. The engine leans toward &amp;ldquo;maybe water&amp;rdquo; rather than &amp;ldquo;definitely water&amp;rdquo; because overwatering is its own problem.&lt;/p&gt;
&lt;p&gt;The sensitivity veto also inverts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Wetness concern:&lt;/strong&gt; If a sensitive surface (like natural grass with 0.9 damage sensitivity) gets a Maybe verdict with wetness above 0.4, it&amp;rsquo;s pushed to No. Protect the surface.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dryness concern:&lt;/strong&gt; If a sensitive surface (like fresh seed with 0.95 effective sensitivity) gets a Maybe verdict with wetness below 0.3, it&amp;rsquo;s pushed to Yes. Water the fragile plants.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Same mechanism, opposite direction. And yet one line of mode-aware logic handles both cases.&lt;/p&gt;
&lt;h2 id="app-specific-features-without-conditionals"&gt;App-Specific Features Without Conditionals&lt;/h2&gt;
&lt;p&gt;It&amp;rsquo;s worth pointing out that Yardwise has two features the other apps don&amp;rsquo;t: manual watering tracking and cold-weather dormancy suppression. Rather than branching on app identity, these are driven by the input data:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Manual watering&lt;/strong&gt; is an array of &lt;code&gt;WateringEvent&lt;/code&gt; values on the input struct. Ridewise and Fieldwise pass an empty array. The engine&amp;rsquo;s watering contribution calculation gracefully returns zero when there are no events, meaning no conditional needed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Establishment sensitivity&lt;/strong&gt; is a &lt;code&gt;Double&lt;/code&gt; on the input (0.0 by default). Yardwise sets it to 0.5 for newly seeded areas and 0.2 for newly planted ones, boosting the surface&amp;rsquo;s damage sensitivity. Ridewise and Fieldwise leave it at 0.0. Again, no branching for the apps that don&amp;rsquo;t care, the math just works with a zero boost.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cold-weather dormancy&lt;/strong&gt; activates when the temperature drops below 55°F. It injects additional &amp;ldquo;wetness&amp;rdquo; into the Yardwise calculation, suppressing watering recommendations when plants are dormant. This &lt;em&gt;is&lt;/em&gt; gated on &lt;code&gt;EngineMode.drynessConcern&lt;/code&gt; because the concept doesn&amp;rsquo;t make sense for riders given that cold weather doesn&amp;rsquo;t make trails &lt;em&gt;less wet&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;This is the one place where I accepted a mode check in the engine, and it was a pragmatic call. Dormancy is fundamentally a gardening concept. Trying to abstract it into a mode-agnostic mechanism would have added complexity without clarity.&lt;/p&gt;
&lt;h2 id="the-input-struct"&gt;The Input Struct&lt;/h2&gt;
&lt;p&gt;On to the engine inputs, where everything flows through a single input type:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;GroundwiseInputs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Weather (required)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rain24hInches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rain48hInches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rain72hInches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;minutesSinceRainEnded&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rainPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RainPattern&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;temperatureF&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;sustainedWindMph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;cloudCoverFraction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Weather (optional, improves accuracy)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;gustsMph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;dewPointF&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;relativeHumidity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;overnightLowF&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;daysSinceLastRain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Freeze/thaw context&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;frozenSnowAccumulationInches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;hoursSinceThawBegan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;hoursBelowFreezing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Site context&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;siteType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SiteType&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;surfaceType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SurfaceType&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;exposureLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ExposureLevel&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Mode and calibration&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;engineMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EngineMode&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;establishmentSensitivityBoost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Double&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;isCurrentlyPrecipitating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Bool&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;hasThunderstorm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Bool&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;// Yardwise-specific&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;manualWateringEvents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;WateringEvent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each app constructs this from its own domain model. Ridewise fills in the weather data and its spot&amp;rsquo;s surface type. Yardwise adds watering events and an establishment boost. The engine doesn&amp;rsquo;t know or care which app called it.&lt;/p&gt;
&lt;h2 id="the-output-same-structure-different-meaning"&gt;The Output: Same Structure, Different Meaning&lt;/h2&gt;
&lt;p&gt;The engine evaluates the inputs as a black box and generates an assessment in this form:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-swift" data-lang="swift"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;GroundwiseAssessment&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt; &lt;span class="c1"&gt;// .yes / .maybe / .no&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Confidence&lt;/span&gt; &lt;span class="c1"&gt;// .low / .medium / .high&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rationale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;GroundwiseRationale&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;riskSurfaceDamage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RiskLevel&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;riskActivitySafety&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RiskLevel&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;riskActivityQuality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RiskLevel&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;recoveryOutlook&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RecoveryOutlook&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The consuming app interprets the verdict through its own lens. Ridewise renders Yes as &amp;ldquo;Dry — go ride.&amp;rdquo; Yardwise renders Yes as &amp;ldquo;Water today.&amp;rdquo; The engine provides the structured rationale (headline, decisive factor, contributing details), and each app&amp;rsquo;s UI formats it with domain-appropriate language.&lt;/p&gt;
&lt;p&gt;Risk levels work the same way. Surface damage risk on a trail means erosion and ruts. Surface damage risk on a newly seeded lawn means killing the seed. Same risk level, different consequence, the app handles the framing.&lt;/p&gt;
&lt;h2 id="what-id-do-differently"&gt;What I&amp;rsquo;d Do Differently&lt;/h2&gt;
&lt;p&gt;The engine&amp;rsquo;s one-file structure (2,193 lines in &lt;code&gt;GroundwiseEngine.swift&lt;/code&gt;) works but isn&amp;rsquo;t ideal. The frozen conditions check alone is 360 lines. If I were starting over, I&amp;rsquo;d break the pipeline into discrete stages: &lt;code&gt;FreezeAssessor&lt;/code&gt;, &lt;code&gt;PrecipitationAssessor&lt;/code&gt;, and &lt;code&gt;DryingCalculator&lt;/code&gt;, &lt;code&gt;VerdictResolver&lt;/code&gt;. And I&amp;rsquo;d make each of these a separate type with a clear protocol.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;d also make the threshold constants configurable per surface type rather than per engine mode. Right now, all wetness-concern surfaces share the same 0.3/0.6 thresholds. But a clay tennis court arguably deserves tighter thresholds than an artificial turf field. The sensitivity veto partially handles this, but explicit per-surface thresholds would be cleaner.&lt;/p&gt;
&lt;p&gt;That said, the current design has shipped and works. Premature refactoring is its own trap.&lt;/p&gt;
&lt;h2 id="the-tradeoff"&gt;The Tradeoff&lt;/h2&gt;
&lt;p&gt;Sharing an engine across three apps means accepting constraints:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;You can&amp;rsquo;t special-case easily.&lt;/strong&gt; If Ridewise needs a trail-specific behavior that doesn&amp;rsquo;t generalize, you either make it general enough for the shared engine or handle it in the app layer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Testing surface area grows.&lt;/strong&gt; Every change to the engine needs to be validated against all three app contexts. A threshold tweak that improves Ridewise verdicts might break Yardwise scenarios.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Abstraction has a ceiling.&lt;/strong&gt; Cold-weather dormancy proved that not everything generalizes cleanly. Knowing when to accept a mode check instead of over-abstracting is a judgment call.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The upside: every improvement to the drying model benefits all three apps simultaneously. When I improved the residual wetness calculation for trails, clay tennis courts and garden beds got more accurate too because they share the same underlying property (absorbent surfaces retain moisture).&lt;/p&gt;
&lt;p&gt;For an indy developer maintaining three related apps, that leverage is worth the constraints.&lt;/p&gt;
&lt;h2 id="next-in-the-series"&gt;Next in the Series&lt;/h2&gt;
&lt;p&gt;The &lt;a href="https://stalefishlabs.com/read/2026-03-23-edge-cases/"
&gt;next article&lt;/a&gt; digs into the edge cases that make weather modeling humbling: freeze/thaw cycles, precipitation intensity, snow melt, and cold-weather plant dormancy. Each one is a lesson in how simple models break when they meet the real world.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Ridewise, Fieldwise, and Yardwise are all available for iOS from &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;. Built by one developer, powered by one engine.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/swiftengine.png" type="image/png" length="0"/></item><item><title>Turning Raw Weather Into a Drying Model</title><link>https://stalefishlabs.com/read/2026-03-17-drying-model/</link><pubDate>Tue, 17 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-03-17-drying-model/</guid><description>How we model surface drying after rain, and why a downpour and a drizzle leave very different trails.</description><content:encoded>&lt;p&gt;In the &lt;a href="https://stalefishlabs.com/read/2026-03-05-weather-engine-intro/"
&gt;first article&lt;/a&gt; about my foray into using weather data to assess surface conditions for riding activities, I introduced the Groundwise engine, a software decision system that turns weather data into a yes/maybe/no verdict for outdoor conditions. This article goes deeper into the core moisture model: how the engine takes raw weather observations and calculates a wetness score that drives the verdict. The hope is to show how a simple question such as &amp;ldquo;can I ride my mountain bike today?&amp;rdquo; spiraled into a fairly complex yet intriguing design challenge. Totally fair if you aren&amp;rsquo;t entranced by weather math, I get it, but I feel like there&amp;rsquo;s some value in pulling back the curtain to reveal how much goes into solving a seemingly simple problem.&lt;/p&gt;
&lt;p&gt;The Groundwise model is relatively restrainted in scope, it isn&amp;rsquo;t trying to simulate soil physics. It&amp;rsquo;s trying to approximate what an experienced local would know instinctively — &amp;ldquo;it rained pretty hard yesterday, but it&amp;rsquo;s been warm and windy all morning, so the trails are probably fine.&amp;rdquo; The goal is to encode that intuition into something repeatable and surface-aware.&lt;/p&gt;
&lt;h2 id="weighted-precipitation-not-all-rain-is-equal"&gt;Weighted Precipitation: Not All Rain Is Equal&lt;/h2&gt;
&lt;p&gt;The engine tracks precipitation across three time windows: the last 24 hours, 24-48 hours ago, and 48-72 hours ago. But it doesn&amp;rsquo;t treat them equally. Here&amp;rsquo;s how they are weighted differently by the weather precipitation equation:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;weatherPrecip = rain24h × 0.7 + (rain48h - rain24h) × 0.2 + (rain72h - rain48h) × 0.1
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Recent rain dominates. An inch in the last 24 hours contributes seven times more to the score than an inch from two days ago. This matches real-world observation, where rain from three days ago has had significant time to drain and evaporate, while yesterday&amp;rsquo;s rain is still actively affecting conditions.&lt;/p&gt;
&lt;p&gt;The subtraction matters too. &lt;code&gt;rain48h&lt;/code&gt; is a &lt;em&gt;cumulative&lt;/em&gt; total (it includes the last 24 hours), so the formula isolates each window&amp;rsquo;s unique contribution. A half an inch total over 48 hours where all of it fell in the last 24 tells a very different story than half an inch spread evenly.&lt;/p&gt;
&lt;h3 id="rain-score-normalization"&gt;Rain Score Normalization&lt;/h3&gt;
&lt;p&gt;A recent rain results in a raw precipitation value that gets normalized to a 0-1 scale, so effectively a percentage:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;rainScore = min(1.0, totalPrecip / 0.75)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Three-quarters of an inch saturates the score (maximum value of 1, or 100%). Beyond that, more rain doesn&amp;rsquo;t make things &lt;em&gt;more&lt;/em&gt; wet from the engine&amp;rsquo;s perspective — you&amp;rsquo;re already at maximum precipitation/saturation, which matches reality in that a trail, yard, or any other outdoor surface has a limit to how wet it can get. This prevents a 3-inch deluge from producing a wildly different result than a 1-inch soaking. Both are &amp;ldquo;very wet&amp;rdquo; — the distinction that matters is how long ago it happened and how fast things are drying.&lt;/p&gt;
&lt;h3 id="why-intensity-matters"&gt;Why Intensity Matters&lt;/h3&gt;
&lt;p&gt;The engine tracks rain pattern: light, steady, or downpour. It then applies a multiplier to the base wetness given one of those patterns:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Multiplier&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Light&lt;/td&gt;
&lt;td&gt;0.7x&lt;/td&gt;
&lt;td&gt;Gentle rain soaks in gradually, less runoff, less surface pooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Steady&lt;/td&gt;
&lt;td&gt;1.1x&lt;/td&gt;
&lt;td&gt;Sustained saturation, ground can&amp;rsquo;t keep up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Downpour&lt;/td&gt;
&lt;td&gt;1.5x&lt;/td&gt;
&lt;td&gt;Overwhelms drainage, causes pooling and surface flooding&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A quarter inch of light rain over several hours is genuinely different from a quarter inch dumped in 20 minutes. The light rain absorbs more evenly. The downpour creates surface water, overwhelms drainage on absorbent surfaces, and can cause erosion on trails.&lt;/p&gt;
&lt;h2 id="drying-strength-the-recovery-side"&gt;Drying Strength: The Recovery Side&lt;/h2&gt;
&lt;p&gt;Wetness is only half the picture. The other half is how aggressively conditions are drying things out. The engine calculates a composite drying strength from four weather factors:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;drying = (tempFactor × 0.30) + (windFactor × 0.30) + (humidityFactor × 0.25) + (skyFactor × 0.15)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Temperature (30% weight):&lt;/strong&gt; Warmer air holds more moisture and drives evaporation. The factor scales linearly from 0 at 50°F to 1.0 at 90°F. Below 50°F, evaporation slows dramatically. Above 90°F, you&amp;rsquo;re drying as fast as conditions allow.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;tempFactor = clamp((tempF - 50) / 40, 0, 1)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Wind (30% weight):&lt;/strong&gt; Moving air carries moisture away from surfaces. Scales linearly up to 20 mph, where the effect plateaus - going from 20 to 40 mph doesn&amp;rsquo;t double the drying rate.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;windFactor = clamp(sustainedWindMph / 20, 0, 1)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Humidity (25% weight):&lt;/strong&gt; Dry air absorbs moisture more readily. This is the inverse, where low humidity means strong drying.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;humidityFactor = clamp(1.0 - effectiveHumidity, 0, 1)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;When the weather API provides a dew point instead of relative humidity, the engine converts it using the Magnus formula. Dew point is actually the more reliable measurement — relative humidity fluctuates throughout the day even when actual moisture content stays constant.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sky cover (15% weight):&lt;/strong&gt; Clear skies mean solar radiation hitting surfaces directly, which drives evaporation. Clouds block that. This gets the lowest weight because its effect is real but smaller than the others.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;skyFactor = clamp(1.0 - cloudCover, 0, 1)
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="exposure-matters"&gt;Exposure Matters&lt;/h3&gt;
&lt;p&gt;All of this gets modified by where the surface actually sits. A trail under heavy tree canopy dries differently than an open field fully exposed to sunlight:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exposure&lt;/th&gt;
&lt;th&gt;Rain Multiplier&lt;/th&gt;
&lt;th&gt;Drying Multiplier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Exposed&lt;/td&gt;
&lt;td&gt;1.0x&lt;/td&gt;
&lt;td&gt;1.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shaded&lt;/td&gt;
&lt;td&gt;0.7x&lt;/td&gt;
&lt;td&gt;0.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Covered&lt;/td&gt;
&lt;td&gt;0.0x&lt;/td&gt;
&lt;td&gt;0.8x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Shaded spots get less rain (canopy intercepts 30%) but also dry 40% slower (less sun, less wind). Covered spots (under a roof or overhang) get no rain at all but still have air circulation for moderate drying.&lt;/p&gt;
&lt;p&gt;This creates an interesting dynamic: a shaded trail might actually be &lt;em&gt;drier&lt;/em&gt; than an exposed one after light rain (less water reached it) but &lt;em&gt;wetter&lt;/em&gt; after heavy rain (the rain that got through takes longer to leave).&lt;/p&gt;
&lt;h3 id="drying-classification"&gt;Drying Classification&lt;/h3&gt;
&lt;p&gt;The composite score maps to three tiers, each returning a fixed drying effectiveness value:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Score Range&lt;/th&gt;
&lt;th&gt;Classification&lt;/th&gt;
&lt;th&gt;Effectiveness&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;≥ 0.7&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;td&gt;0.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.4 – 0.7&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;lt; 0.4&lt;/td&gt;
&lt;td&gt;Weak&lt;/td&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;I deliberately chose discrete tiers over a continuous curve. The difference between a 0.41 and a 0.69 drying score isn&amp;rsquo;t meaningful in practice — both represent &amp;ldquo;conditions are helping somewhat.&amp;rdquo; The tiers prevent false precision while still capturing the real distinction between &amp;ldquo;sunny and breezy&amp;rdquo; (strong), &amp;ldquo;overcast and mild&amp;rdquo; (moderate), and &amp;ldquo;cold, humid, and still&amp;rdquo; (weak).&lt;/p&gt;
&lt;h2 id="combining-it-all-the-wetness-score"&gt;Combining It All: The Wetness Score&lt;/h2&gt;
&lt;p&gt;I warned you this problem has more nuance that it would seem. But hang in there, we&amp;rsquo;re getting closer to arriving at some meaningful mathematical conclusions. For example, the wetness score blends precipitation and drying into a single 0-1 value:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;baseWetness = rainScore × 0.5 + (rainScore × timingScore) × 0.6
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This formula does something subtle. The first term (&lt;code&gt;rainScore × 0.5&lt;/code&gt;) ensures that heavy rain always contributes &lt;em&gt;some&lt;/em&gt; wetness regardless of timing. The second term (&lt;code&gt;rainScore × timingScore&lt;/code&gt;) captures the interaction — recent heavy rain is much worse than old heavy rain. The timing score decays linearly from 1.0 (just stopped) to 0.0 (24+ hours ago).&lt;/p&gt;
&lt;p&gt;After the base calculation, the rain pattern multiplier is applied (0.7x for light, 1.1x for steady, 1.5x for downpour), then drying reduces it:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;dryingEffect = dryingScore × 0.3 × dryingEffectiveness
wetness = baseWetness × (1.0 - dryingEffect)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The &lt;code&gt;dryingEffectiveness&lt;/code&gt; factor prevents drying from being too aggressive when rain just ended. If rain stopped 30 minutes ago, even strong drying conditions haven&amp;rsquo;t had time to do much yet. The factor scales up as time passes, so drying&amp;rsquo;s impact grows the longer it&amp;rsquo;s been since rain.&lt;/p&gt;
&lt;h3 id="a-worked-example"&gt;A Worked Example&lt;/h3&gt;
&lt;p&gt;To put all these equations into perspective, let&amp;rsquo;s trace through a real scenario to arrive at a Groundwise verdict: &lt;strong&gt;0.4 inches of steady rain ended 8 hours ago. It&amp;rsquo;s 72°F, 12 mph wind, 45% humidity, 20% cloud cover. Exposed dirt trail.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Rain score:&lt;/strong&gt;&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;weatherPrecip = 0.4 × 0.7 = 0.28 (all in last 24h)
rainScore = min(1.0, 0.28 / 0.75) = 0.37
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Timing score:&lt;/strong&gt;&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;hours = 480 min / 60 = 8
timingScore = max(0, 1.0 - 8/24) = 0.67
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Drying strength:&lt;/strong&gt;&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;temp = (72-50)/40 × 0.30 = 0.165
wind = 12/20 × 0.30 = 0.18
humidity = 0.55 × 0.25 = 0.1375
sky = 0.80 × 0.15 = 0.12
total = 0.60 → Moderate (effectiveness = 0.5)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Wetness:&lt;/strong&gt;&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;base = 0.37 × 0.5 + (0.37 × 0.67) × 0.6 = 0.185 + 0.149 = 0.334
× steady pattern (1.1) = 0.367
dryingEffectiveness = max(0.2, 1.0 - 0.67 × 0.5) = 0.665
dryingEffect = 0.5 × 0.3 × 0.665 = 0.10
wetness = 0.367 × (1.0 - 0.10) = 0.33
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Verdict: Maybe&lt;/strong&gt; (0.33 is just above the 0.3 threshold). The trail is borderline — rideable but still damp. On a dirt trail (damage sensitivity 0.5), the sensitivity veto wouldn&amp;rsquo;t trigger. A reasonable call either way, and the engine goes with Maybe, giving the rider the opportunity to then weigh locale-specific details such as trail sensitivity to damage.&lt;/p&gt;
&lt;p&gt;Now change one variable — make it 85°F instead of 72°F:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;temp = (85-50)/40 × 0.30 = 0.2625
total drying = 0.70 → Strong (effectiveness = 0.8)
dryingEffect = 0.8 × 0.3 × 0.665 = 0.16
wetness = 0.367 × (1.0 - 0.16) = 0.308
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Still Maybe, but barely. A bit more wind or another hour of drying and it flips to Yes. That matches intuition — a hot afternoon pulls moisture out fast.&lt;/p&gt;
&lt;h2 id="residual-wetness-the-ground-remembers"&gt;Residual Wetness: The Ground Remembers&lt;/h2&gt;
&lt;p&gt;The timing-based model handles most scenarios well, but it has a blind spot: &lt;strong&gt;ground saturation after heavy rain on absorbent surfaces.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If it rained an inch on Monday and you&amp;rsquo;re checking the trail on Wednesday, the timing score says &amp;ldquo;it&amp;rsquo;s been 48 hours, moisture contribution is minimal.&amp;rdquo; But anyone who&amp;rsquo;s walked a clay trail two days after heavy rain knows that&amp;rsquo;s wrong. The ground is still holding water.&lt;/p&gt;
&lt;p&gt;The engine adds a residual wetness component for absorbent surfaces (dirt, grass, clay, amended soil) when precipitation exceeded 0.25 inches:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;volumeFactor = clamp((rain - 0.25) / 0.75, 0, 1)
decayFactor = based on hours since rain and drying strength
surfaceRetention = inverse of surface drying multiplier
dryingReduction = strength-based reduction
residualWetness = 0.55 × volumeFactor × decayFactor × surfaceRetention × (1.0 - dryingReduction)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The peak residual value of ~0.55 deliberately sits in the middle of the Maybe range. It&amp;rsquo;s not enough to trigger a hard No, but it keeps the engine from prematurely saying Yes on saturated ground.&lt;/p&gt;
&lt;p&gt;Clay surfaces (drying multiplier 0.7x) retain the most residual moisture. Concrete and metal (2.0x+) don&amp;rsquo;t get residual wetness at all, they&amp;rsquo;re draining surfaces that hold little (concrete) to no (metal) water.&lt;/p&gt;
&lt;h2 id="what-this-model-doesnt-do"&gt;What This Model Doesn&amp;rsquo;t Do&lt;/h2&gt;
&lt;p&gt;A few things I intentionally left out:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Soil type modeling.&lt;/strong&gt; Real evapotranspiration models account for soil composition, drainage rates, water table depth. The engine uses surface type as a proxy instead. A dirt trail in sandy Arizona soil dries differently than one in Georgia red clay, and the engine can&amp;rsquo;t distinguish them. The tradeoff is simplicity — asking users to classify their soil composition seemed like a bridge too far. If you disagree, let us know, but I decided to err on the side of simplicity at least in terms of user inputs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Microclimate effects.&lt;/strong&gt; Two spots a mile apart can have meaningfully different conditions. The engine uses a single weather observation for each saved location, which might come from a station several miles away. Elevation, valley effects, and urban heat islands aren&amp;rsquo;t modeled.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Drainage infrastructure.&lt;/strong&gt; A well-designed trail with proper drainage handles rain better than a flat trail in a depression. An athletic field with subsurface drainage is ready for play sooner than one without. The engine doesn&amp;rsquo;t know about this. Surface type captures some of it (artificial turf implies drainage engineering), but it&amp;rsquo;s imperfect.&lt;/p&gt;
&lt;p&gt;These are all real limitations, and I&amp;rsquo;d rather be transparent about them than pretend the model is more precise than it is. The engine targets &amp;ldquo;better than guessing&amp;rdquo; — not &amp;ldquo;better than walking over and checking yourself.&amp;rdquo;&lt;/p&gt;
&lt;h2 id="next-in-the-series"&gt;Next in the Series&lt;/h2&gt;
&lt;p&gt;The &lt;a href="https://stalefishlabs.com/read/2026-03-19-one-engine-three-apps/"
&gt;next article&lt;/a&gt; covers how this engine is packaged as a shared Swift framework consumed by three different apps — each with its own surface library, threshold calibration, and verdict interpretation. Same core math, three different products. It&amp;rsquo;s a lesson in code reuse, and the benefits of solving a problem once in just a general enough way to apply that solution multiple times.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The drying model powers &lt;a href="https://stalefishlabs.com/use/ridewise/"
target="_blank"
&gt;Ridewise&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/use/fieldwise/"
target="_blank"
&gt;Fieldwise&lt;/a&gt;, and &lt;a href="https://stalefishlabs.com/use/yardwise/"
target="_blank"
&gt;Yardwise&lt;/a&gt; — all available for iOS from &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/floodedgrass.png" type="image/png" length="0"/></item><item><title>The Pole Sitter Strategy: Fixing a Fantasy F1 Scoring Loophole</title><link>https://stalefishlabs.com/read/2026-03-13-the-pole-sitter-strategy/</link><pubDate>Fri, 13 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-03-13-the-pole-sitter-strategy/</guid><description>Someone won our F1 fantasy league picking the pole sitter every week. The data showed why, and appification allowed for an elegant fix.</description><content:encoded>&lt;p&gt;Before &lt;a href="https://stalefishlabs.com/use/openwheelers/"
target="_blank"
&gt;Open Wheelers&lt;/a&gt; was an app, it was a text thread. A group of friends, a shared note, picks submitted before qualifying. One of those friends figured out something the rest of us missed: you could win the whole league by making the same Podium pick every single week.&lt;/p&gt;
&lt;p&gt;Her strategy was simple: wait for qualifying, pick whoever was in pole position, and move on to baking amazing cakes for F1 brunch. Seriously. No research, no gut feelings, no agonizing over race pace versus one-lap speed. Just, who&amp;rsquo;s P1 on the grid, and is this week feeling more strawberry or lemon? I won&amp;rsquo;t divulge any names, but hers rhymes with Sammy, although this year we just call her champ.&lt;/p&gt;
&lt;p&gt;Because she won the league. And the rest of us were annoyed enough to wonder why.&lt;/p&gt;
&lt;h2 id="the-old-scoring-made-it-easy"&gt;The old scoring made it easy&lt;/h2&gt;
&lt;p&gt;Our original manual league used this scoring system:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Points&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Podium&lt;/td&gt;
&lt;td&gt;5 for P1, 3 for P2, 1 for P3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNF&lt;/td&gt;
&lt;td&gt;3 for first retirement only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fastest Lap&lt;/td&gt;
&lt;td&gt;1 point&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;See the problem? The Podium category was worth up to &lt;strong&gt;5 points&lt;/strong&gt; while DNF maxed out at 3 and Fastest Lap was a throwaway single point. The game was structurally a Podium-picking contest with two minor side bets. If you could crack the Podium category, the other picks barely mattered.&lt;/p&gt;
&lt;p&gt;And cracking the Podium category turns out to be remarkably easy when you just pick the pole sitter.&lt;/p&gt;
&lt;h2 id="how-often-does-pole-become-podium"&gt;How often does pole become podium?&lt;/h2&gt;
&lt;p&gt;I looked at every F1 race from 2016 through 2024, the same decade of data used in the &lt;a href="https://stalefishlabs.com/read/2026-03-04-the-none-trap-dnf-game-theory/"
&gt;DNF analysis&lt;/a&gt;. Here&amp;rsquo;s how the pole sitter fared:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Season&lt;/th&gt;
&lt;th&gt;Races&lt;/th&gt;
&lt;th&gt;Pole wins&lt;/th&gt;
&lt;th&gt;Pole P2&lt;/th&gt;
&lt;th&gt;Pole P3&lt;/th&gt;
&lt;th&gt;Pole on podium&lt;/th&gt;
&lt;th&gt;Off podium&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2016&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;17 (81%)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2017&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;16 (80%)&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2018&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;15 (71%)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2019&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;14 (67%)&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2020&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;14 (82%)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2021&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;16 (73%)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2022&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;15 (68%)&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;19 (86%)&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2024&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;18 (75%)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The pole sitter finishes on the podium roughly &lt;strong&gt;75% of the time&lt;/strong&gt; across the modern era. That&amp;rsquo;s not a strategy, that&amp;rsquo;s a cheat code.&lt;/p&gt;
&lt;h2 id="the-pole-sitter-under-old-scoring"&gt;The pole sitter under old scoring&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s see what &amp;ldquo;always pick the pole sitter&amp;rdquo; actually produced under the old 5-3-1 system. Take 2023, the best season for this strategy:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;14 wins × 5 points = 70&lt;/li&gt;
&lt;li&gt;3 runner-ups × 3 points = 9&lt;/li&gt;
&lt;li&gt;2 third-place finishes × 1 point = 2&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Season total: 81 points from Podium alone&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Meanwhile, the maximum possible from the other two categories combined was about 66 points for DNF (3 × 22 races, if you nailed every single one) and 22 for Fastest Lap (1 × 22). Realistically, even a sharp player might score 25-30 across those categories, but in reality choosing &lt;em&gt;first&lt;/em&gt; DNF turned out to be incredibly difficult (Fastest Lap wasn&amp;rsquo;t much better).&lt;/p&gt;
&lt;p&gt;So our pole sitter friend was pulling in roughly 60-80 Podium points per season while everyone else scrambled to make up the gap with DNF and Fastest Lap picks that were worth a fraction as much. The scoring wasn&amp;rsquo;t balanced, it was broken. Our friend just noticed the breakage and exploited it while the rest of us picked (and lost!) with our hearts.&lt;/p&gt;
&lt;h2 id="the-fix-flatten-the-pyramid"&gt;The fix: flatten the pyramid&lt;/h2&gt;
&lt;p&gt;When I built Open Wheelers as a proper app, I had a chance to redesign the scoring from scratch. The key change was simple: &lt;strong&gt;3-2-1 across all three categories&lt;/strong&gt;. I even named our underlying fantasy scoring engine Podium to highlight this design decision.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Points&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Podium&lt;/td&gt;
&lt;td&gt;3 for P1, 2 for P2, 1 for P3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Overtaker&lt;/td&gt;
&lt;td&gt;3 for most positions gained, 2 for 2nd, 1 for 3rd&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNF&lt;/td&gt;
&lt;td&gt;3 for 1st retirement, 2 for 2nd, 1 for 3rd&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Every category now has the same ceiling. No single pick dominates. Let&amp;rsquo;s rerun the pole sitter strategy under the new system.&lt;/p&gt;
&lt;h2 id="the-pole-sitter-under-new-scoring"&gt;The pole sitter under new scoring&lt;/h2&gt;
&lt;p&gt;Same 2023 season, same strategy, new math:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;14 wins × 3 points = 42&lt;/li&gt;
&lt;li&gt;3 runner-ups × 2 points = 6&lt;/li&gt;
&lt;li&gt;2 third-place finishes × 1 point = 2&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Season total: 50 points from Podium&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Still solid, but now the Overtaker and DNF categories carry equal weight. A player who scores well across all three categories can absolutely overtake (pun intended) the pole-sitting, cake-baking rules anarchist.&lt;/p&gt;
&lt;p&gt;Consider a well-rounded player who averages just 1.5 points per race across Overtaker and DNF, modest accuracy, not perfection. Over 22 races, that&amp;rsquo;s 33 points from those two categories alone. Add a reasonable Podium performance of, say, 35 points (less than the pole sitter, but making smarter individual picks), and you&amp;rsquo;re at 68 points total versus the pole sitter&amp;rsquo;s 50-plus-whatever-they-stumble-into on Overtaker and DNF.&lt;/p&gt;
&lt;h2 id="the-deeper-fix-replacing-fastest-lap-with-overtaker"&gt;The deeper fix: replacing Fastest Lap with Overtaker&lt;/h2&gt;
&lt;p&gt;The old system&amp;rsquo;s other problem was that two of the three categories, Podium and Fastest Lap, correlated heavily with the same thing: having the fastest car. The driver on pole was usually also the one most likely to set the fastest lap. If you picked the dominant driver for both, you were essentially making one pick and getting credit for two.&lt;/p&gt;
&lt;p&gt;Overtaker breaks that correlation completely. The driver who gains the most positions is almost never the pole sitter — by definition, they started at the front with nowhere to climb. The Overtaker category rewards knowledge of the &lt;em&gt;rest&lt;/em&gt; of the grid: who qualified poorly but has strong race pace, who&amp;rsquo;s taking engine penalties, which midfield team nailed their setup for the race even if qualifying didn&amp;rsquo;t go their way. It&amp;rsquo;s sort of the anti-qualifying pick, which makes it a fun bookend to the Podium pick, and also provides motivation to wade deeper into the grid for a pick.&lt;/p&gt;
&lt;p&gt;This also means the pole sitter strategy now only helps you in one of three categories. The other two require completely different analysis. That&amp;rsquo;s a game with real strategic breadth.&lt;/p&gt;
&lt;h2 id="does-the-pole-sitter-strategy-still-work"&gt;Does the pole sitter strategy still work?&lt;/h2&gt;
&lt;p&gt;It&amp;rsquo;s still a &lt;em&gt;good&lt;/em&gt; Podium pick, hitting the podium 75% of the time is nothing to sneeze at. But under balanced 3-2-1 scoring, it&amp;rsquo;s no longer a league-winning exploit. It&amp;rsquo;s a floor, not a ceiling.&lt;/p&gt;
&lt;p&gt;The players who will win Open Wheelers leagues are the ones who can pick Podium finishers with similar accuracy to the pole sitter &lt;em&gt;and&lt;/em&gt; identify the likely Overtaker &lt;em&gt;and&lt;/em&gt; read the DNF tea leaves. That&amp;rsquo;s three distinct skills instead of one repeated shortcut.&lt;/p&gt;
&lt;p&gt;Which is exactly what a fantasy game should reward: breadth of knowledge, not a single automatic insight applied 24 times.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;&lt;a href="https://stalefishlabs.com/use/openwheelers/"
target="_blank"
&gt;Open Wheelers&lt;/a&gt; is a casual F1 and IndyCar fantasy game for friends, built by &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/poleposition.png" type="image/png" length="0"/></item><item><title>I Built a Weather Engine That Tells You When to Ride, Play, or Water</title><link>https://stalefishlabs.com/read/2026-03-12-weather-engine-intro/</link><pubDate>Thu, 12 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-03-12-weather-engine-intro/</guid><description>One decision engine, three audiences: a yes/maybe/no verdict for riders, rec league players, and gardeners.</description><content:encoded>&lt;p&gt;Every mountain biker knows the ritual. It rained last night. You check the radar, clear now. You check the trail association&amp;rsquo;s Facebook page, nobody&amp;rsquo;s posted. You text your riding buddy: &amp;ldquo;Think the trails are good?&amp;rdquo; They don&amp;rsquo;t know either. So you either stay home and miss a perfectly rideable day, or show up and chew through muddy trails that needed another six hours to dry.&lt;/p&gt;
&lt;p&gt;I got tired of guessing so I built a weather decision engine that answers the question directly: &lt;strong&gt;is it too wet to ride?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Then I realized the same engine could answer two more questions: &lt;strong&gt;is this field playable?&lt;/strong&gt; and &lt;strong&gt;does my garden need watering?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This is the story of the Groundwise engine, a single Swift package that powers three iOS apps by asking the same underlying question from different perspectives.&lt;/p&gt;
&lt;h2 id="the-problem-with-did-it-rain"&gt;The Problem With &amp;ldquo;Did It Rain?&amp;rdquo;&lt;/h2&gt;
&lt;p&gt;Weather apps mostly tell you what &lt;em&gt;will&lt;/em&gt; happen, and to a lesser degree they can tell you &lt;em&gt;what&lt;/em&gt; happened. What they don&amp;rsquo;t tell you is &lt;em&gt;what it means&lt;/em&gt; for the specific thing you want to do.&lt;/p&gt;
&lt;p&gt;Half an inch of rain means completely different things depending on context:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;On a &lt;strong&gt;concrete skatepark&lt;/strong&gt;, it sheds in a couple hours. You&amp;rsquo;re probably fine by afternoon.&lt;/li&gt;
&lt;li&gt;On a &lt;strong&gt;dirt trail&lt;/strong&gt;, it might need 24 hours with good sun and wind. Or 48 hours or more if it&amp;rsquo;s overcast and cold.&lt;/li&gt;
&lt;li&gt;On a &lt;strong&gt;clay tennis court&lt;/strong&gt;, you might be waiting even longer, clay holds moisture and damages easily when wet.&lt;/li&gt;
&lt;li&gt;In a &lt;strong&gt;container garden on a sunny patio&lt;/strong&gt;, that half inch drained through the potting mix hours ago and your herbs might need water again tomorrow.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The answer isn&amp;rsquo;t just &amp;ldquo;how much rain,&amp;rdquo; it&amp;rsquo;s how much rain, on what surface, how long ago, and what the drying conditions have been since.&lt;/p&gt;
&lt;h2 id="from-weather-data-to-a-verdict"&gt;From Weather Data to a Verdict&lt;/h2&gt;
&lt;p&gt;So I built a weather analysis engine that takes in a handful of weather observations and produces a single categorical verdict regarding an action on a specific surface: &lt;strong&gt;Yes&lt;/strong&gt;, &lt;strong&gt;Maybe&lt;/strong&gt;, or &lt;strong&gt;No&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The inputs are straightforward, stuff you&amp;rsquo;d get from any weather API:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Precipitation totals (last 24, 48, and 72 hours)&lt;/li&gt;
&lt;li&gt;How long since rain ended&lt;/li&gt;
&lt;li&gt;Whether it was light, steady, or a downpour&lt;/li&gt;
&lt;li&gt;Current temperature, wind speed, humidity, and cloud cover&lt;/li&gt;
&lt;li&gt;Whether it&amp;rsquo;s actively raining right now&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Plus one critical piece of context: &lt;strong&gt;what surface are you asking about?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The engine defines 17 surface types across three categories. Each has a drying multiplier that controls how fast moisture clears, for example:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Surface&lt;/th&gt;
&lt;th&gt;Drying Speed&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Metal skateboard ramps&lt;/td&gt;
&lt;td&gt;2.8x&lt;/td&gt;
&lt;td&gt;Fastest, sheds water, heats up quickly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Artificial turf&lt;/td&gt;
&lt;td&gt;2.5x&lt;/td&gt;
&lt;td&gt;Engineered to drain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Asphalt&lt;/td&gt;
&lt;td&gt;2.2x&lt;/td&gt;
&lt;td&gt;Dark surface, absorbs heat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concrete&lt;/td&gt;
&lt;td&gt;2.0x&lt;/td&gt;
&lt;td&gt;Drains well, slower to heat than asphalt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Potting mix&lt;/td&gt;
&lt;td&gt;2.0x&lt;/td&gt;
&lt;td&gt;Loose, fast-draining soil&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compost&lt;/td&gt;
&lt;td&gt;1.2x&lt;/td&gt;
&lt;td&gt;Active decomposition generates some heat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dirt/ground&lt;/td&gt;
&lt;td&gt;1.0x&lt;/td&gt;
&lt;td&gt;Baseline, absorbs and holds water&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Natural grass&lt;/td&gt;
&lt;td&gt;1.0x&lt;/td&gt;
&lt;td&gt;Root systems retain moisture&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amended soil (mulched)&lt;/td&gt;
&lt;td&gt;0.7x&lt;/td&gt;
&lt;td&gt;Mulch deliberately slows evaporation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clay&lt;/td&gt;
&lt;td&gt;0.7x&lt;/td&gt;
&lt;td&gt;Dense, holds water, slow to release&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That 4x difference between metal and clay isn&amp;rsquo;t just a number, it&amp;rsquo;s the difference between &amp;ldquo;rideable in an hour&amp;rdquo; and &amp;ldquo;unplayable until tomorrow.&amp;rdquo;&lt;/p&gt;
&lt;h2 id="the-decision-pipeline"&gt;The Decision Pipeline&lt;/h2&gt;
&lt;p&gt;The engine runs checks in a specific order, with the most critical conditions evaluated first:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Is the ground frozen?&lt;/strong&gt; If there&amp;rsquo;s an ice or snow hazard, the verdict is No regardless of moisture. This check alone is about 360 lines of code, freeze/thaw cycles are surprisingly complex. A concrete skatepark clears ice in 6 hours at 55°F, but a dirt trail might need 72 hours at 45°F to fully thaw and dry.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Is it actively raining?&lt;/strong&gt; Straightforward gate. If precipitation is falling, the answer is No (or Maybe for very light drizzle on a fast-draining surface with strong drying conditions).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Calculate a wetness score.&lt;/strong&gt; This is where the real modeling happens. The engine combines:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;rain score&lt;/strong&gt; weighted toward recent precipitation (70% from last 24h, 20% from 24-48h, 10% from 48-72h)&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;timing score&lt;/strong&gt; that decays linearly over 24 hours since rain ended&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;drying strength&lt;/strong&gt; composite that factors in temperature, wind, humidity, and cloud cover&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Residual wetness&lt;/strong&gt; for absorbent surfaces, heavy rain on dirt or clay retains moisture well beyond what the timing score captures&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;4. Apply the verdict thresholds.&lt;/strong&gt; A wetness score below 0.3 means Yes (dry enough). Between 0.3 and 0.6 means Maybe (borderline). Above 0.6 means No (too wet).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Sensitivity veto.&lt;/strong&gt; High-sensitivity surfaces (like newly seeded lawns or natural grass athletic fields) get pushed from Maybe toward a more protective verdict. The engine would rather tell you to wait than let you damage a surface that&amp;rsquo;s expensive to repair.&lt;/p&gt;
&lt;h2 id="one-engine-two-perspectives"&gt;One Engine, Two Perspectives&lt;/h2&gt;
&lt;p&gt;If you&amp;rsquo;re wondering how the above, which is described in terms of &lt;strong&gt;using&lt;/strong&gt; a surface, could possibly apply to a container garden, it&amp;rsquo;s a fair question. And this is where the engine morphed into serving a series of apps instead of just one:&lt;/p&gt;
&lt;p&gt;The engine calculates a &lt;strong&gt;wetness score&lt;/strong&gt;. For riders and athletes, high wetness is bad, you want dry conditions. For gardeners, high wetness is good, it means you can skip watering.&lt;/p&gt;
&lt;p&gt;Same math, opposite interpretation:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Wetness Score&lt;/th&gt;
&lt;th&gt;Rider/Athlete View&lt;/th&gt;
&lt;th&gt;Gardener View&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Low (&amp;lt; 0.3)&lt;/td&gt;
&lt;td&gt;Yes, go ride!&lt;/td&gt;
&lt;td&gt;Yes, water today&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium (0.3-0.6)&lt;/td&gt;
&lt;td&gt;Maybe, check conditions&lt;/td&gt;
&lt;td&gt;Maybe, check soil&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High (&amp;gt; 0.6)&lt;/td&gt;
&lt;td&gt;No, too wet&lt;/td&gt;
&lt;td&gt;No, skip watering&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The engine calls this &lt;code&gt;EngineMode&lt;/code&gt;, either &lt;code&gt;wetnessConcern&lt;/code&gt; or &lt;code&gt;drynessConcern&lt;/code&gt;. A single enum that flips the entire interpretation layer while keeping the underlying moisture model identical.&lt;/p&gt;
&lt;p&gt;Yardwise (the gardening app) uses slightly shifted thresholds, 0.15 and 0.45 instead of 0.3 and 0.6, because the stakes of getting it wrong are lower. Missing a watering day is a mild inconvenience. Riding a muddy trail damages the trail. Playing softball on a muddy clay infield isn&amp;rsquo;t great. You get the idea.&lt;/p&gt;
&lt;h2 id="why-not-just-show-a-percentage"&gt;Why Not Just Show a Percentage?&lt;/h2&gt;
&lt;p&gt;Early versions of the apps showed a moisture percentage, and it wasn&amp;rsquo;t good. I didn&amp;rsquo;t know what to do with &amp;ldquo;the trail is at 47% moisture.&amp;rdquo; Is that rideable? It depends on the surface, on your own risk/comfort tolerance, on whether you care about trail damage, etc. A percentage pushes the decision back onto the user, which is exactly the problem the app was supposed to solve.&lt;/p&gt;
&lt;p&gt;The solution was to simplify down to three states: Yes/Maybe/No, with an explanation of &lt;em&gt;why&lt;/em&gt;. The Maybe state exists specifically for conditions where reasonable people would disagree. I have near me both public and private mountain bike trails, and the tolerance for mud varies widely based on level of traffic, so Maybe is when deferring to rider&amp;rsquo;s judgement is actually correct. The engine shows you the contributing factors (temperature is helping, but humidity is holding things back, for example) so you can make the final call.&lt;/p&gt;
&lt;p&gt;Each verdict also carries a &lt;strong&gt;confidence level&lt;/strong&gt; (Low, Medium, High). Confidence drops when timing data is missing, when rain has been patchy and unpredictable, or when the wetness score lands right on a threshold boundary.&lt;/p&gt;
&lt;h2 id="whats-coming-in-this-series"&gt;What&amp;rsquo;s Coming in This Series&lt;/h2&gt;
&lt;p&gt;If you find talk of drying multipliers and residual wetness scores riveting, then hold on to your butts! This is merely the first article in a series about building the Groundwise engine. Coming up:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Turning Raw Weather Into a Drying Model&lt;/strong&gt; - The math behind drying strength, residual wetness, and why rain intensity matters as much as total accumulation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One Engine, Three Apps&lt;/strong&gt; - How to structure a Swift package that serves multiple products with different surface libraries and threshold calibrations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Edge Cases That Break Your Weather Model&lt;/strong&gt; - Freeze/thaw cycles, cold-weather plant dormancy, the surprising complexity of &amp;ldquo;is snow melting?&amp;rdquo;, and dispatches from the great Nashville ice storm of 2026&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flipping the Question&lt;/strong&gt; - How the Yardwise inversion works, including manual watering tracking and evaporative demand suppression&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Building Confidence Into Uncertain Verdicts&lt;/strong&gt; - Why three states beat a percentage, and how to communicate uncertainty without creating anxiety&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-apps"&gt;The Apps&lt;/h2&gt;
&lt;p&gt;The Groundwise engine powers three apps, all available for iOS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://stalefishlabs.com/use/ridewise/"
target="_blank"
&gt;&lt;strong&gt;Ridewise&lt;/strong&gt;&lt;/a&gt;, ride conditions for trails, skateparks, bike parks, and pump tracks&lt;/li&gt;
&lt;li&gt;&lt;a href="https://stalefishlabs.com/use/fieldwise/"
target="_blank"
&gt;&lt;strong&gt;Fieldwise&lt;/strong&gt;&lt;/a&gt;, field conditions for rec league sports on grass, turf, clay, and hard courts&lt;/li&gt;
&lt;li&gt;&lt;a href="https://stalefishlabs.com/use/yardwise/"
target="_blank"
&gt;&lt;strong&gt;Yardwise&lt;/strong&gt;&lt;/a&gt;, watering guidance for casual gardeners with lawns, beds, and containers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All three are built by &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;, a small indie studio focused on simple tools for getting outside and doing things together.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;If you&amp;rsquo;ve built something that turns raw data into human decisions, I&amp;rsquo;d love to hear how you approached the threshold and confidence problem. Drop a comment or find us on &lt;a href="https://bsky.app/profile/stalefishlabs.bsky.social"
target="_blank"
&gt;Bluesky&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/mtbstorm.png" type="image/png" length="0"/></item><item><title>Building a Better Kicker: The Physics Behind the Jump Simulator</title><link>https://stalefishlabs.com/read/2026-03-10-building-a-better-kicker/</link><pubDate>Tue, 10 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-03-10-building-a-better-kicker/</guid><description>How projectile physics, air drag, landing geometry, and a 'Dial It In' optimizer power a free browser-based jump design tool for BMX, MTB, and motorcycle riders.</description><content:encoded>&lt;p&gt;Anyone who has ever flown through the air on two wheels can appreciate this: a rider staring at a jump and its gap, trying to picture the arc. How fast do I need to go? Will I clear it? Will the landing feel smooth or like a punch to the spine? The mental math is unreliable, the necessary intuition only gained with loads of experience, and the consequences of getting it wrong range from cuts and bruises to an ER visit and a stint on injured reserve.&lt;/p&gt;
&lt;p&gt;We built the &lt;a href="https://stalefishlabs.com/experiments/jump-simulator/"
&gt;Jump Simulator&lt;/a&gt; to replace that guesswork with real physics. It&amp;rsquo;s a free, browser-based tool that lets BMX, MTB, and MX riders design gap jumps, simulate trajectories, and see exactly where (and how hard!) they&amp;rsquo;ll land before moving a single shovel of dirt.&lt;/p&gt;
&lt;h2 id="the-problem-with-gap-jump-design"&gt;The Problem with Gap Jump Design&lt;/h2&gt;
&lt;p&gt;Gap jumps are deceptively simple: a takeoff ramp, empty air, and a landing ramp. Three elements. But each one hides a web of variables. The takeoff angle determines how much speed converts to height versus distance. The lip height sets the launch point above the gap floor, and with the landing height establish if you&amp;rsquo;re working with doubles, a step-up, or an often dreaded step-down. The gap distance is the void you need to clear. The landing angle and height offset determine whether your wheels kiss the slope gently or slam into flat ground.&lt;/p&gt;
&lt;p&gt;Change any one variable and the entire trajectory shifts. A 5-degree steeper takeoff lip at the same speed can turn a clean landing into a 4-foot overshoot. A 2 mph drop in approach speed can leave you dangling over the gap&amp;rsquo;s leading edge leading to a monster case.&lt;/p&gt;
&lt;p&gt;Experienced riders develop intuition for this, but intuition fails at the margins, and the margins are exactly where crashes happen.&lt;/p&gt;
&lt;h2 id="projectile-motion-with-drag"&gt;Projectile Motion, With Drag&lt;/h2&gt;
&lt;p&gt;At its core, the simulator solves a projectile motion problem, but not the textbook version. Once a rider leaves the lip, two forces act on them: gravity pulling them down, and air drag slowing them through the arc.&lt;/p&gt;
&lt;p&gt;The launch velocities are straightforward:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;vx = speed × cos(angle)
vy = speed × sin(angle)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Where gravity &lt;code&gt;g = 9.81 m/s²&lt;/code&gt; and &lt;code&gt;angle&lt;/code&gt; is the takeoff lip angle. In a vacuum, that&amp;rsquo;s all you need, horizontal velocity stays constant, vertical velocity decays linearly. But at real-world speeds, air drag matters, especially for motorcycles carrying hundreds of pounds through the air at 40+ mph.&lt;/p&gt;
&lt;p&gt;The drag force at any instant is:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;F_drag = ½ × ρ × CdA × v²
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Where &lt;code&gt;ρ&lt;/code&gt; is air density (1.225 kg/m³), &lt;code&gt;CdA&lt;/code&gt; is the rider+vehicle&amp;rsquo;s drag area, and &lt;code&gt;v&lt;/code&gt; is the current speed. The deceleration is &lt;code&gt;F_drag / mass&lt;/code&gt;, applied opposite to the velocity vector. Math nerdery aside, a heavier vehicle feels less deceleration from the same drag force. Or to put it even clearer, a lighter-weight BMX setup loses speed faster than a motorcycle at the same velocity.&lt;/p&gt;
&lt;p&gt;Because drag depends on velocity, which changes continuously, there&amp;rsquo;s no clean closed-form solution. The simulator uses Euler numerical integration with a 0.0005-second timestep, updating position and velocity at each step. This produces a trajectory that&amp;rsquo;s subtly different from a pure parabola, the arc is slightly asymmetric, with the descent steeper than the ascent. That&amp;rsquo;s how real jumps work.&lt;/p&gt;
&lt;p&gt;What all this stuff is working toward, and where the complexity lies, is detecting &lt;em&gt;where&lt;/em&gt; on the landing ramp the rider actually touches down.&lt;/p&gt;
&lt;h2 id="landing-detection-slope-geometry"&gt;Landing Detection: Slope Geometry&lt;/h2&gt;
&lt;p&gt;The landing ramp isn&amp;rsquo;t a point, it&amp;rsquo;s a sloped surface with a defined start, angle, and length. The simulator steps through the trajectory in 0.001-second increments, checking at each step whether the rider&amp;rsquo;s position has crossed below the landing ramp&amp;rsquo;s surface.&lt;/p&gt;
&lt;p&gt;The ramp surface is defined as a line from the gap&amp;rsquo;s far edge, angled downward at the landing angle. At each timestep, the simulator compares the rider&amp;rsquo;s y-position against the ramp&amp;rsquo;s y-value at that x-position. When the rider crosses below the surface, we interpolate to find the precise landing point.&lt;/p&gt;
&lt;p&gt;This discrete stepping approach is more robust than solving the intersection analytically, because it handles edge cases gracefully, riders that land past the ramp, riders that never reach it, and riders that graze the lip on the way down. Sadly the simulator doesn&amp;rsquo;t account for deliberate stylish nose taps at the lip on landing, but that doesn&amp;rsquo;t mean you shouldn&amp;rsquo;t do them!&lt;/p&gt;
&lt;h2 id="the-verdict-system"&gt;The Verdict System&lt;/h2&gt;
&lt;p&gt;So to rule a jump successful (safe!), landing on the ramp is necessary but not sufficient. A rider who lands on the ramp at a 45-degree descent angle when the ramp slopes at 25 degrees is going to be in for a rough time. The angle mismatch means the bike slams into the surface rather than matching it.&lt;/p&gt;
&lt;p&gt;The simulator classifies every jump into one of five verdicts:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Clean&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lands on ramp, descent angle within 15° of ramp angle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tight&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lands on ramp but very close to the start or end (ratio &amp;lt; 0.15 or &amp;gt; 0.85)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hard Landing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lands on ramp but descent angle mismatch exceeds 15°&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Short&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lands before the ramp (didn&amp;rsquo;t clear the gap)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Long&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lands past the ramp (overshot)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The 15-degree tolerance is generous on purpose. Real-world suspension and body positioning absorb some mismatch. But beyond 15 degrees, even a skilled rider is going to feel it.&lt;/p&gt;
&lt;h2 id="impact-g-force"&gt;Impact G-Force&lt;/h2&gt;
&lt;p&gt;Beyond the verdict, the simulator estimates the G-force on landing. It calculates the component of the rider&amp;rsquo;s velocity perpendicular to the landing ramp surface, the part that your body and suspension have to absorb, and converts it to a G-force estimate assuming a short deceleration window.&lt;/p&gt;
&lt;p&gt;This number is especially useful when comparing vehicle types. A motorcycle landing at the same trajectory angle as a BMX hits the ramp with more perpendicular velocity because of its higher approach speed, even if the descent angle matches the ramp perfectly. A &amp;ldquo;Clean&amp;rdquo; verdict with 4G of impact is a very different experience on a 25 pound BMX bike than on a 200+ pound dirt bike.&lt;/p&gt;
&lt;h2 id="three-vehicles-three-physics-profiles"&gt;Three Vehicles, Three Physics Profiles&lt;/h2&gt;
&lt;p&gt;BMX, MTB, and motorcycles ride fundamentally differently. A 20-inch BMX bike at 15 mph feels fast. A 27.5- or 29-inch mountain bike at 15 mph feels moderate. A 250cc dirt bike at 15 mph is barely rolling. The vehicles have different wheelbases, different masses, different drag profiles, and different speed envelopes.&lt;/p&gt;
&lt;p&gt;The simulator ships with three presets:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;BMX&lt;/th&gt;
&lt;th&gt;MTB&lt;/th&gt;
&lt;th&gt;Moto&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wheel diameter&lt;/td&gt;
&lt;td&gt;20&amp;quot;&lt;/td&gt;
&lt;td&gt;27.5&amp;quot;&lt;/td&gt;
&lt;td&gt;21&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wheelbase&lt;/td&gt;
&lt;td&gt;37&amp;quot;&lt;/td&gt;
&lt;td&gt;41&amp;quot;&lt;/td&gt;
&lt;td&gt;58&amp;quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mass (rider + vehicle)&lt;/td&gt;
&lt;td&gt;90 kg&lt;/td&gt;
&lt;td&gt;100 kg&lt;/td&gt;
&lt;td&gt;190 kg&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drag area (CdA)&lt;/td&gt;
&lt;td&gt;0.40 m²&lt;/td&gt;
&lt;td&gt;0.45 m²&lt;/td&gt;
&lt;td&gt;0.65 m²&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed range&lt;/td&gt;
&lt;td&gt;5–25 mph&lt;/td&gt;
&lt;td&gt;8–30 mph&lt;/td&gt;
&lt;td&gt;15–55 mph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gap range&lt;/td&gt;
&lt;td&gt;2–40 ft&lt;/td&gt;
&lt;td&gt;2–60 ft&lt;/td&gt;
&lt;td&gt;8–80 ft&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Unlike a simple projectile calculator, the presets &lt;em&gt;do&lt;/em&gt; change the physics. Mass determines how much air drag decelerates the vehicle mid-flight, a 190 kg motorcycle shrugs off drag that would noticeably slow a 90 kg BMX setup. The drag area (CdA) captures the vehicle&amp;rsquo;s frontal profile: a motorcycle with a seated rider presents a larger surface to the air than a tucked BMX rider. Together, mass and CdA produce meaningfully different trajectories at the same launch speed and angle.&lt;/p&gt;
&lt;p&gt;And yes I do realize I&amp;rsquo;m being incredibly cavalier switching between metric and Imperial units of measurement, sorry I still think in terms of Imperial units, just can&amp;rsquo;t get the length of King Henry I&amp;rsquo;s foot out of my head. But physics is typically better done in metric units, so I&amp;rsquo;m using it mostly when discussing equations. The actual jump simulator tool allows you to switch between the two, and unlike me it is consistent throughout!&lt;/p&gt;
&lt;h2 id="dial-it-in-the-optimizer"&gt;&amp;ldquo;Dial It In&amp;rdquo;: The Optimizer&lt;/h2&gt;
&lt;p&gt;The most useful feature isn&amp;rsquo;t the physics or the visualization. It&amp;rsquo;s a single button: &lt;strong&gt;Dial It In&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;When a rider has set up their ramp geometry but can&amp;rsquo;t find a speed that yields a clean landing, the optimizer searches the full speed range for the current sport preset, evaluating the physics at each 0.5 mph increment. It collects every speed that produces a &amp;ldquo;Clean&amp;rdquo; verdict, then picks the one closest to the rider&amp;rsquo;s current speed setting.&lt;/p&gt;
&lt;p&gt;If no speed in the range works, the ramp geometry is fundamentally incompatible, it goes further: adjusting the landing angle in 1-degree increments to find a viable configuration. The algorithm returns the smallest set of changes needed to make the jump work.&lt;/p&gt;
&lt;p&gt;This is the feature that saves trips to the ER. A rider can set up their dream gap, hit Dial It In, and immediately know whether the jump is achievable and exactly what speed to target.&lt;/p&gt;
&lt;h2 id="rendering-canvas-2d-not-webgl"&gt;Rendering: Canvas 2D, Not WebGL&lt;/h2&gt;
&lt;p&gt;Anyone who ever played the old Stunt Cycle arcade game will appreciate this decision (yeah I&amp;rsquo;m aging myself, I know)&amp;hellip;I deliberately chose Canvas 2D over Three.js or WebGL for this tool. The visualization is fundamentally two-dimensional, a side profile of the jump, and adding a third dimension would obscure the information riders actually need: trajectory shape, landing point, and angle matching.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://stalefishlabs.com/read/2026-03-10-building-a-better-kicker/stuntcycle.png" alt="" title="The Stunt Cycle arcade game used 2D side-to-side motorcycle jumps"&gt;&lt;/p&gt;
&lt;p&gt;The rendering system uses a dynamic coordinate transformation that maps real-world feet/meters to canvas pixels. The scene auto-scales to fit the jump geometry, keeping the takeoff lip as the origin point and expanding the viewport as the gap distance or peak height grows.&lt;/p&gt;
&lt;h3 id="the-vehicle"&gt;The Vehicle&lt;/h3&gt;
&lt;p&gt;Each vehicle is drawn procedurally. The bicycles get two wheels, a frame, handlebars, and a standing rider silhouette. The motorcycle gets thicker tires, a swing-arm, front fork, engine block, exhaust pipe, seat, fenders, and a seated rider with a helmet and visor, legs bent down to the pegs instead of standing on pedals. During flight, the vehicle rotates naturally using the actual descent angle at each point in the trajectory:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;rotation = atan2(-vy, vx)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This produces the realistic nose-up-on-takeoff, level-at-peak, nose-down-into-landing motion that riders recognize from real footage.&lt;/p&gt;
&lt;h3 id="ghost-trails"&gt;Ghost Trails&lt;/h3&gt;
&lt;p&gt;Every time a rider changes a parameter, the previous trajectory is saved as a ghost trail, a semi-transparent dashed arc that lingers for 2 seconds before fading. Up to three trails are kept simultaneously.&lt;/p&gt;
&lt;p&gt;Ghost trails are the key to iterative design. A rider can nudge the speed up by 1 mph and immediately see how the new arc compares to the old one. Without ghosts, each change exists in isolation. With them, the design process becomes visual diff.&lt;/p&gt;
&lt;h2 id="the-animation-state-machine"&gt;The Animation State Machine&lt;/h2&gt;
&lt;p&gt;The simulator isn&amp;rsquo;t just a static trajectory plotter, it animates the full jump sequence. The animation runs through four phases:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Approach&lt;/strong&gt;: The bike rolls up the kicker surface, following the curved ramp profile&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flight&lt;/strong&gt;: Projectile motion through the air, following the calculated trajectory&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ride-off&lt;/strong&gt;: On a clean landing, the bike eases down the landing ramp for 1 second&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Crash&lt;/strong&gt;: On a miss, the bike freezes at the impact point with a red crash indicator&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each phase transitions to the next based on position triggers, not timers. The approach phase ends when the bike reaches the lip. The flight phase ends when landing detection fires. The ride-off or crash phase runs for a fixed duration, then the animation holds on the final frame.&lt;/p&gt;
&lt;p&gt;Playback speed is adjustable from 0.25x to 2x, which is useful for studying the critical moment when the bike meets the landing ramp.&lt;/p&gt;
&lt;h2 id="kicker-geometry-the-radius-problem"&gt;Kicker Geometry: The Radius Problem&lt;/h2&gt;
&lt;p&gt;The takeoff ramp isn&amp;rsquo;t a straight slope, it&amp;rsquo;s a curved surface defined by a radius. The kicker radius determines how gradually the rider transitions from horizontal to the lip angle. A tight radius (small value) creates an abrupt, poppy kicker. A large radius creates a smooth, flowing ramp that&amp;rsquo;s easier to ride but requires more speed to clear a similar gap as its poppier cousin.&lt;/p&gt;
&lt;p&gt;The simulator calculates the kicker radius automatically from the lip angle and lip height:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;radius = height / (1 - cos(angle))
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This is the same circular arc math used in skateboard ramp design, and it ensures the ramp profile matches real-world construction. The curve is rendered on the canvas as a series of line segments following the arc, giving riders an accurate picture of the ramp&amp;rsquo;s shape and size.&lt;/p&gt;
&lt;h2 id="what-we-learned-building-it"&gt;What We Learned Building It&lt;/h2&gt;
&lt;p&gt;So all the tech implementation details aside, we learned a few things while building this tool:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Landing angle matters more than distance.&lt;/strong&gt; The early prototype only checked whether the rider cleared the gap. Testers kept asking &amp;ldquo;why does this feel wrong?&amp;rdquo;, the answer was always angle mismatch. Try jumping to flat in the real world and you&amp;rsquo;ll feel this mismatch in your teeth! Adding the 15-degree tolerance check and the &amp;ldquo;Hard Landing&amp;rdquo; verdict transformed the tool from a distance calculator into an actual design tool.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ghost trails were an afterthought that became essential.&lt;/strong&gt; Ghost trails became a need because the canvas flickered between parameter changes and revealed a lack of visual continuity. Once created, riders started using them to compare trajectories, and now they&amp;rsquo;re the primary way to iterate on jump designs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Dial It In optimizer needed to be conservative.&lt;/strong&gt; Early versions would suggest radical changes, &amp;ldquo;increase speed by 12 mph and change the landing angle by 20 degrees.&amp;rdquo; That&amp;rsquo;s technically correct but useless. The final version minimizes the delta from the rider&amp;rsquo;s current settings, making suggestions that feel like refinements rather than redesigns.&lt;/p&gt;
&lt;h2 id="try-it"&gt;Try It&lt;/h2&gt;
&lt;p&gt;The &lt;a href="https://stalefishlabs.com/experiments/jump-simulator/"
&gt;Jump Simulator&lt;/a&gt; runs entirely in your browser. No account, no install, no tracking. Pick your vehicle, dial in your ramp geometry, and simulate before you build. Let us know if it helps you, and feel free to recommend improvements. Send it!&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The &lt;a href="https://stalefishlabs.com/experiments/jump-simulator/"
&gt;Jump Simulator&lt;/a&gt; is a free experiment from &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/screenshot-2026-03-10-at-11.00.27-pm.png" type="image/png" length="0"/></item><item><title>The None Trap: Game Theory &amp; the Formula 1 DNF Pick</title><link>https://stalefishlabs.com/read/2026-03-09-the-none-trap-dnf-game-theory/</link><pubDate>Mon, 09 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2026-03-09-the-none-trap-dnf-game-theory/</guid><description>Is picking 'nobody to DNF' the safe move in fantasy F1? We ran the numbers on a decade of retirements to find out.</description><content:encoded>&lt;p&gt;When designing the DNF pick category for our fantasy racing mobile app &lt;a href="https://stalefishlabs.com/use/openwheelers/"
target="_blank"
&gt;Open Wheelers&lt;/a&gt;, I included an option that felt generous: pick &lt;strong&gt;None&lt;/strong&gt;. If you believe nobody will retire from the race, select None and collect 2 points if you&amp;rsquo;re right. Otherwise, pick a specific driver you think will be among the first three to retire, and earn 3, 2, or 1 points depending on how early they go out. We take our fantasy games seriously, I want them to be engaging and fun, and I care a lot about getting things right. So I decided to dig a bit deeper on this rule mechanic because it felt suspicious.&lt;/p&gt;
&lt;p&gt;On the surface, None looks like the smart play. I even worried perhaps it would become the ONLY play and break the game; I thought maybe everyone would figure out that None is the no-brainer pick to beat the system. After all, modern F1 cars are absurdly reliable. Races where every car finishes make headlines now instead of being unthinkable. So why not make None your weekly lock and bank the free 2 points?&lt;/p&gt;
&lt;p&gt;Because it&amp;rsquo;s a trap. And the data proves it despite my instinct toward the opposite.&lt;/p&gt;
&lt;h2 id="a-decade-of-did-not-finishes"&gt;A decade of Did-Not-Finishes&lt;/h2&gt;
&lt;p&gt;I looked at every F1 race from 2016 through 2024, 190 grands prix across nine seasons. Here&amp;rsquo;s the reality of how often &amp;ldquo;nobody retires&amp;rdquo; actually happens:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Season&lt;/th&gt;
&lt;th&gt;Races&lt;/th&gt;
&lt;th&gt;Avg DNFs per race&lt;/th&gt;
&lt;th&gt;Zero-DNF races&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2016&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;3.1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2017&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2018&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;2.8&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2019&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;2.6&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2020&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;2.6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2021&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;2.1&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2022&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;2.8&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;2.3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2024&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;1.7&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Across the entire dataset, roughly &lt;strong&gt;12 out of 190 races&lt;/strong&gt; had zero retirements. That&amp;rsquo;s about 6% of the time. Put another way, if you picked None for every race across a full decade, you&amp;rsquo;d be right once every 16 races. At 2 points per victorious pick, that means a steady None pick throughout an entire season would average a grand total of 3 points per season&amp;hellip;yikes!&lt;/p&gt;
&lt;h2 id="the-math-doesnt-lie"&gt;The math doesn&amp;rsquo;t lie&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s compare two hypothetical players over a 24-race season like 2024:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Player A: The &amp;ldquo;None&amp;rdquo; loyalist&lt;/strong&gt;
Picks None every single race. Correct twice. Season total: &lt;strong&gt;4 points&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Player B: The student of unreliability&lt;/strong&gt;
Studies which teams are struggling with reliability or drivers who just seem to have a knack for finding the gravel, and targets those drivers. Even with modest accuracy, hitting a top-3 DNF just 5 times across 24 races, the floor is 5 points and the ceiling is 15 points depending on whether those picks land 1st, 2nd, or 3rd out.&lt;/p&gt;
&lt;p&gt;And 5 hits isn&amp;rsquo;t ambitious. In 2023, Esteban Ocon and Logan Sargeant each retired from &lt;strong&gt;7 of 22 races&lt;/strong&gt;. In 2022, four different drivers, Zhou Guanyu, Valtteri Bottas, Carlos Sainz, and Fernando Alonso, hit 6 retirements each. If you identified any of those drivers early and kept picking them, you&amp;rsquo;d outscore None by a landslide.&lt;/p&gt;
&lt;h2 id="but-cars-are-getting-more-reliable"&gt;But cars ARE getting more reliable&lt;/h2&gt;
&lt;p&gt;This is true, and it&amp;rsquo;s the one wrinkle worth watching. F1&amp;rsquo;s finishing rate has climbed steadily from about 86% in 2016 to over 91% in 2024. The sport saw its first-ever consecutive zero-DNF races to open the 2024 season in Bahrain and Saudi Arabia.&lt;/p&gt;
&lt;p&gt;If that trend continues, and the 2026 regulations could easily reverse it, zero-DNF races might climb from 2-3 per season toward 4-5. Even at that rate, None at 2 points per hit would still max out at 8-10 points across a season, which a savvy driver picker can match by nailing just three or four retirements in the top-3 positions. It&amp;rsquo;s worth noting that the opening race of 2026 had not only three DNFs but also two DNSs and one Lance Stroll driving the race effectively for test purposes since he pitted multiple times and ultimately finished with only 43 laps (the leaders did 58 laps). DNSs and turning a real race into a test are almost unheard of&amp;hellip;so it&amp;rsquo;s looking like 2026 may move us back toward the DNF mean instead of cementing a new age of reliability.&lt;/p&gt;
&lt;h2 id="what-this-means-for-strategy"&gt;What this means for strategy&lt;/h2&gt;
&lt;p&gt;Back to the None pick, I verified it isn&amp;rsquo;t the safe move. It&amp;rsquo;s the &lt;em&gt;passive&lt;/em&gt; move. It says &amp;ldquo;I don&amp;rsquo;t want to think about this category.&amp;rdquo; And passivity rarely wins in games with point differentials this tight.&lt;/p&gt;
&lt;p&gt;The real edge in the DNF category comes from paying attention to things most fantasy players ignore: which teams are running new and untested power units, which drivers have a reputation for aggressive racing that invites contact, and which cars have been nursing recurring mechanical issues across practice sessions.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the kind of knowledge that separates pub-league champions from the field. And it&amp;rsquo;s exactly the kind of informed, opinionated pick-making I built Open Wheelers around.&lt;/p&gt;
&lt;p&gt;So go ahead and pick None if you genuinely believe every car is making it to the checkered flag. Just know that the odds are about 15-to-1 against you.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;&lt;a href="https://stalefishlabs.com/use/openwheelers/"
target="_blank"
&gt;Open Wheelers&lt;/a&gt; is a casual F1 and IndyCar fantasy game for friends, built by &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/dnf.png" type="image/png" length="0"/></item><item><title>Every Skateboarder's First Question, Answered With a Sticker</title><link>https://stalefishlabs.com/read/2026-03-07-transition-facts/</link><pubDate>Sat, 07 Mar 2026 00:00:00 -0800</pubDate><guid>https://stalefishlabs.com/read/2026-03-07-transition-facts/</guid><description>Skateboarders who ride ramps always ask the same questions. A nutrition label-style sticker gives them the answers without anyone having to repeat themselves.</description><content:encoded>&lt;p&gt;The first thing a skateboarder asks when they roll up to a new ramp is some variation of the same question: &amp;ldquo;What&amp;rsquo;s the tranny?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Not far behind: &amp;ldquo;How tall is it?&amp;rdquo; Then &amp;ldquo;How wide?&amp;rdquo; Then &amp;ldquo;What&amp;rsquo;s the flat?&amp;rdquo; And if the ramp&amp;rsquo;s owner is around, they answer. Again. For the hundredth time. Sometimes they don&amp;rsquo;t remember the exact specs anymore. Sometimes they give a number that&amp;rsquo;s close enough. Sometimes they just shrug and say &amp;ldquo;ride it and find out.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;BMX riders do the same thing, just with different priorities. And the questions aren&amp;rsquo;t idle curiosity, they&amp;rsquo;re practical. The dimensions of a ramp fundamentally change how it rides. A 5-foot mini with 8.5 feet of transition rides completely differently than a 5-foot mini with 7 feet of transition. One will feel mellow and flowy, the other steep and snappy and make it easier to lock certain tricks. Same height, totally different experience. Width determines how long you can cruise grinds and slides, or in the case of a vert ramp how many times you can switch directions on airs. Flat bottom determines how much speed you carry between walls and how much time you have to think between tricks. Coping style determines how grinds feel and whether your wheels are going to hang up. Surface material tells you how slick a ramp is, and whether you&amp;rsquo;re about to have a smooth ride or a sketchy one.&lt;/p&gt;
&lt;p&gt;These aren&amp;rsquo;t nerdy details. They&amp;rsquo;re the spec sheet for the experience you&amp;rsquo;re about to have.&lt;/p&gt;
&lt;h2 id="the-repetition-problem"&gt;The repetition problem&lt;/h2&gt;
&lt;p&gt;If you&amp;rsquo;ve ever built a ramp, in your backyard, at a DIY spot, or for a local park, you know the drill. People show up, they&amp;rsquo;re stoked, and they want to know what they&amp;rsquo;re riding. You tell them. Then the next crew shows up and you tell them again. Your buddy brings a friend the following weekend and you tell them too. You post a clip online and the first comment is &amp;ldquo;specs?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s not annoying, exactly. It&amp;rsquo;s flattering that people care, and it&amp;rsquo;s a totally valid question. But it&amp;rsquo;s repetitive, and over time the numbers start to blur. Was the transition 7 feet or 7.5? Did you go with 14 feet of flat or 15? You built the thing three years ago and the napkin sketch is long gone.&lt;/p&gt;
&lt;p&gt;Park owners and ramp builders face this at scale. A skatepark might have a dozen features, each with different dimensions that riders want to know. The information exists, someone measured and cut every piece of wood or formed every piece of concrete, but it lives in a set of plans that nobody can find, or in the builder&amp;rsquo;s head, or nowhere at all.&lt;/p&gt;
&lt;h2 id="the-nutrition-label-idea"&gt;The nutrition label idea&lt;/h2&gt;
&lt;p&gt;The solution hit me because of how obvious it is: a nutrition facts label, but for ramps.&lt;/p&gt;
&lt;p&gt;Everyone knows the format. You&amp;rsquo;ve seen it on every food package your entire life. It&amp;rsquo;s a clean, standardized layout that packs a lot of specs into a small space. It&amp;rsquo;s quickly scannable. It&amp;rsquo;s familiar. And most importantly, it solves the exact same problem: conveying a set of important specs to someone who needs them, in a format they already know how to read.&lt;/p&gt;
&lt;p&gt;Instead of calories and protein, you get height and transition radius. Instead of serving size, you get rideable width. Instead of ingredients, you get surface material. Same structure, completely different domain, but the format transfers perfectly because the underlying need is the same: &amp;ldquo;tell me what I&amp;rsquo;m dealing with, quickly.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Slap it up on the edge of the vert where nobody rides, or the side of the transition, even on the deck of the ramp by the coping. Now every skater who rolls up gets their questions answered before they even have to ask.&lt;/p&gt;
&lt;h2 id="building-the-generator"&gt;Building the generator&lt;/h2&gt;
&lt;p&gt;That&amp;rsquo;s the idea behind &lt;a href="https://stalefishlabs.com/experiments/transition-facts/"
&gt;Transition Facts&lt;/a&gt;, a tool in our Experiments section. You punch in the specs of your ramp — height, transition radius, width, flat bottom, coping type and diameter, surface material, and platform depth — and it generates a nutrition facts-style label that you can print as a sticker or a PDF.&lt;/p&gt;
&lt;p&gt;The design mimics the real FDA nutrition label closely enough that the format is instantly recognizable, but adapts the content entirely for ramp specs. The layout is intentionally dense in the way the original is — no wasted space, just the numbers that matter.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s a small tool. There&amp;rsquo;s no account to create, no app to download. Enter your specs, print your label, stick it on your ramp. Done.&lt;/p&gt;
&lt;h2 id="why-it-works"&gt;Why it works&lt;/h2&gt;
&lt;p&gt;The reason a sticker works better than just telling people is the same reason nutrition labels work better than asking the cashier what&amp;rsquo;s in your food. The information is &lt;em&gt;there&lt;/em&gt;, at the point of use, every time, for everyone, without requiring anyone&amp;rsquo;s time or memory.&lt;/p&gt;
&lt;p&gt;A ramp owner doesn&amp;rsquo;t have to be present. A park doesn&amp;rsquo;t have to staff someone to answer questions. A visiting skater doesn&amp;rsquo;t have to feel weird asking. The specs are just &lt;em&gt;on the ramp&lt;/em&gt;, the same way ingredients are just on the box.&lt;/p&gt;
&lt;p&gt;And there&amp;rsquo;s a secondary benefit: it settles arguments. &amp;ldquo;You sure that&amp;rsquo;s only a foot and a half of vert?&amp;rdquo; &amp;ldquo;Feels more like 2 feet!&amp;rdquo; Now there&amp;rsquo;s a label. Discussion over. Ride the ramp.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;&lt;a href="https://stalefishlabs.com/experiments/transition-facts/"
&gt;Transition Facts&lt;/a&gt; is a free tool in the &lt;a href="https://stalefishlabs.com/experiments/"
&gt;Stalefish Labs Experiments&lt;/a&gt; section. No account needed — just specs in, sticker out.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/screenshot-2026-03-09-at-11.09.22-pm.png" type="image/png" length="0"/></item><item><title>From Group Chat to App: How Going Digital Unlocked Better Fantasy Stats</title><link>https://stalefishlabs.com/read/2026-03-06-from-group-chat-to-app/</link><pubDate>Fri, 06 Mar 2026 00:00:00 -0800</pubDate><guid>https://stalefishlabs.com/read/2026-03-06-from-group-chat-to-app/</guid><description>Moving our F1 fantasy league from a text thread to a real app didn't just make things easier, it made the game better.</description><content:encoded>&lt;p&gt;&lt;a href="https://stalefishlabs.com/use/openwheelers/"
target="_blank"
&gt;Open Wheelers&lt;/a&gt; the app started life as a text thread. A group of friends with a love of Formula 1 just wanted a fun alternative to complex fantasy games with drafted teams, waivers, etc. So we played a manual game within a text thread, a gentleman&amp;rsquo;s agreement to get your picks in before the lights go out, and yours truly would manually tally results after each race, post the updated standings, and we&amp;rsquo;d argue about who was actually winning. It worked well enough for years. And it was completely holding the game back.&lt;/p&gt;
&lt;p&gt;The thing about a manual game is that your pick categories are limited to whatever a human scorekeeper (me!) can reasonably verify after a race. Podium finish? Easy, check the top 3. DNF? Straightforward enough, the list of retirements is right there. Fastest lap? Easyish, one name, but F1 stopped publishing it clearly when they stopped awarding points to drivers in 2025, so it required a search each week.&lt;/p&gt;
&lt;p&gt;So that&amp;rsquo;s what we were using: Podium, Fastest Lap, DNF. Three categories, three picks. Simple, fun, and perfectly adequate for a casual league among friends run out of a text thread. You may not think of the Messages app as a game engine, but it actually kinda worked.&lt;/p&gt;
&lt;p&gt;But &amp;ldquo;adequate&amp;rdquo; and &amp;ldquo;kinda&amp;rdquo; are dangerous words for someone who obsesses over game design.&lt;/p&gt;
&lt;h2 id="the-problem-with-fastest-lap"&gt;The problem with Fastest Lap&lt;/h2&gt;
&lt;p&gt;Turns out the mediocrity of our system had everything to do with the picks and points, and how they were born out of what was doable for a guy manually managing the stats. Take Fastest Lap, which sounds like an exciting stat. In practice, it&amp;rsquo;s one of the least interesting picks you can make, and here&amp;rsquo;s why: in today&amp;rsquo;s F1, the fastest lap is all too often just an index of the top cars in the race. It was actually a bit more interesting in the first years of our game when F1 actually gave out a point for Fastest Lap - then you&amp;rsquo;d sometimes see a driver deep in the pack pit late for fresh soft tires to try and snag a point. When F1 did away with that rule in 2025, Fastest Lap became a strategic afterthought, not a battle.&lt;/p&gt;
&lt;p&gt;More importantly, it basically became a proxy pick for the Podium, a pick we already had. So we wanted something that rewarded paying attention to the actual racing. Something with real variance from week to week, where deep knowledge of the grid actually mattered. And maybe even something that could reward a bit of grit for that driver who battles throughout the race with hard driving even if it doesn&amp;rsquo;t necessarily net them podium glory. Think Lewis Hamilton struggling at Ferrari but in some races rage-passing half the field from a poor qualifying start.&lt;/p&gt;
&lt;h2 id="enter-the-overtaker"&gt;Enter the Overtaker&lt;/h2&gt;
&lt;p&gt;The stat we really wanted was &lt;strong&gt;most positions gained&lt;/strong&gt;: the driver who climbs the most spots from their starting position to their finishing position. In F1, this is sometimes called the &amp;ldquo;overtaker&amp;rdquo; award. It rewards the most dynamic racing of the afternoon: the recovery drives, the strategic masterstrokes, the scrappy midfield battles that don&amp;rsquo;t make the broadcast highlights but absolutely define the race.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s Fernando Alonso starting P17 after a qualifying penalty and slicing through to P7. Or a midfield driver nailing an alternate strategy to jump six cars on the undercut. Or someone surviving a chaotic wet start to gain a dozen spots while half the grid spins off. Yes, probably Max. You get the idea. These are the moments that make F1 worth watching, and the Overtaker pick puts a fantasy stake directly in them.&lt;/p&gt;
&lt;p&gt;The problem? Try scoring that manually from a group chat.&lt;/p&gt;
&lt;p&gt;To calculate positions gained, you need each driver&amp;rsquo;s &lt;strong&gt;starting grid position&lt;/strong&gt; and their &lt;strong&gt;finishing position&lt;/strong&gt;, then compute the delta for every classified finisher, then rank them. That&amp;rsquo;s 20 drivers, two data points each, &lt;strong&gt;every race&lt;/strong&gt;. It&amp;rsquo;s not rocket science, but it&amp;rsquo;s exactly the kind of tedious bookkeeping that kills a casual game. No one&amp;rsquo;s going to maintain a spreadsheet for a fun side competition with friends. And if the scorekeeper gets it wrong, which I will, because grid penalties and post-race time penalties shift the numbers, the arguments start.&lt;/p&gt;
&lt;h2 id="the-app-does-the-boring-parts"&gt;The app does the boring parts&lt;/h2&gt;
&lt;p&gt;This is the quiet superpower of moving a game from text to software. The app doesn&amp;rsquo;t just automate scoring, it &lt;strong&gt;unlocks categories that were impractical before&lt;/strong&gt;. When results flow in digitally, computing positions-gained is trivial. Grid position minus finish position, sort descending, done. The app handles ties, accounts for DNS and DNF edge cases, and posts the answer the moment results are confirmed.&lt;/p&gt;
&lt;p&gt;What took a motivated scorekeeper (me!) 15 minutes of cross-referencing now takes zero effort. And the pick itself, who&amp;rsquo;s going to gain the most positions, is infinitely more interesting than Fastest Lap. It asks you to think about:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Qualifying mismatches&lt;/strong&gt;: Which fast cars are starting out of position due to grid penalties?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Race pace vs. one-lap speed&lt;/strong&gt;: Who has a car that&amp;rsquo;s better in race trim than qualifying trim?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Strategy variance&lt;/strong&gt;: Which teams might roll the dice on an alternate strategy that could leap them up the order?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Track characteristics&lt;/strong&gt;: Is this a track where overtaking is physically possible, or will grid position mostly hold?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every one of those questions requires genuine knowledge of the sport. That&amp;rsquo;s a pick category with depth.&lt;/p&gt;
&lt;h2 id="the-design-principle"&gt;The design principle&lt;/h2&gt;
&lt;p&gt;The lesson here isn&amp;rsquo;t specific to F1 or fantasy sports. It&amp;rsquo;s a broader game design idea: &lt;strong&gt;the best mechanics are often the ones that were too complicated to run by hand&lt;/strong&gt;. When you move a game to software, don&amp;rsquo;t just digitize the existing rules. Ask what was impossible before and whether it&amp;rsquo;s interesting now.&lt;/p&gt;
&lt;p&gt;For Open Wheelers, Fastest Lap was a concession to manual scoring. Overtaker is the pick we always wanted but couldn&amp;rsquo;t practically support. The app didn&amp;rsquo;t just make the game easier to play, it made it a better game.&lt;/p&gt;
&lt;p&gt;And if you think that&amp;rsquo;s an incremental difference, try picking the Overtaker for a rainy Interlagos. That&amp;rsquo;s not a checkbox. That&amp;rsquo;s a &lt;em&gt;decision&lt;/em&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;&lt;a href="https://stalefishlabs.com/use/openwheelers/"
target="_blank"
&gt;Open Wheelers&lt;/a&gt; is a casual F1 and IndyCar fantasy game for friends, built by &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/icon.png" type="image/png" length="0"/></item><item><title>The Weather Says Clear Skies. So Why Is the Trail a Swamp?</title><link>https://stalefishlabs.com/read/2026-03-05-why-is-the-trail-a-swamp/</link><pubDate>Thu, 05 Mar 2026 00:00:00 -0800</pubDate><guid>https://stalefishlabs.com/read/2026-03-05-why-is-the-trail-a-swamp/</guid><description>Weather apps tell you what the weather is. They don't tell you what last night's rain did to the ground beneath your tires.</description><content:encoded>&lt;p&gt;I love mountain biking. I love skateboarding. I love pretty much anything that involves wheels and dirt and concrete and the outdoors. What I don&amp;rsquo;t love is driving 30 minutes to a trailhead only to find the trail is a rutted, muddy mess, despite the forecast showing nothing but sunshine.&lt;/p&gt;
&lt;p&gt;This is sadly not an isolated occurrence.&lt;/p&gt;
&lt;h2 id="the-gap-between-weather-and-reality"&gt;The Gap Between Weather and Reality&lt;/h2&gt;
&lt;p&gt;Weather apps are great at telling you what the weather is and often what it will be. Temperature, wind, humidity, chance of rain, it&amp;rsquo;s all there. What they don&amp;rsquo;t tell you is what last night&amp;rsquo;s rain did to the ground beneath your tires. That half inch at 2am? On a south-facing dirt trail with good drainage, it might be bone dry by noon. On a shaded clay singletrack in the woods? Come back Thursday. Or what about the concrete skatepark in direct sunlight vs. the shaded halfpipe layered in Skatelite? Not the same.&lt;/p&gt;
&lt;p&gt;For years I did what every rider or skater does. I&amp;rsquo;d check the radar, look at the hourly history, maybe glance at the 48-hour precipitation total, and make a gut call. Sometimes I&amp;rsquo;d text a buddy: &amp;ldquo;You think Warner is rideable?&amp;rdquo; And they&amp;rsquo;d text back something equally scientific: &amp;ldquo;Probably?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The variables are too fuzzy. How much did it rain? When did it stop? What&amp;rsquo;s the temperature now? Is the wind helping things dry? Is the trail in full sun or deep shade? Is the surface dirt, gravel, concrete, wood? Did it freeze overnight and is now thawing into a soupy mess? Each of these factors interacts with the others in ways that are genuinely hard to hold in your head all at once.&lt;/p&gt;
&lt;h2 id="so-i-built-an-app"&gt;So I Built an App&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;m a developer, so naturally my response to this frustration was to write software. What started as a personal side project, just a quick tool to check if my local spots were rideable, turned into something much bigger once I realized how many people share this exact problem.&lt;/p&gt;
&lt;p&gt;The app pulls real weather data, looks at recent precipitation, drying conditions, temperature trends, freeze-thaw cycles, surface type, sun exposure, all of it, and synthesizes it into a simple answer: &lt;strong&gt;Yes&lt;/strong&gt;, &lt;strong&gt;Maybe&lt;/strong&gt;, or &lt;strong&gt;No&lt;/strong&gt;. It tells you whether conditions are likely suitable for riding right now, and gives you a seven-day outlook so you can plan ahead.&lt;/p&gt;
&lt;p&gt;Building it forced me to formalize all of those gut-feel heuristics I&amp;rsquo;d been running in my head for years. How much rain is too much? How long does it take to dry? Does Ramp Armor dry faster than wood or metal? When does a freeze actually matter? Translating intuition into code is humbling, because it forces you to confront how much of your &amp;ldquo;expertise&amp;rdquo; is just vibes.&lt;/p&gt;
&lt;h2 id="its-still-an-inexact-science"&gt;It&amp;rsquo;s Still an Inexact Science&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s the thing I want to be honest about: even with a carefully crafted and dedicated prediction engine running real weather data through a multi-phase algorithm, this is still an inexact science. Ground conditions are hyperlocal. Two trails a mile apart can behave completely differently based on soil composition, tree cover, elevation, and drainage. No amount of weather data can perfectly capture what&amp;rsquo;s happening at a specific spot on the earth&amp;rsquo;s surface.&lt;/p&gt;
&lt;p&gt;The app gives you a well-informed estimate, and in my experience, it&amp;rsquo;s right far more often than my old gut-check method. But it&amp;rsquo;s not infallible, and I never want to pretend it is.&lt;/p&gt;
&lt;p&gt;That honesty is actually baked into the product. Users can submit feedback when a verdict doesn&amp;rsquo;t match what they found on the ground. And behind the scenes, I use an LLM to process those reports, analyzing the weather conditions, the surface type, the user&amp;rsquo;s description of what they actually encountered, and then use that to identify patterns where the engine&amp;rsquo;s assumptions might be off. It&amp;rsquo;s a feedback loop that lets the prediction model evolve based on real-world ground truth from actual riders at actual spots.&lt;/p&gt;
&lt;p&gt;I think that&amp;rsquo;s what makes this problem so interesting. It sits right at the intersection of atmospheric data, soil science, and lived experience. No model will ever be perfect, but a model that learns from the people using it every day can keep getting better.&lt;/p&gt;
&lt;h2 id="whats-next"&gt;What&amp;rsquo;s Next&lt;/h2&gt;
&lt;p&gt;In a &lt;a href="https://stalefishlabs.com/read/2026-03-11-weather-engine-intro/"
&gt;future post&lt;/a&gt;, I plan to pull back the curtain on the prediction engine itself, how it evaluates moisture, what the drying model looks like, how freeze-thaw cycles are handled, and why certain surfaces behave so differently from others. It&amp;rsquo;s a surprisingly deep rabbit hole, and I think anyone who&amp;rsquo;s ever stared at a weather app trying to decide &amp;ldquo;is it rideable?&amp;rdquo; will find it interesting.&lt;/p&gt;
&lt;p&gt;Until then, check the app, trust the verdict, and if it&amp;rsquo;s wrong, tell me. That&amp;rsquo;s how we make it better.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;&lt;a href="https://stalefishlabs.com/use/ridewise/"
target="_blank"
&gt;Ridewise&lt;/a&gt; is available now on the App Store, built by &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/ridewise.png" type="image/png" length="0"/></item><item><title>Bad Timing &amp; Sticktoitiveness</title><link>https://stalefishlabs.com/read/2025-06-04-bad-timing-and-sticktoitiveness/</link><pubDate>Wed, 04 Jun 2025 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2025-06-04-bad-timing-and-sticktoitiveness/</guid><description>A decade building a social app, released three weeks before a pandemic. And still not quitting.</description><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Timing really is nearly everything. And what it isn&amp;rsquo;t, circumstance makes up for. — Steven Van Zandt&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It was such a great plan, it really was. You know those success stories where lightning strikes someone with a genius idea, and then somehow stars align and they take their idea to fruition right when it&amp;rsquo;s needed most? Wild success ensues, and you get to read about the virtue of following your dreams, and how simply seizing opportunity is all that separates you from similar results. If I was really cheeky I&amp;rsquo;d give you a numbered list of steps to follow. Well this is not one of those stories, this is a tale of timing, really bad timing. This is a cautionary tale to show how even endless enthusiasm, diligent work, and careful planning can be hilariously mocked by the unforeseen.&lt;/p&gt;
&lt;p&gt;My big idea was a software solution to the cat herding problem of figuring out who&amp;rsquo;s in and out when trying to get together with friends. I&amp;rsquo;m an old skateboarder (really old!) and it&amp;rsquo;s an activity that is generally less enjoyable and less safe alone. So skating with friends is a key part of the sport. We used text messaging to plan sessions and it sorta works but has a lot of failings like the first joke or unrelated comment can fully derail the in/out responses, the skate session invite scrolling off into oblivion. Seemed like a problem that could be perfectly solved with a mobile app.&lt;/p&gt;
&lt;p&gt;Enter &lt;a href="https://stalefishlabs.com/use/muster/"
&gt;Muster&lt;/a&gt;, a mobile app for getting people together in the real world. I tend to play the long game, meaning my pet projects sometimes take a while but I don&amp;rsquo;t quit easily, so back in 2019 the app had been a project for going on a decade. The iOS version was finally nearing completion and the excitement of actually releasing this thing into the wild was getting real. What had started as a Minimum Viable Product very much morphed into a full-featured app, and the unwillingness to not ship something half-baked at least partially explains the ridiculously long development timeline. In tragedies there&amp;rsquo;s often a seed of self-destruction if you look deeply enough, and in the case of Muster the perfectionism of features caused the publish date to slip literally years and eventually land in early 2020. So I was wrapping up the final touches on this app for helping people get together…what could go wrong?&lt;/p&gt;
&lt;p&gt;On February 20, 2020 I excitedly published my decade-plus long project to Apple&amp;rsquo;s App Store, the dream was now reality. Indeed, that dream of connecting the world one intimate event at a time was officially beginning. I celebrated with a drink and dessert (actual photo)…it was now time to sit back and wait for the world to adopt this can&amp;rsquo;t-miss new idea. I imagined finally doing away with clumsy antiquated texting to plan our skate sessions, and the app quickly growing beyond our skateboard crew as a solution for all informal RSVPs. Muster would be a force for good, bringing friend and foe together. Bullies and bullees burying the hatchet, dogs and cats living together in harmony, was peace on Earth finally within reach? I should&amp;rsquo;ve savored that dessert a bit more…probably a good general rule to live by.&lt;/p&gt;
&lt;p&gt;The CDC Museum lists the official start of the COVID-19 pandemic as December 19, 2019, with the first U.S. case on January 20, 2020. But given that pro sports dominates much of American culture, it would be March 11, 2020 when things &amp;ldquo;got real,&amp;rdquo; as the NBA canceled the remainder of their season. So less than three weeks after the launch of the app that had been carefully designed to break with other forms of social media and put the focus on people being face-to-face with each other, we find out that being face-to-face with each other is literally life-threatening. Brutal.&lt;/p&gt;
&lt;p&gt;I had managed to create a doomsday app purely through an accident of timing. Like catastrophic timing, some of the worst timing ever. How could you possibly spend ten years of your life creating something, only to release it within weeks of it becoming absolutely useless? Worse than useless, dangerous! That was a crushing, helpless feeling, sadly but mercifully overshadowed in some ways by the life and death consequences of the pandemic, which trivialized our individual pursuits in the wake of literal survival for many. Even so, we all knew there would eventually be a return to normalcy, just no clue how or when, and at what cost.&lt;/p&gt;
&lt;p&gt;So yes in hindsight I should&amp;rsquo;ve been working on Zoom not Muster leading into 2020, but here&amp;rsquo;s the good news about horrific timing…eventually time heals most things, one way or another. You just have to hang on and not let the lows of the present erase the potential highs of the future. I can&amp;rsquo;t say I was bursting with such cool-headed stoicism in 2020, there was little pretense of calm or wisdom in the moment. Nevertheless, quitting wasn&amp;rsquo;t on the table, the newly released app languished unused during COVID, as did many of us, while we safe-distanced and waited to find out what happens next.&lt;/p&gt;
&lt;p&gt;Then time marched on and eventually things got better. Even though Muster&amp;rsquo;s initial release was comically flawed, like on a Shakespearean level, it ultimately survived COVID, and is alive and well today. It continues to help me and my friends not skate alone, and it has remained a vibrant project that is still evolving and improving as I stumble to unravel the subtle social challenges of bringing humans together.&lt;/p&gt;
&lt;p&gt;I guess the takeaway (in both tech projects and in life) is to realize that even the best attempts at planning can go horribly awry, and the only thing you can truly control is how much quit you have in you. I learned that sometimes the best laid plans will be thwarted by the unknown, where opportunities become problems that turn into predicaments. The only choice is to quit or press on, and if you believe in yourself and what you&amp;rsquo;re doing that choice is surprisingly easy to make.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;&lt;a href="https://stalefishlabs.com/use/muster/"
target="_blank"
&gt;Muster&lt;/a&gt; is available now on the App Store, built by &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/edwin-hooper-q8m8clkryeo-unsplash-1288x726.jpg" type="image/png" length="0"/></item><item><title>Obsession &amp; the Artistic Entrepreneur</title><link>https://stalefishlabs.com/read/2024-08-19-obsession-and-the-artistic-entrepreneur/</link><pubDate>Mon, 19 Aug 2024 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/read/2024-08-19-obsession-and-the-artistic-entrepreneur/</guid><description>When obsessive problem-solving meets entrepreneurship — and fifteen years on one app starts looking more like art than business.</description><content:encoded>&lt;blockquote&gt;
&lt;p&gt;Without obsession, life is nothing. — John Waters&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I obsess over things. Once an idea takes hold, I&amp;rsquo;m at its mercy. It can arrive in an instant but linger for a very long time, in some cases years and even decades. It can be an obscure hobby (beekeeping), a high-risk activity outside of mainstream ball sports (skateboarding), or it can be an idea for a problem to be solved. Problem solvers are often thought of as entrepreneurs, and they certainly can be, but we&amp;rsquo;re really talking about two wildly different personalities and skill sets. Yet obsession can give one a false sense of the other.&lt;/p&gt;
&lt;p&gt;I mention this because I&amp;rsquo;ve been obsessively working on a particular problem for quite some time, and as you learn more you might think the time, energy, and money I&amp;rsquo;ve spent on it makes me an entrepreneur. That&amp;rsquo;s what I thought too, and I was very wrong. Entrepreneurs are (or should be) serious about things like determining if there&amp;rsquo;s actually a market for their solution, how to properly reach that market, and just maybe how much it will cost, both financially and more importantly in time. They would do that BEFORE diving into the work, and they&amp;rsquo;d be right to do so. But that&amp;rsquo;s not me, and when aiming that laser focus of obsession at a problem, I&amp;rsquo;ve learned I&amp;rsquo;m really only solving it for myself.&lt;/p&gt;
&lt;p&gt;Project myopia can be a wonderful thing for personal projects where you&amp;rsquo;re effectively doing pro bono work for yourself. Expectations typically line up pretty well with results when they live in the same person&amp;rsquo;s head. Or to put it in marketing terms, you don&amp;rsquo;t have to sell yourself on the benefits of your own idea. Thing is, you DO have to eventually sell others on your idea if part of your plan involves it solving their problems too. For example, a mobile app with a comically ambitious scope. I&amp;rsquo;m talking about &lt;a href="https://stalefishlabs.com/use/muster/"
&gt;Muster&lt;/a&gt;, which is attempting to evolve the concept of an RSVP for the mobile age and in doing so change how we get together with each other in the real world. I&amp;rsquo;ve been working on it for nearly 15 years, which is slow by almost any standard but insanely slow for tech (the iPhone itself has only been around since 2007). But I&amp;rsquo;m patient, and stubborn. And I understand that to do just about anything meaningful in life, you must exhibit dogged perseverance and cultivate a certain love for the process of creation itself, with a bright line separating effort from outcome.&lt;/p&gt;
&lt;p&gt;So with Muster think an extremely informal evite with a hint of messaging, calendar, a touch of Instagram, even a dash of Strava. I know in marketing you aren&amp;rsquo;t supposed to define yourself in terms of your peers or competitors but it&amp;rsquo;s often the simplest way to convey an idea, and zero-sum thinking is usually counter-productive in a world large enough for multiple ideas to co-exist and serve unique purposes. Besides, referencing &amp;ldquo;the competition&amp;rdquo; is small potatoes when it comes to Muster and ignoring marketing best practices. Anyway, the idea grew out of the need to distill the communication for getting together down to two possibilities: in or out. Other interesting features evolved over time but that&amp;rsquo;s still the core of the app: let&amp;rsquo;s get together, are you in or out?&lt;/p&gt;
&lt;p&gt;Seems simple enough, right? I naively thought so too way back in 2010. Turns out there&amp;rsquo;s a lot of subtlety in event planning, even informal events like meeting up with friends for a coffee. How do people find each other in the app, cultivated friend lists like Facebook? If we&amp;rsquo;re meeting on Friday in Atlanta (EST) but I create the invitation on Wednesday while in Nashville (CST), what time zone is used? Do you show that time zone in the app? To both people? Is location required or should it be optional, maybe inferred from the title like &amp;ldquo;Grilling at Our House?&amp;rdquo; And what about notifications, iPhone and Android users coming together, privacy concerns, etc.? It started to become clear why no other mobile apps have succeeded in solving this ostensibly simple problem, and why most planning is still done with clunky text threads. Enter an obsessive guy with technical skills, a problem to solve, an over-abundance of optimism, and zero regard for proper entrepreneurial decision-making.&lt;/p&gt;
&lt;p&gt;Years go by. Then a few more. Plenty of roadblocks surfaced, including a shuttered software development tool (Parse) and a pandemic, among other challenges…but all those design problems mentioned got solved, and many more were identified and solved as well. Not only that, but real-world usage led to several significant enhancements such as event messages and media, invitee groups, plus-ones, co-hosts, min/max attendees, and more. The idea evolved but I stuck to my guns in building the app I wanted, not the app I guessed the world needed. Sure, tons of testing was done with friends and family, not to mention Muster being both a lead sponsor and planning tool for the &lt;a href="https://www.instagram.com/rageattherose/"
target="_blank"
&gt;world&amp;rsquo;s largest backyard skateboard contest&lt;/a&gt;. But at the end of the day every major decision came down to &amp;ldquo;what do I personally want this app to do?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;And here we get to the real issue at hand. I&amp;rsquo;ve heard it said that if Bob Dylan hadn&amp;rsquo;t become one of the greatest songwriters in history, he&amp;rsquo;d be sitting on a sidewalk somewhere playing the exact same music. That&amp;rsquo;s an artist. An artist creates because they can&amp;rsquo;t not do it, and the best artists create for themselves, regardless of whether they ultimately sell their art or perform it to make a living. With art, success lies in the creation itself, not in how it&amp;rsquo;s received by others — Vincent Van Gogh only sold one painting in his lifetime!&lt;/p&gt;
&lt;p&gt;Business is in many ways the opposite of art, where success is necessarily defined by others voting with their wallet for a product by trading goods or money. App-making is certainly a business, I mean apps are literally published in an app STORE. So it&amp;rsquo;s difficult to make the &amp;ldquo;apps are art&amp;rdquo; argument with a straight face. Yet there&amp;rsquo;s an artist inside this engineer who is dutifully committed to creating the ultimate real world social app purely for myself. And at the same time publish it in a store where others get to decide if it also provides any value to them.&lt;/p&gt;
&lt;p&gt;The irony that this ruthlessly individualized app depends on others for business success isn&amp;rsquo;t lost on the artist in me, yet I can&amp;rsquo;t not create it. The resulting conflict is real, and the jury is still out on whether this particular marriage of technology and liberal arts ultimately delivers on that famous Steve Jobs insight. In the meantime, if you ever stumble across a guy on a sidewalk sitting on a skateboard with a laptop coding away, consider downloading his app and inviting him for a coffee. I&amp;rsquo;m in!&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;&lt;a href="https://stalefishlabs.com/use/muster/"
target="_blank"
&gt;Muster&lt;/a&gt; is available now on the App Store, built by &lt;a href="https://stalefishlabs.com"
target="_blank"
&gt;Stalefish Labs&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Michael Morrison</dc:creator><enclosure url="https://stalefishlabs.com/obsessed.png" type="image/png" length="0"/></item></channel></rss>