What I built
I added a group feature to K-Saju. The flow is simple.
- Someone creates a crew and drops the link into a group chat.
- Each person opens the link and enters only their birth date (and optionally a nickname).
- Once everyone has joined, they get cast into a K-Pop group based on saju.
Each member receives a role—leader, visual, main vocalist, rapper, maknae, and more, across eight possible positions. The result also includes a pairwise chemistry-score matrix for the whole group, the highest-chemistry duo, and a tension line: the lowest-scoring pair, presumably the two who have been debating the dinner menu for 40 minutes. The group itself receives a one-line concept such as “the nation’s complete group.”
You can try it here: K-Saju Crew

The problem: this service stores nothing
K-Saju started with one architectural principle: do not permanently store personal information; keep the product stateless. There is no login and no database. Every result page is rendered on the server from GET parameters.
/card?date=1995-08-28, /match?a=...&b=...—the URL is the entire state.
But the ideal UX for a group feature is “share a link → everyone enters their own birthday → participants accumulate.” Someone has to collect those inputs, and that is state. The product principle and the desired UX collided head-on.
I considered three options.
- A. Completely stateless — One person encodes everybody’s birthday into the URL. It stores nothing, but turns one person into the organizer who has to enter every detail. It loses the fun of self-entry—and the viral structure that comes with it.
- B. Minimal temporary storage — Store only birth dates under a random ID in a lightweight store, then delete them automatically with a TTL.
- C. Client-side accumulation — Collect inputs in the client and update shared state. It avoids storage, but does not fit the “one link that everyone opens in the group chat” scenario, while adding complexity.
The solution: the lobby lasts 30 days, the result lasts forever
I chose a hybrid of B and A. The tension disappears when the state is split into “while people are gathering” and “after everyone has joined.”
- Lobby (while gathering): A temporary store with a 30-day TTL. It keeps only a birth date, time zone, and optional nickname—no full name, contact information, or account. When the time expires, the whole record disappears.
- Result (after gathering): The final result gets a permanent stateless permalink (
/crew/r?...) containing every member’s data as parameters. The link keeps working even after the lobby expires. The original principle remains: the URL is the state.
The creator’s deletion permission is handled with an admin key in the URL fragment (/crew/abc123#k=...). The fragment is never sent to the server, so the admin key does not appear in server logs. It is a small detail, but one I like.
There are also two layers of abuse protection: serialized join requests to prevent concurrent-join races, and daily creation/join limits per IP address.
The casting engine: deterministic, not random
If positions were assigned randomly, the fun would not last a day. The same members need to get the same result when they run it again—“Wait, is this real?” is half the viral loop—and there needs to be a reason for every assignment.
- The existing two-person chemistry engine (
computeChemistry) runs pairwise across N members to build a matrix. The group score combines the average with an element-coverage bonus. The highest and lowest pairs become the best duo and the tension line. - Positions are derived deterministically from each person’s five-element distribution, ten-god axis, and strength signals. Every one of the eight positions gets a line explaining the casting. Even in an entertainment feature, it matters not to throw out a score without a reason.
- I tuned the distribution with 300 simulated groups. It would not be much fun if one position dominated or only one of the six group concepts kept appearing.
// engine/src/groupChemistry.ts — signature only
export function computeGroupChemistry(members: CardPayload[]): GroupChemistryResult;
// 3–7 people. Pairwise matrix + eight-position casting + group concept.
// Pure function, deterministic.
I limited the group size to three through seven. It feels like the right size for an idol group, and the number of pairs grows as C(n,2), which quickly hurts both matrix readability and OG-rendering cost.
What I learned
- You can keep a principle by splitting it up. “No storage versus group feature” looked like an either/or choice. Separating the lifetime of state into a lobby and a result made it possible to keep both. After the TTL, we retain nothing, while the user still has a permanent link.
- Entertainment needs determinism even more. The same input producing the same result, with an explainable reason, creates trust—and resharing—for content people are supposedly viewing just for fun.
- Viral structure is decided in feature design. Two-person chemistry ends with a one-to-one share. A group gives every participant their own position card to reshare. The multiplier belongs in the design stage, not the marketing stage.
K-Saju is a K-culture entertainment experience made for fun. Please enjoy saju readings as content, not as a prediction. 🐣
Comments
Loading comments...
Leave a comment
Your comment will be published after admin approval.