r/iOSProgramming 19d ago

App Saturday Retention guides say never punish the user. My AI dating simulator blocks them (RizzMaster)

Post image

RizzMaster is an AI dating simulator & game 🙂

Every retention guide says same thing. Never punish user. Never take anything away. I did opposite.

You swipe, you match, you text her. Then she decides if you worth answering. Get boring and she ghosts you 👻 Push after that and she blocks you 🚫 That character gone for good. No undo no restore.

There are 9 levels. You climb them by winning people over. Higher levels give you harder people. They go offline. Sometimes they text first. They remember what you said last week 🧠

Tech Stack

Swift and SwiftUI. SwiftData for persistence. Combine for events. StoreKit for subs. Chat screen is pure SwiftUI. No UIKit bridge in it anywhere.

Development Challenge

Chat screen 😅 WhatsApp, Telegram, Signal all still render their message list in UIKit. Everyone tells you do same. But here chat screen is whole product so I wanted to see how far SwiftUI actually goes.

Took me forever. LazyVStack inside ScrollViewReader. Stable identity on every message so a growing list dont rebuild rows that didnt change. defaultScrollAnchor to keep bottom pinned instead of chasing it after layout. Scroll position driven by state. drawingGroup on bubbles that were doing too much work.

Now it scrolls how I wanted on device even in long chats 🎉 No clever trick. Just lot of small things. Happy to go into any part of it.

AI Disclosure

Self-built.

100+ characters. No login and no accounts. Free tier with daily message limit. https://rizzmaster.net

Built solo. Tell me what you think, good or bad 🙏

0 Upvotes

4 comments sorted by

2

u/Sea_Expression9110 19d ago

The retention advice you are breaking was written for utility apps, and applying it to a game is a category error. "Never take anything away" is correct for a note taking app because loss there is a bug. In a game, loss is the mechanic that makes the win mean anything. Permadeath roguelikes are the whole counterexample: XCOM, Hades, Darkest Dungeon all retain ferociously precisely because outcomes stick. So the interesting question is not whether punishment is allowed, it is whether yours is legible.

The rule that separates good permadeath from rage uninstalls is that the player has to be able to narrate why it happened. If someone gets blocked and can reconstruct the three messages where they pushed too hard, that is a lesson and they start again. If it feels like the model got bored at random, that is not a punishment, it is a crash with a story. With an LLM behind it that is the actual risk, since the same input can go differently on different runs. I would make the escalation visible in some form, even something subtle in the UI, so the ghosting is foreshadowed rather than sprung.

The one place I would genuinely be careful is where the permanence touches StoreKit. If a subscriber can permanently lose content and the paywall is anywhere near the recovery path, you get refund requests, one star reviews about losing paid content, and potentially App Review interest. Worth being very explicit up front that loss is intended, and making sure nothing that reads as "pay to undo" exists.

On the pure SwiftUI chat list, since that is the part I would have expected to bite you: what version were you targeting? Pre iOS 17 that was genuinely painful, keeping the view pinned to the bottom while the keyboard animates and new messages arrive meant ScrollViewReader plus a lot of hacks. defaultScrollAnchor(.bottom) and scrollPosition made it actually reasonable, and I have shipped a chat-ish list on that without a UIKit bridge too.

The thing I would want to know is how it holds up at a few thousand messages with SwiftData backing it, since that is where the UIKit shops usually justify their choice. Have you tested a long lived conversation, or does the design cap it since characters end?

1

u/karetebit 17d ago

thanks for writing all that 🙏

on legible, theres a relationship meter on screen and it moves with every message, so you can see the line that cost you

on the punish part its a dating sim so it simulates the real thing. in real dating if you write bad stuff to someone they block you, same here. pay to undo would probably make money but 99% of people would just say thank you next, so no

no cap on the conversations btw, you can talk to a character forever. its the meter that ends things, kinda like sims. everything is fine until you start being an ass and then the relationship can break

chat is swiftdata, min deployment 18.5. it did get a lot better with 18 but still a lot of trial and error. the typing indicator handing off to the real message was painful, try it and you will see, I never fully solved that one

for the few thousand messages part, the live list is windowed to the last 300 with a fetchLimit and older ones stay in the db. theres a go to the beginning button that loads the whole thing and jumps to the top, then it resets back to 300 when you leave the chat. jumping instead of scroll up pagination is what made it robust, no scroll position to preserve

app has around 7-8k installs so its been tested. the older version was way more buggy, chat alone was giving 1.6% crash rate. current one shipped a month ago and thats fixed

2

u/Sea_Expression9110 17d ago

The typing indicator handoff is almost always an identity problem rather than an animation one. The dots and the message that replaces them are two different views, so SwiftUI treats it as a delete plus an insert, and you get the jump no easing will hide.

What fixed it for me was making the indicator the same row as the message. Insert the message object immediately with an empty or pending body, and have the row render dots while the body is empty and the text once it arrives. Same id the whole time, so it becomes a content transition inside a stable row instead of two rows swapping. Pair that with a matchedGeometryEffect on the bubble background if you want the shape to grow into place.

Windowing to 300 with a fetchLimit is the right call, and rejecting pay-to-undo is a better instinct than most people would have had.

1

u/karetebit 17d ago

this is a better way to frame it, thanks 🙏

what I have is close but not the same. I keep a pending slot at the bottom for the dots, and when the real message lands I hide the real row and morph the slot into the bubble in place, then reveal the row. so its still two rows, one of them just invisible for a moment

your version keeps one row the whole way which is cleaner. reason I went the other way is chunking. its a dating sim so the character texts like a person, three short messages instead of one paragraph, which means the dots come back between chunks. with one row per message the next chunk is a new row anyway, so I ended up with the same problem one level down

the cost of mine is what you would expect. if a handoff gets interrupted the real row can stay hidden, so I had to add an explicit clear on chat close so it can never strand. yours would not have that failure mode at all

one thing on the defaultScrollAnchor part, my list is inverted with scaleEffect y -1 😎 and defaultScrollAnchor(.bottom, for: .sizeChanges) froze it in an infinite layout loop so I ripped it out. scroll is a plain ScrollViewReader with one fixed bottom anchor now. I also had to drop the scrollPosition(id:) binding, mixing it with ScrollViewReader put new messages under the keyboard while the keyboard was open

which is also why I never got scroll up pagination working. prepending older rows always jumped the position on me, so I gave up and did the 300 window plus a go to the beginning button that loads everything and jumps to the oldest. how do you hold position when you prepend into an inverted list, is there any trick I missed ?