seven / writing / 2026 / timenest-core-motion-fsm3 min read
§ 02 · 2026-03-22 · Craft · Systems

Building TimeNest: FSM, Core Motion, and teaching an app to notice what you're doing

My MAIC competition entry. The goal was to build a focus timer that detects your context automatically — using device orientation, geofencing, and a finite state machine to tie it together.

TimeNest is my entry for MAIC (行動應用創新賽). The pitch: a focus app that doesn't require you to manually start a timer. Instead, it watches what you're doing — where you are, how your device is placed — and infers your focus state from that.

Building it was an exercise in hardware sensing, state management, and the specific kind of patience required when your inputs are physical-world signals that don't always behave.

The core idea

The system has two sensing layers:

Geofencing via MapKit — instead of asking users to define their own focus zones, the app automatically detects nearby libraries using Apple MapKit. Libraries in TimeNest are called Focus Nests (專注巢穴). When you're within 50 metres of one, the system is primed and watching.

Device orientation via Core Motion — when you place your phone face-down inside a Focus Nest, the app starts a countdown. After 60 uninterrupted seconds face-down, you begin accumulating focus points. Pick it up before the 60 seconds are up, and the countdown resets.

The interaction model is almost zero friction. Walk into a library, put your phone on the table face-down, and the app handles the rest.

Why the 60-second threshold exists

The 60-second gate is the anti-gaming mechanism. Without it, you could tap your phone down and immediately earn points — or write a script to fake orientation events. With it, short or accidental placements don't count.

The threshold also has a secondary effect: it filters out the noise of actually sitting down. You pull out your chair, drop your bag, place your phone — that whole sequence takes 10–20 seconds. By the time you're settled, you're halfway to the threshold anyway.

Why Core Motion is trickier than it looks

The raw accelerometer data is noisy. You can't just threshold on "Z-axis pointing down" and call it done — any movement, a vibration, or putting the phone down with slight force will trigger false readings.

What actually works is using the CMDeviceMotion attitude data (pitch, roll, yaw) rather than raw acceleration. These values are processed by the device's sensor fusion algorithm and are significantly smoother.

I added a 1.5-second stability window on top: the detected orientation state needs to hold before I act on it. A deliberate placement is sustained; noise is brief. This filters almost everything that shouldn't start the 60-second countdown.

The FSM

The state machine has four states: idle, primed (within 50m of a Focus Nest but phone not placed), pending (face-down, counting toward 60s), and focusing (points accumulating).

Transitions:

  • idle → primed: enter 50m radius of a library
  • primed → pending: device placed face-down
  • pending → focusing: 60 seconds elapsed face-down
  • pending → primed: device picked up before 60s
  • focusing → primed: device picked up
  • any → idle: exit the 50m radius

Using a proper FSM rather than a nest of boolean flags was one of the better decisions I made early on. The state is always explicit, transitions are defined in one place, and adding a new state doesn't require auditing the entire codebase for side effects.

Swift's enum with associated values maps cleanly to this pattern. Each state carries only the data it needs.

Thread confinement

Core Motion callbacks arrive on a background thread. The UI state lives on the main thread. Early on I had a few race conditions from not being careful about this boundary.

The fix was explicit: all sensor events go through a background OperationQueue for processing, and state mutations are always dispatched back to the main thread via @MainActor. Once I made this explicit and consistent, the race conditions went away and stayed away.

What's next

The sensing is solid. The UI needs work — the onboarding in particular is too sparse. I'm also thinking about session analytics: streak data, average focus duration, patterns over time.

Whether that happens depends partly on how the competition goes, partly on how much I want to keep building it. Right now it's both.

Back to the blog