Building an Elden Ring Quest Tracker
I loved Skyrim. I happily spent several hundred hours playing and replaying it. So when I recently heard of a new game, the Skyrim of the 2020s, I had to buy it. Thus begins my saga with Elden Ring, the massive open-world RPG with story guidance from George R.R. Martin.
Within the first hour of the game, I learned how brutal Souls games can be. I crept into interesting cliffside caves only to die so far inside that I couldn’t retrieve my corpse.
I lost all my runes.
I gaped in awed wonder as I rode the elevator down to Siofra River, only to find that grisly death awaited me, far from the nearest site of grace. I bravely ran away before I could die again.
I met ghostly figures and fascinating NPCs who tempted me with a few lines of dialogue… which I immediately forgot as soon as it was needed.
10/10, highly recommended.
One thing in particular about Elden Ring irked me - there was no quest tracker. Ever the good sport, I opened up a Notes document on my iPhone. Of course, that wasn’t nearly enough.
I needed an app to help me track RPG playthrough details. Nothing on the App Store really matched what I was looking for, so apparently I would need to write it. It’s called Shattered Ring, and it’s available on the App Store now.
Tech Choices
By day, I write documentation for the Realm Swift SDK. I had recently written a SwiftUI template app for Realm to provide developers with a SwiftUI starter template to build on, complete with login flows. The Realm Swift SDK team has been steadily shipping SwiftUI features, which has made it - in my probably biased opinion - a dead simple starting point for app development.
I wanted something I could build super fast - partially so I could get back to playing Elden Ring instead of writing an app, and partially to beat other apps to market while everyone is still talking about Elden Ring. I couldn’t take months to build this app. I wanted it yesterday. Realm + SwiftUI was going to make that possible.
Data Modeling
I knew that I wanted to track quests in the game. The quest model was easy:
class Quest: Object, ObjectKeyIdentifiable {
@Persisted(primaryKey: true) var _id: ObjectId
@Persisted var name = ""
@Persisted var isComplete = false
@Persisted var notes = ""
}
All I really needed was a name, a bool to toggle when the quest was complete, a notes field, and a unique identifier.
As I thought about my gameplay, though, I realized that I didn’t just need quests - I also wanted to keep track of locations. I stumbled into - and quickly out of when I started dying - so many cool places that probably had interesting non-player characters (NPCs) and awesome loot. I wanted to be able to keep track of whether I had cleared a location, or just ran away from it, so I could remember to go back later and check it out once I had better gear and more abilities. So I added a location object:
class Location: Object, ObjectKeyIdentifiable {
@Persisted(primaryKey: true) var _id: ObjectId
@Persisted var name = ""
@Persisted var isCleared = false
@Persisted var notes = ""
}
Hmm. That looked a lot like the quest model. Did I really need a separate object? Then I thought about one of the early locations I visited - the Church of Elleh - which had a smith anvil. I hadn’t actually done anything to improve my gear yet, but it might be nice to know which locations had the smith anvil in the future when I wanted to go somewhere to do an upgrade. So I added another bool:
@Persisted var hasSmithAnvil = false
Then I thought about how that same location also had a merchant. I might want to know in the future whether a location had a merchant. So I added another bool:
@Persisted var hasMerchant = false
Great! Location object sorted.
But… there was something else. I kept getting all these interesting story tidbits from NPCs. And what happened when I completed a quest - would I need to go back to an NPC to collect a reward? That would require me to know who had given me the quest and where they were located. Time to add a third model, the NPC, that would tie everything together:
class NPC: Object, ObjectKeyIdentifiable {
@Persisted(primaryKey: true) var _id: ObjectId
@Persisted var name = ""
@Persisted var isMerchant = false
@Persisted var locations = List<Location>()
@Persisted var quests = List<Quest>()
@Persisted var notes = ""
}
Great! Now I could track NPCs. I could add notes to help me keep track of those interesting story tidbits while I waited to see what would unfold. I could associate quests and locations with NPCs. After adding this object, it became obvious that this was the object that connected the others. NPCs are at locations. But I knew from some reading online that sometimes NPCs move around in the game, so locations would have to support multiple entries - hence the list. NPCs give quests. But that should also be a list, because the first NPC I met gave me more than one quest. Varre, right outside of the Shattered Graveyard when you first enter the game, told me to “Follow the threads of grace” and “go to the castle.” Right, sorted!
Now I could use my objects with SwiftUI property wrappers to start creating the UI.
SwiftUI Views + Realm’s Magical Property Wrappers
Since everything hangs off the NPC, I would start with the NPC views. The @ObservedResults
property wrapper gives you an easy way to do this.
struct NPCListView: View {
@ObservedResults(NPC.self) var npcs
var body: some View {
VStack {
List {
ForEach(npcs) { npc in
NavigationLink {
NPCDetailView(npc: npc)
} label: {
NPCRow(npc: npc)
}
}
.onDelete(perform: $npcs.remove)
.navigationTitle("NPCs")
}
.listStyle(.inset)
}
}
}
Now I could iterate through a list of all the NPCs, had an automatic onDelete
action to remove NPCs, and could add Realm’s implementation of .searchable
when I was ready to add search and filtering. And it was basically one line to hook it up to my data model. Did I mention Realm + SwiftUI is amazing? It was easy enough to do the same thing with Locations and Quests, and make it possible for app users to dive into their data through any path.
Then, my NPC detail view could work with the @ObservedRealmObject
property wrapper to display the NPC details, and make it easy to edit the NPC:
struct NPCDetailView: View {
@ObservedRealmObject var npc: NPC
var body: some View {
VStack {
HStack {
Text("Notes")
.font(.title2)
Spacer()
if npc.isMerchant {
Image(systemName: "dollarsign.square.fill")
}
Spacer()
Text($npc.notes)
Spacer()
}
}
}
Another benefit of the @ObservedRealmObject
was that I could use the $
notation to initiate a quick write, so the notes field would just be editable. Users could tap in and just add more notes, and Realm would just save the changes. No need for a separate edit view, or to open an explicit write transaction to update the notes.
At this point, I had a working app and I could easily have shipped it.
But… I had a thought.
One of the things that I loved about open world RPG games was replaying them as different characters, and with different choices. So maybe I would want to replay Elden Ring as a different class. Or - maybe this wasn’t an Elden Ring tracker specifically, but maybe I could use it to track any RPG game. What about my D&D games?
If I wanted to track multiple games, I needed to add something to my model. I needed a concept of something like a game or a playthrough.
Iterating on the Data Model
I needed some object to encompass the NPCs, Locations, and Quests that were a part of this playthrough, so I could keep them separate from other playthroughs. So what if that was a Game?
class Game: Object, ObjectKeyIdentifiable {
@Persisted(primaryKey: true) var _id: ObjectId
@Persisted var name = ""
@Persisted var npcs = List<NPC>()
@Persisted var locations = List<Location>()
@Persisted var quests = List<Quest>()
}
Alright! Great. Now I can track the NPCs, Locations, and Quests that are in this game, and keep them distinct from other games.
The Game object was easy to conceive, but when I started thinking about the @ObservedResults
in my views, I realized that wouldn’t work anymore. @ObservedResults
return all the results for a specific object type. So if I wanted to display only the NPCs for this game, I’d need to change my views.*
- Swift SDK version 10.24.0 added the ability to use Swift Query syntax in
@ObservedResults
, which allows you to filter results using thewhere
parameter. I am definitely refactoring to use this in a future version! The Swift SDK team has been steadily releasing new SwiftUI goodies.
Oh. Also, I’d need a way to distinguish the NPCs in this game from the ones in other games. Hrm. Now might be time to look into backlinking. After spelunking in the Realm Swift SDK Docs, I added this to the NPC model:
@Persisted(originProperty: "npcs") var npcInGame: LinkingObjects<Game>
Now I could backlink the NPCs to the Game object. But, alas, now my views get more complicated.
Updating SwiftUI Views for the Model Changes
Since I want only a subset of my objects now (and this was before the @ObservedResults
update), I switched my list views from @ObservedResults
to @ObservedRealmObject
, observing the game:
@ObservedRealmObject var game: Game
Now I still get the benefits of quick-writing to add and edit NPCs, Locations, and Quests in the game, but my List code had to update a little bit:
ForEach(game.npcs) { npc in
NavigationLink {
NPCDetailView(npc: npc)
} label: {
NPCRow(npc: npc)
}
}
.onDelete(perform: $game.npcs.remove
Still not bad, but another level of relationships to consider. And since this isn’t using @ObservedResults
, I couldn’t use the Realm implementation of .searchable
, but would have to implement it myself. Not a big deal, but more work.
Frozen Objects and Appending to Lists
Now, up to this point, I have a working app. I could ship this as-is. Everything is still simple with the Realm Swift SDK property wrappers doing all the work.
But I wanted my app to do more.
I wanted to be able to add Locations and Quests from the NPC view, and have them automatically appended to the NPC. And I wanted to be able to view and add a quest-giver from the quest view. And I wanted to be able to view and add NPCs to locations from the location view.
All of this required a lot of appending to lists, and when I started trying to do this with quick writes after creating the object, I realized that wasn’t going to work. I’d have to manually pass objects around and append them.
What I wanted was to do something like this:
func addLocationToNpc(npc: NPC, game: Game, locationName: String) {
let realm = try! Realm()
let thisLocation = game.locations.where { $0.name == locationName }.first!
try! realm.write {
npc!.locations.append(thisLocation)
}
}
This is where something that wasn’t entirely obvious to me as a new developer started to get in my way. I had never really had to do anything with threading and frozen objects before, but I was getting crashes whose error messages made me think this was related to that. Fortunately, I remembered writing a code example about thawing frozen objects so you can work with them on other threads, so it was back to the docs - this time to the Threading page that covers Frozen Objects. (More improvements that the Realm Swift SDK team has added since I’ve joined MongoDB - yay!)
After visiting the docs, I had something like this:
func addLocationToNpc(npc: NPC, game: Game, locationName: String) {
let realm = try! Realm()
Let thawedNPC = npc.thaw()
let thisLocation = game.locations.where { $0.name == locationName }.first!
try! realm.write {
thawedNPC!.locations.append(thisLocation)
}
}
That looked right, but was still crashing. But why? (This is when I cursed myself for not providing a more thorough code example in the docs. Working on this app has definitely produced some tickets to improve our documentation in a few areas!)
After spelunking in the forums and consulting the great oracle Google, I ran across a thread where someone was talking about this issue. It turns out, you have to thaw not just the object you’re trying to append to but also the thing you’re trying to append. This may be obvious to a more experienced developer, but it tripped me up for a while. So what I really needed was something like this:
func addLocationToNpc(npc: NPC, game: Game, locationName: String) {
let realm = try! Realm()
let thawedNpc = npc.thaw()
let thisLocation = game.locations.where { $0.name == locationName }.first!
let thawedLocation = thisLocation.thaw()!
try! realm.write {
thawedNpc!.locations.append(thawedLocation)
}
}
Great! Problem solved. Now I could create all the funcs I needed to manually handle the appending (and removing, as it turns out) of objects.
Everything Else is Just SwiftUI
After this, everything else I had to learn to produce the app was just SwiftUI, like how to filter, how to make the filters user-selectable, and how to implement my own version of .searchable
.
There are definitely some things I’m doing with navigation that are less than optimal. There are some UX improvements I still want to make. And switching my @ObservedRealmObject var game: Game
back to @ObservedResults
with the new filtering stuff will help with some of those improvements. But overall, the Realm Swift SDK property wrappers made implementing this app simple enough that even I could do it.
In total, I built the app in two weekends and a handful of weeknights. Probably one weekend of that time was me getting stuck with the appending to lists issue, and also making a website for the app, getting all the screenshots to submit to the App Store, and all the “business” stuff that goes along with being an indie app developer.
But I’m here to tell you that if I, a less-experienced developer with exactly one prior app to my name - and that with a lot of feedback from my lead - can make an app like Shattered Ring, you can, too. And it’s a heck of a lot easier with SwiftUI + the Realm Swift SDK’s SwiftUI features. Check out the SwiftUI Quick Start for a good example to see how easy it is.
所有评论(0)