TODO Slayer (formerly QuestKeeper)
A pixel-RPG to-do app in which each task is a quest, each deadline a dungeon encounter, and an on-time finish a one-hit win for the hero. It is a native iOS app built with SwiftUI and SwiftData, and it keeps data on the device with no accounts or ads.
At a glance
| Item | Details |
|---|---|
| Releases | 1.0.0 (2026-07-24) · renamed from Quest Keeper to TODO Slayer in 1.3.0 (2026-08-17) · latest 1.4.0 (2026-09-04) |
| Link | App Store |
| Stack | SwiftUI · SwiftData · WidgetKit (sharing data with the app through an App Group) · App Intents (creating quests from Shortcuts) · StoreKit · Swift 6 |
| Architecture | A quest stores only facts such as its deadline and completion time. Urgency, monster strength, and the day's temporary graves are computed by pure functions each time the screen is drawn. |
| Quality | On the 1.4.0 merge commit, 430 unit tests and 55 UI tests passed in CI with no failures |
| Monetization | No ads · a three-tier tip in-app purchase (Tip Jar) |
| App Store | 603 impressions · 54 product page views · about 4 downloads (checked 2026-09-28) |
Project story
How did the project start?
I once enjoyed an iPhone app called "QUEST", and I wanted to build one that leaned even further into gamification. At the same time, I wanted to implement the core parts of native iOS development myself: Siri integration, Shortcuts, iCloud, widgets and Live Activities, and Quick Actions. When I pictured an app I would actually use that could hold all of those, a to-do app came to mind naturally, and treating tasks as quests followed just as naturally.
It is also a learning project for working directly with native boundaries such as SwiftData, WidgetKit, App Groups, and Swift 6 strict concurrency, without React Native or Flutter.
What was the biggest problem, and how did you solve it?
A quest completed from the widget still showed as unfinished when I brought the app back. The app and the widget share one SwiftData store inside an App Group. A fresh launch showed the completion correctly, but when a backgrounded app returned to the foreground, it did not reflect what the widget had saved in the meantime.
Instead of changing code on guesswork, I built a spike that sent the same process to the background, wrote a completion time into the store from outside, brought the app back, and captured the screen.
My first idea, modelContext.rollback(), kept the same SQLite connection and snapshot and went on showing the old data.
Recreating the ModelContainer when the app became active, on the other hand, opened a new connection that read the latest state from disk and gave the same result as a fresh launch.
Fixing it surfaced two more rules. A transition that never reached the background, such as briefly pulling down Control Center, must not swap the container, or an open editor would close. Rescheduling notifications and writing the widget snapshot also had to run against the new container. Run against the old data, they rescheduled notifications for a quest the widget had already completed and overwrote the correct snapshot the widget had written.
I also had to correct the reproduction once. Reproducing with two containers in one process gave a false pass, because the first container could see the second one's write. So I switched to an experiment with two real processes over one store, and across 40 racing runs, an explicit re-fetch never once returned a stale value.
Why did you rename it?
The name Quest Keeper could not be registered on the English store.
It was available on the Korean store, and I considered keeping it there, but the name seemed to hide what the app actually does and show only its game side, so I renamed it to TODO Slayer on both stores.
How many users are there now?
Downloads are still at about four. With 603 impressions and 54 product page views, the app is not short on visibility, but those views are not turning into downloads. I am analyzing what is holding people back, and I am building and comparing candidates for what the first store screenshot should show.