For fun only. K-Saju is an entertainment project. The K-Pop roles below are a playful interpretation of saju-inspired signals, not personality assessment or advice.
A two-person compatibility page can stay stateless with almost no effort. Put both birth dates in a URL, render the result on the server, and the link is the record. No account, no database, no cleanup job.
That was already a product rule in K-Saju. We do not retain personal inputs. A result is reproducible from its GET parameters.
Then we built /crew: “What if your friend group debuted as a K-Pop group?” A creator makes a link, sends it to a group chat, and each friend enters their own birth date. At three to seven members, the app assigns distinct positions, shows pairwise chemistry, and creates a shareable poster.
The fun part is the casting. The engineering problem is that the social flow needs a temporary shared state. A link cannot accumulate submissions by itself.
This post is about the decisions behind that feature: where we allowed state, how we made the result durable without retaining a lobby forever, and how we kept the casting explainable instead of treating it as a black-box score.
The conflict: a self-service group flow needs somewhere to collect data
There were two clean but incomplete options.
The first was to keep everything stateless. The creator would enter all members' dates at once, then receive a result URL. It matched our existing architecture, but it defeated the point of sharing a link. The person who starts the group often does not know everyone else's date, and asking them to collect it in a chat creates friction before the feature has started.
The second was a conventional persistent group object. It would make joining easy, but it would turn a deliberately stateless service into one that keeps user-provided dates indefinitely unless we built retention and deletion policies around it.
We chose a hybrid instead:
- The lobby is temporary state. It stores only the date of birth, time zone, optional nickname, and optional crew name.
- The lobby expires after 30 days.
- A completed result becomes a stateless permalink. It encodes the group inputs in the URL and can render after the lobby is gone.
The distinction matters. Storage exists only to assemble a group. It is not the source of truth for a shared result.
The lobby lives in one JSON file per crew. At this scale, a filesystem store was a better fit than adding a database dependency. Creation sweeps expired records; reads delete an expired record before returning it. Writes go through a temporary file and rename so a half-written JSON file is not visible as a crew.
async function writeAtomic(id: string, rec: CrewRecord): Promise<void> {
await mkdir(crewDir(), { recursive: true });
const tmp = fileFor(id) + `.tmp-${process.pid}-${Date.now()}`;
await writeFile(tmp, JSON.stringify(rec), "utf8");
await rename(tmp, fileFor(id));
}
The permanent result page is /crew/r. Its query string contains the member dates, optional per-member nicknames, time zone, and language. The codec enforces the same three-to-seven-member constraint on decode that the collection flow enforces on input. A valid URL is enough to recompute the whole report and its Open Graph image.
There is an unavoidable privacy trade-off here: the share link carries birth dates and nicknames. We say that directly in the lobby and again on the result page. The design does not claim to hide information that a recipient of the link can inspect. It limits what the server retains after the group has been assembled.
The creator can also delete a lobby immediately. The authorization key is carried in the URL fragment, not in a query parameter. Browsers do not send fragments in HTTP requests, so the server never receives the key as part of a page request. The delete API receives it only when the creator explicitly uses the management action.
That is not a substitute for access control in a multi-user system. It is a small, appropriate boundary for an anonymous, short-lived lobby with no accounts.
A result link should survive the lobby that created it
A 30-day lobby is useful for collection, but it is a poor long-term share artifact. A friend may revisit a result after the TTL. A social preview may be fetched later. A link that quietly becomes a 404 makes the group feature feel broken.
The solution was to treat the lobby and the result as different resources:
/** Build the /crew/r search params for a finished group. */
export function encodeCrewParams(p: CrewParams, lang: "en" | "ko"): URLSearchParams {
const sp = new URLSearchParams();
sp.set("m", p.dates.join("."));
p.nicks.forEach((n, i) => {
if (n) sp.set(`n${i + 1}`, n);
});
sp.set("tz", p.tz);
if (lang === "ko") sp.set("lang", "ko");
return sp;
}
The lobby URL identifies a temporary resource. The result URL carries the inputs needed to derive a result. This gives us a useful failure mode: losing a lobby stops future joins, but it does not invalidate results already shared.
There are costs. URLs can be copied into logs, analytics, chat previews, and browser history. We deliberately keep the encoded payload narrow, place no real names or contact details in it, and tell users what it contains. If the feature later needs edits, access controls, or a larger data model, that would be a reason to revisit the architecture rather than stretching this URL format beyond its job.
Casting roles with a deterministic engine
“Your group has a main dancer and a producer” is easy copy to generate. The harder question is whether a person can tell why they got that role, and whether refreshing the page changes it.
We made computeGroupChemistry a pure deterministic function. It accepts three to seven existing card payloads and returns:
- every
i < jpairwise chemistry result; - an average pair score and a small five-element coverage bonus;
- the strongest pair and, when distinct, the lowest-scoring “tension” pair;
- a group concept; and
- one unique K-Pop position per member.
The group score is intentionally modest: clamp(58, 99, averageScore + (elementCoverage - 3) * 2). The pairwise average does most of the work. Element coverage contributes a small bonus for a group that covers more of the five-element model, but it cannot overpower the underlying pair results. We keep the same friendly floor used by the two-person feature: a low score becomes story tension, not a negative judgment about real friends.
For positions, each member receives a score for each of eight roles: leader, main vocal, main dancer, visual, rapper, maknae, mood maker, and producer. The signals come from the existing fortune-axis values, element balance, and day-master strength. For example, the leader score favors authority plus a small strength bonus; main vocal favors output plus fire.
The important implementation detail is that this is a global assignment problem, not eight independent “pick the maximum” calls. Independent picks can give every strong member the same role. We create a member-by-position matrix, sort cells by score, then greedily assign the highest available cell while ensuring that both the member and position are unused.
cells.sort(
(a, b) => b.score - a.score || a.position - b.position || a.member - b.member,
);
const byMember = new Array<PositionAssignment | null>(members.length).fill(null);
const usedPositions = new Set<number>();
let assigned = 0;
for (const c of cells) {
if (assigned === members.length) break;
if (byMember[c.member] !== null || usedPositions.has(c.position)) continue;
byMember[c.member] = { positionId: POSITION_ORDER[c.position]!, reason: c.reason };
usedPositions.add(c.position);
assigned++;
}
The tie-breaks are explicit: position-table order, then member index. This looks unglamorous, but it is what makes a shared result stable. Given the same inputs, the result and the explanation line are the same.
Each cell also records its highest-contributing signal. The UI can therefore say that someone was cast as leader because their authority signal led the group, rather than presenting a score with no provenance. In an entertainment feature, “explainable” does not mean scientifically validated. It means the product does not pretend its output appeared by magic.
We also avoided making the group label depend on raw counts, because a seven-person crew has 21 pairs while a three-person crew has three. Group concepts use shares of pair types, with ordered thresholds. We tuned those thresholds against 300 simulated groups so the six concepts did not collapse into one default label. The simulation was not a claim about users; it was a quick distribution check for product copy.
Concurrency and abuse were part of the product flow
A group-chat link creates a predictable race: several people tap Join at the same time. Joining is a read-modify-write operation, so naive file access can lose a member even if every request returns success.
The store serializes joins per crew ID in the Node process. It also rejects an eighth member, malformed dates, and an accidental duplicate made of the same date plus nickname. Twins with the same date remain possible.
We added per-IP daily caps to creation and joining routes as well. Rate limits do not make a public form abuse-proof, but they prevent the obvious path from turning an inexpensive filesystem feature into a pile of junk records.
This was another reason to name the deployment assumption in code: in-memory serialization is sufficient for our single Node process and filesystem store. It is not a distributed lock. If the service moves to multiple instances, the storage and locking design must move with it.
Rendering a group poster with Satori
The permanent URL also powers the social preview. The OG route rebuilds the charts and group result directly from the query string, then renders a 1200×630 poster with three to seven chick characters and their position labels.
Satori is excellent for programmatic social images, but it is not a browser. Its layout and font support reward simple flexbox layouts and punish assumptions that happen to work in CSS on a normal page. We avoided emoji-dependent graphics, used image assets for the chicks, capped labels, and set flexShrink: 0 on lineup items. Character sizes step down as the crew grows so seven members still fit inside the 1100-pixel content width.
That route is another practical benefit of stateless results: an OG crawler does not need a session, a cookie, or a live lobby record. It only needs the URL.
What I would keep, and what I would change later
The hybrid model gave us the social interaction we wanted without turning short-lived collection data into a permanent dataset. It also made the share artifact stronger than the temporary workflow that created it.
I would keep the separation between “assemble a group” and “render a result.” It makes retention policy, deletion behavior, and caching much easier to reason about.
I would not force this implementation to become a general collaboration system. Editing members, extending storage, private groups, and multi-instance deployment all change the problem. The next version would need a real persistence and authorization design.
For this feature, a narrow temporary lobby plus a reproducible result URL was enough. It let friends fill in their own data, made the K-Pop casting feel consistent, and kept the server’s memory of the group deliberately short.
Top comments (0)