<?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>Stalefish Labs</title><link>https://stalefishlabs.com/tags/indie-dev/</link><description>We build simple, thoughtful tools for gathering your people, getting outside, and spending less time planning and more fun time together — because the best things happen when everyone shows up.</description><generator>Hugo 0.155.2</generator><language>en-us</language><lastBuildDate>Sun, 19 Apr 2026 06:16:18 +0000</lastBuildDate><atom:link href="https://stalefishlabs.com/tags/indie-dev/index.xml" rel="self" type="application/rss+xml"/><item><title>The Group Chat Had It Right: Why I Un-Fixed Visible Picks</title><link>https://stalefishlabs.com/the-lab/2026-03-31-the-group-chat-had-it-right/</link><pubDate>Tue, 31 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/the-lab/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/the-lab/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/the-lab/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/apps/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/the-lab/2026-03-27-uncertain-verdicts/</link><pubDate>Fri, 27 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/the-lab/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/the-lab/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/the-lab/2026-03-17-drying-model/"
&gt;drying model&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/the-lab/2026-03-20-one-engine-three-apps/"
&gt;multi-app architecture&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/the-lab/2026-03-23-edge-cases/"
&gt;edge cases&lt;/a&gt;, and the &lt;a href="https://stalefishlabs.com/the-lab/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/the-lab/2026-03-12-weather-engine-intro/"
&gt;core concept&lt;/a&gt; through the &lt;a href="https://stalefishlabs.com/the-lab/2026-03-17-drying-model/"
&gt;drying math&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/the-lab/2026-03-20-one-engine-three-apps/"
&gt;multi-app architecture&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/the-lab/2026-03-23-edge-cases/"
&gt;winter edge cases&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/the-lab/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/apps/ridewise/"
target="_blank"
&gt;Ridewise&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/apps/fieldwise/"
target="_blank"
&gt;Fieldwise&lt;/a&gt;, and &lt;a href="https://stalefishlabs.com/apps/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>One Engine, Three Apps: Sharing a Swift Decision Engine Across Products</title><link>https://stalefishlabs.com/the-lab/2026-03-20-one-engine-three-apps/</link><pubDate>Fri, 20 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/the-lab/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/the-lab/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/the-lab/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/the-lab/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/the-lab/2026-03-17-drying-model/</link><pubDate>Tue, 17 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/the-lab/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/the-lab/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/the-lab/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/apps/ridewise/"
target="_blank"
&gt;Ridewise&lt;/a&gt;, &lt;a href="https://stalefishlabs.com/apps/fieldwise/"
target="_blank"
&gt;Fieldwise&lt;/a&gt;, and &lt;a href="https://stalefishlabs.com/apps/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>I Built a Weather Engine That Tells You When to Ride, Play, or Water</title><link>https://stalefishlabs.com/the-lab/2026-03-12-weather-engine-intro/</link><pubDate>Thu, 12 Mar 2026 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/the-lab/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/apps/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/apps/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/apps/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>From Group Chat to App: How Going Digital Unlocked Better Fantasy Stats</title><link>https://stalefishlabs.com/the-lab/2026-03-06-from-group-chat-to-app/</link><pubDate>Fri, 06 Mar 2026 00:00:00 -0800</pubDate><guid>https://stalefishlabs.com/the-lab/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/apps/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/apps/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/the-lab/2026-03-05-why-is-the-trail-a-swamp/</link><pubDate>Thu, 05 Mar 2026 00:00:00 -0800</pubDate><guid>https://stalefishlabs.com/the-lab/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/the-lab/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/apps/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/the-lab/2025-06-04-bad-timing-and-sticktoitiveness/</link><pubDate>Wed, 04 Jun 2025 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/the-lab/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/apps/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/apps/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/the-lab/2024-08-19-obsession-and-the-artistic-entrepreneur/</link><pubDate>Mon, 19 Aug 2024 00:00:00 -0700</pubDate><guid>https://stalefishlabs.com/the-lab/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/apps/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/apps/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>