
One Engine, Three Apps: Sharing a Swift Decision Engine Across Products
March 20, 2026 · by Michael Morrison
Structuring a shared Swift package across three apps with different surfaces, thresholds, and verdicts.
In the previous articles I’ve covered what the Groundwise engine does and how the drying model works. 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).
The pitch sounds clean: “one engine, three apps.” The reality required some specific design choices to keep it from becoming a tangled mess of conditionals.
The Shape of the Problem
The initial problem was how to apply human intuition to map recent/current weather conditions onto different kinds of surfaces to assess “rideability,” the application being that I wanted a simple verdict on if a mountain bike trail or skateboard ramp was sufficiently dry to session. That’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’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’s answering the fundamental question: what’s the moisture situation at this spot?
So the idea of three different apps started to coalesce, and their distinguishing characteristics began to emerge:
- Cares about different surfaces (skateparks vs. soccer fields vs. herb gardens)
- Has different thresholds for what counts as “too wet” or “too dry”
- Interprets the engine verdict differently (wet is bad for riders, good for gardeners)
- Needs app-specific logic (gardeners track manual watering, riders don’t)
- Uses different language in the rationale (“trail might be muddy” vs. “field may hold water” vs. “soil has enough moisture”)
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’ll see, this was a key design decision early that paid off big later.
EngineMode: Flipping the Perspective
One of the most key types in the Groundwise engine is what allows it to pivot between caring about dryness or caring about wetness:
enum EngineMode {
case wetnessConcern // Ridewise, Fieldwise
case drynessConcern // Yardwise
}
The engine always calculates a wetness score from 0 (bone dry) to 1 (saturated). EngineMode controls what that score means:
- Wetness concern: Low score = Yes (go ride), high score = No (too wet)
- Dryness concern: Low score = Yes (water your plants), high score = No (skip watering)
The verdict enum is the same in both modes (Yes, Maybe, No) but the interpretation inverts. This is a deliberate choice. The consuming app doesn’t need to know which mode produced the verdict. It just renders appropriate app-specific colors/symbols for Yes, Maybe, and No.
SiteType: A Unified Wrapper
Each app has its own domain vocabulary. Ridewise has “spots” (trails, skateparks), Fieldwise has “fields” (baseball diamonds, tennis courts), Yardwise has “areas” (lawns, raised beds). Rather than making the engine accept three different input types, they’re wrapped in a single enum:
enum SiteType {
case spot(SpotType) // Ridewise: trail, skatepark, bikeJumps, pumpTrack...
case field(FieldType) // Fieldwise: baseball, soccer, tennis, rugby...
case area(AreaType) // Yardwise: lawn, raisedBed, container, compost...
}
The key thing we’re trying to arrive at for wetness/dryness purposes is a surface type. Each case in this enum maps to a SurfaceType, the thing the engine actually cares about. A SpotType.skatepark maps to SurfaceType.concrete. A FieldType.baseball maps to SurfaceType.naturalGrass. A AreaType.container maps to SurfaceType.pottingMix.
The engine never branches on SpotType or FieldType 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.
Surface Types: The Parameterization Layer
The apps currently have seventeen surface types to encode the physical properties that actually matter for moisture modeling:
// Each surface type defines:
var dryingMultiplier: Double // 0.7 (clay) to 2.8 (metal)
var damageSensitivity: Double // 0.0 to 1.0
var isDrainingSurface: Bool // sheds water vs. absorbs
var isAbsorbentSurface: Bool // holds water in material
var safetySensitivity: Double // slip risk when wet
var qualitySensitivity: Double // quality degradation when wet
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.
This means adding a new surface type is a data change, not a logic change. If someone wanted to add “packed gravel” for Ridewise, they’d define its properties and it would flow through the existing pipeline.
Also notice a few perhaps surprising things that entered the data model: safety and quality sensititivies. When you think about it, rideability isn’t just about “too muddy” or “has puddles,” there’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.
As an aside, it’s worth noting that I ride a local private mountain bike trail that gets so little traffic that mud isn’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’ll take a Maybe verdict as a green light given its unique properties.
Threshold Calibration Per Mode
When it comes to actually rendering a verdict, the verdict thresholds differ between wetness concern and dryness concern:
| Yes | Maybe | No | |
|---|---|---|---|
| Wetness concern | < 0.30 | 0.30 – 0.60 | ≥ 0.60 |
| Dryness concern | < 0.15 | 0.15 – 0.45 | ≥ 0.45 |
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 “maybe water” rather than “definitely water” because overwatering is its own problem.
The sensitivity veto also inverts:
- Wetness concern: If a sensitive surface (like natural grass with 0.9 damage sensitivity) gets a Maybe verdict with wetness above 0.4, it’s pushed to No. Protect the surface.
- Dryness concern: If a sensitive surface (like fresh seed with 0.95 effective sensitivity) gets a Maybe verdict with wetness below 0.3, it’s pushed to Yes. Water the fragile plants.
Same mechanism, opposite direction. And yet one line of mode-aware logic handles both cases.
App-Specific Features Without Conditionals
It’s worth pointing out that Yardwise has two features the other apps don’t: manual watering tracking and cold-weather dormancy suppression. Rather than branching on app identity, these are driven by the input data:
Manual watering is an array of WateringEvent values on the input struct. Ridewise and Fieldwise pass an empty array. The engine’s watering contribution calculation gracefully returns zero when there are no events, meaning no conditional needed.
Establishment sensitivity is a Double 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’s damage sensitivity. Ridewise and Fieldwise leave it at 0.0. Again, no branching for the apps that don’t care, the math just works with a zero boost.
Cold-weather dormancy activates when the temperature drops below 55°F. It injects additional “wetness” into the Yardwise calculation, suppressing watering recommendations when plants are dormant. This is gated on EngineMode.drynessConcern because the concept doesn’t make sense for riders given that cold weather doesn’t make trails less wet.
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.
The Input Struct
On to the engine inputs, where everything flows through a single input type:
struct GroundwiseInputs {
// Weather (required)
let rain24hInches: Double
let rain48hInches: Double
let rain72hInches: Double
let minutesSinceRainEnded: Int?
let rainPattern: RainPattern
let temperatureF: Double
let sustainedWindMph: Double
let cloudCoverFraction: Double
// Weather (optional, improves accuracy)
let gustsMph: Double?
let dewPointF: Double?
let relativeHumidity: Double?
let overnightLowF: Double?
let daysSinceLastRain: Int?
// Freeze/thaw context
let frozenSnowAccumulationInches: Double?
let hoursSinceThawBegan: Int?
let hoursBelowFreezing: Int?
// Site context
let siteType: SiteType
let surfaceType: SurfaceType
let exposureLevel: ExposureLevel
// Mode and calibration
let engineMode: EngineMode
let establishmentSensitivityBoost: Double
let isCurrentlyPrecipitating: Bool
let hasThunderstorm: Bool
// Yardwise-specific
let manualWateringEvents: [WateringEvent]
}
Each app constructs this from its own domain model. Ridewise fills in the weather data and its spot’s surface type. Yardwise adds watering events and an establishment boost. The engine doesn’t know or care which app called it.
The Output: Same Structure, Different Meaning
The engine evaluates the inputs as a black box and generates an assessment in this form:
struct GroundwiseAssessment {
let verdict: Verdict // .yes / .maybe / .no
let confidence: Confidence // .low / .medium / .high
let rationale: GroundwiseRationale
let riskSurfaceDamage: RiskLevel
let riskActivitySafety: RiskLevel
let riskActivityQuality: RiskLevel
let recoveryOutlook: RecoveryOutlook?
}
The consuming app interprets the verdict through its own lens. Ridewise renders Yes as “Dry — go ride.” Yardwise renders Yes as “Water today.” The engine provides the structured rationale (headline, decisive factor, contributing details), and each app’s UI formats it with domain-appropriate language.
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.
What I’d Do Differently
The engine’s one-file structure (2,193 lines in GroundwiseEngine.swift) works but isn’t ideal. The frozen conditions check alone is 360 lines. If I were starting over, I’d break the pipeline into discrete stages: FreezeAssessor, PrecipitationAssessor, and DryingCalculator, VerdictResolver. And I’d make each of these a separate type with a clear protocol.
I’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.
That said, the current design has shipped and works. Premature refactoring is its own trap.
The Tradeoff
Sharing an engine across three apps means accepting constraints:
- You can’t special-case easily. If Ridewise needs a trail-specific behavior that doesn’t generalize, you either make it general enough for the shared engine or handle it in the app layer.
- Testing surface area grows. 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.
- Abstraction has a ceiling. Cold-weather dormancy proved that not everything generalizes cleanly. Knowing when to accept a mode check instead of over-abstracting is a judgment call.
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).
For an indy developer maintaining three related apps, that leverage is worth the constraints.
Next in the Series
The next article 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.
Ridewise, Fieldwise, and Yardwise are all available for iOS from Stalefish Labs. Built by one developer, powered by one engine.
Want more like this?
Subscribe to get new posts from The Lab delivered to your inbox.
or grab the RSS feed