Minder is a focus timer that's just a black screen and two eyes. You prop your phone up, slide to focus, and the eyes watch you work. If you pick the phone up mid-session, they get very suspicious.
It's body doubling for people with ADHD (or anyone who works better when someone's watching), and the whole thing only works if the eyes feel alive. A looping animation reads as a screensaver within about ten seconds. Here's what made the difference, all in plain SwiftUI with a Canvas.
1. Everything is a spring
Every moving part (the eyelids and brows as a "face" vector, where the eyes look, a little vertical bob) is a damped spring stepped once per frame. This is the entire physics engine:
struct SpringVector {
var x: [CGFloat]
var v: [CGFloat]
init(_ start: [CGFloat]) { x = start; v = Array(repeating: 0, count: start.count) }
mutating func step(toward target: [CGFloat], dt: CGFloat, stiffness k: CGFloat, damping ratio: CGFloat) {
let c = 2 * sqrt(k) * ratio
for i in x.indices {
let a = k * (target[i] - x[i]) - c * v[i]
v[i] += a * dt
x[i] += v[i] * dt
}
}
}
The trick is giving each layer its own feel:
face.step(toward: expr.face.values, dt: dt, stiffness: 190, damping: 0.62) // expressions settle with a little overshoot
gaze.step(toward: target, dt: dt, stiffness: 420, damping: 0.8) // eyes are fast and nearly critical
bob.step(toward: [0], dt: dt, stiffness: 140, damping: 0.38) // a loose, bouncy nod
An expression (happy, curious, proud, suspicious, sleepy...) is just a target vector. Switching expressions never pops, because the springs carry the face there. A nod is just a kick to the bob's velocity.
2. Eyes never sit still: saccades
Real eyes make tiny involuntary jumps. Adding a small random offset to the gaze target every 0.25 to 0.9 seconds is what stopped the eyes looking painted on:
if t > nextSaccadeAt {
saccade = CGPoint(x: .random(in: -0.05...0.05), y: .random(in: -0.04...0.04))
nextSaccadeAt = t + .random(in: 0.25...0.9)
}
3. Blinks have a shape
A blink isn't symmetric: the lid closes in 70 ms and opens over the next 100 ms. Blinks come every 2.2 to 6.5 seconds, 18% of them are double blinks 240 ms apart, and when the eyes are sleepy the whole blink plays at under half speed. Those numbers came from fiddling until it stopped looking robotic.
4. Layers of attention
The brain has a fast layer (springs, saccades, blinks, every frame) and a slow layer that changes moods over seconds and minutes. When you're idle the eyes look around; after a while they get sleepy, then fall asleep. During a focus session they settle into attentive, and every 30 seconds to a minute or so pick a random flash: happy, curious, proud, attentive, or a quick suspicious glance.
Two sensors feed it:
- Core Motion: when the phone is picked up mid-session, the eyes go surprised, then suspicious, and look up at you for a few seconds.
- Optional face tracking (Vision, front camera, part of Pro): the eyes follow your face, and if you've been gone for a while and come back, they look happy and nod. Nothing is recorded or leaves the phone.
Try it
Minder is free on the App Store (iPhone): https://apps.apple.com/app/id6813451929
The source is MIT on GitHub: https://github.com/Mattbusel/minder
If you've built "alive" characters before, I'd love to know what tricks you used. Mine are all above; the springs did 80% of the work.
Top comments (0)