DEV Community

keyoneok
keyoneok

Posted on

Modeling 恋みくじ Results as Structured Content, Not Random Strings

Disclosure: I am involved in the project discussed in this article. I am sharing the content-modeling lessons behind it rather than presenting this as an independent review.

The first version of a fortune application is usually built around an array of strings:

javascript
const fortunes = [
"A new relationship may begin soon.",
"Be patient and wait for the right moment.",
"Someone may be thinking about you."
];

const result =
fortunes[Math.floor(Math.random() * fortunes.length)];

This is enough for a prototype. It is also where many fortune applications stop.

But a Japanese 恋みくじ —a love-focused form of omikuji—needs more than a collection of interchangeable messages.

A visitor may be thinking about unrequited love, reconciliation, a delayed reply, a long-distance relationship, or a new encounter. A result that feels appropriate in one situation may feel careless or confusing in another.

Once the number of results grows, a flat string array becomes difficult to write, localize, test, and maintain.

The challenge is no longer “How do I choose a random sentence?”

It becomes:

How do I model emotional content so that every result remains coherent, culturally understandable, and safe to present?

This article explores one possible architecture.

Why a flat array does not scale

Imagine that the application has 100 fortune messages.

Some messages are optimistic. Others recommend patience. A few are written for reconciliation, while others assume the visitor has not started a relationship yet.

With a flat array, all of these messages are treated as equal candidates.

That creates several problems:

  • A reconciliation message may appear to someone asking about a new encounter.
  • A strongly positive headline may be paired with cautious advice.
  • Two nearly identical results may appear consecutively.
  • Translators may understand the sentence but miss its emotional purpose.
  • Editors cannot easily find every result associated with a particular situation.
  • Automated tests can verify the data type, but not the content structure.

Randomness should decide among suitable results. It should not decide whether a result is suitable.

Treat each fortune as structured content

Instead of storing a fortune as one string, I prefer treating it as a small content object.

typescript
type RelationshipSituation =
| "new_encounter"
| "unrequited_love"
| "waiting_for_reply"
| "reconciliation"
| "long_distance"
| "general";

type FortuneTone =
| "bright"
| "gentle"
| "reflective"
| "cautious";

type Fortune = {
id: string;
situations: RelationshipSituation[];
tone: FortuneTone;
headline: string;
interpretation: string;
action: string;
reflection: string;
weight: number;
locale: string;
};

A single result might look like this:

json
{
"id": "reply-gentle-014",
"situations": ["waiting_for_reply", "general"],
"tone": "gentle",
"headline": "Let silence have a little space",
"interpretation": "A delayed reply does not always mean that someone has lost interest.",
"action": "Avoid sending another message only to escape the discomfort of waiting.",
"reflection": "What would help you feel calm even before the reply arrives?",
"weight": 1,
"locale": "en"
}

This structure separates four different jobs:

  1. Headline creates the memorable moment.
  2. Interpretation connects the result to the visitor’s situation.
  3. Action offers a small and realistic next step.
  4. Reflection gives the visitor something to consider after leaving.

It also makes each result easier to review. An editor can check whether the headline, interpretation, and action belong together instead of evaluating one long paragraph.

Separate eligibility from randomness

A useful selection process has at least two stages.

First, determine which results are eligible. Then select one from that smaller group.

typescript
function getEligibleFortunes(
fortunes: Fortune[],
situation: RelationshipSituation,
locale: string
): Fortune[] {
return fortunes.filter((fortune) => {
const matchesLocale = fortune.locale === locale;

const matchesSituation =
  fortune.situations.includes(situation) ||
  fortune.situations.includes("general");

return matchesLocale && matchesSituation;
Enter fullscreen mode Exit fullscreen mode

});
}

The random function only receives results that match the selected context.

typescript
function selectFortune(fortunes: Fortune[]): Fortune {
const index = Math.floor(Math.random() * fortunes.length);
return fortunes[index];
}

This is still a simple implementation, but it prevents many obvious content mistakes.

The important design decision is that random selection happens after the application has applied its content rules.

Use weighting carefully

Not every result needs to appear with identical frequency.

A broadly applicable result may be suitable for several situations, while a highly specific result should appear only occasionally. Weighting can provide more control.

typescript
function selectWeightedFortune(fortunes: Fortune[]): Fortune {
const totalWeight = fortunes.reduce(
(sum, fortune) => sum + fortune.weight,
0
);

let position = Math.random() * totalWeight;

for (const fortune of fortunes) {
position -= fortune.weight;

if (position <= 0) {
  return fortune;
}
Enter fullscreen mode Exit fullscreen mode

}

return fortunes[fortunes.length - 1];
}

However, weighting should not be used to manipulate vulnerable visitors.

For example, an application should not deliberately show alarming results more frequently because fear produces additional clicks. It should not increase the probability of a positive result after the visitor has viewed an advertisement either.

Weighting is useful for content balance, not emotional pressure.

Prevent immediate repetition

Even with a large result collection, repetition can make the experience feel mechanical.

One privacy-friendly option is to remember a small number of recent result IDs in local storage.

typescript
const HISTORY_KEY = "recent_fortune_ids";
const HISTORY_LIMIT = 3;

function getRecentIds(): string[] {
try {
return JSON.parse(
localStorage.getItem(HISTORY_KEY) ?? "[]"
);
} catch {
return [];
}
}

function rememberFortune(id: string): void {
const updated = [
id,
...getRecentIds().filter((recentId) => recentId !== id)
].slice(0, HISTORY_LIMIT);

localStorage.setItem(HISTORY_KEY, JSON.stringify(updated));
}

The recent IDs can be excluded before selection:

typescript
function excludeRecentResults(
fortunes: Fortune[],
recentIds: string[]
): Fortune[] {
const filtered = fortunes.filter(
(fortune) => !recentIds.includes(fortune.id)
);

return filtered.length > 0 ? filtered : fortunes;
}

This does not require a user account or a server-side history.

It is also important not to create an endless loop of redraws. Removing immediate repetition improves quality, but the interface should still encourage visitors to reflect on a result rather than repeatedly drawing until they receive the answer they want.

Model meaning before translating text

Localization becomes easier when translators can see the function of each field.

Consider the Japanese term 恋みくじ itself. An English interface might use “love fortune,” but that translation does not fully explain the cultural ritual associated with omikuji.

The content model can preserve the original product term while localizing the explanation:

json
{
"product_name": "恋みくじ",
"short_explanation": "A Japanese-style love fortune",
"draw_instruction": "Think about your romantic question, then draw one fortune."
}

The goal is not to translate every Japanese expression literally. It is to preserve the emotional purpose.

A result that sounds gentle in Japanese can become unusually commanding when translated directly into English. A phrase intended as poetic ambiguity can accidentally sound like a factual promise.

For this reason, localization review should ask:

  • Does the result still have the same emotional tone?
  • Does it sound like reflection or prediction?
  • Is the suggested action reasonable in the target culture?
  • Does the translation preserve uncertainty?
  • Could the result be misunderstood as professional advice?

The tone and situations fields provide translators with context that a standalone sentence cannot.

Validate content like code

Structured content can be validated before deployment.

typescript
function validateFortune(fortune: Fortune): string[] {
const errors: string[] = [];

if (!fortune.id) {
errors.push("Missing ID");
}

if (fortune.situations.length === 0) {
errors.push(${fortune.id}: no situations assigned);
}

if (fortune.headline.length > 80) {
errors.push(${fortune.id}: headline is too long);
}

if (fortune.weight <= 0) {
errors.push(${fortune.id}: weight must be positive);
}

if (!fortune.interpretation.trim()) {
errors.push(${fortune.id}: missing interpretation);
}

return errors;
}

Basic validation can detect incomplete entries, duplicate IDs, unsupported locales, invalid weights, and overly long headlines.

Content-specific tests can go further:

  • Every situation should have a minimum number of eligible results.
  • Every supported locale should contain the same required IDs.
  • No result should combine a highly positive headline with contradictory advice.
  • Restricted medical or psychological claims should be flagged for review.
  • Result IDs should remain stable across content updates.

These checks do not replace human editing. They protect editors from mechanical errors so they can spend more time reviewing tone and meaning.

Separate content from presentation

The result object should not contain HTML such as <strong>, layout instructions, or color names.

This keeps the content independent from the interface.

tsx
function FortuneResult({ fortune }: { fortune: Fortune }) {
return (



{fortune.headline}

  <p>{fortune.interpretation}</p>

  <section>
    <h3>A small step</h3>
    <p>{fortune.action}</p>
  </section>

  <section>
    <h3>Something to consider</h3>
    <p>{fortune.reflection}</p>
  </section>
</article>

);
}

The same structured result can later be rendered as:

  • A web page.
  • A mobile result card.
  • A shareable image.
  • An accessible text-only view.
  • A saved private entry.
  • A localized social preview.

Presentation can evolve without rewriting the entire fortune database.

Privacy should influence the architecture

A relationship situation can be sensitive even when it looks like a simple category.

The server often does not need to know that a visitor selected “reconciliation” or “waiting for a reply.” If the result collection is small enough, filtering and selection can happen in the browser.

If analytics are necessary, aggregate events may be enough:

javascript
track("fortune_draw_completed", {
locale: currentLocale
});

Before adding the selected situation to that event, it is worth asking whether the information is genuinely needed.

Good architecture is not only about scalability and speed. It is also about deciding which data should never be collected.

Applying the model to a real project

I have been exploring these ideas while working with Ichizenn’s 恋みくじ, a browser-based Japanese love-fortune experience.

The most important realization was that adding more results does not automatically improve the product.

A smaller collection of coherent, carefully reviewed results is usually more valuable than a large collection of generic predictions. Structure makes it possible to expand the content without losing consistency.

It also makes future improvements safer. New situations, languages, and result formats can be introduced as explicit dimensions instead of being hidden inside paragraphs.

A practical selection pipeline

Putting the ideas together, the complete process might look like this:

typescript
function drawFortune(
allFortunes: Fortune[],
situation: RelationshipSituation,
locale: string
): Fortune {
const eligible = getEligibleFortunes(
allFortunes,
situation,
locale
);

if (eligible.length === 0) {
throw new Error("No eligible fortunes found");
}

const withoutRecentResults = excludeRecentResults(
eligible,
getRecentIds()
);

const selected = selectWeightedFortune(
withoutRecentResults
);

rememberFortune(selected.id);

return selected;
}

This pipeline is intentionally understandable:

  1. Filter by language and situation.
  2. Exclude recently displayed results when possible.
  3. Select from the remaining candidates.
  4. Remember the result locally.
  5. Render the structured content accessibly.

The algorithm is not the most difficult part.

The difficult part is defining what makes a result eligible, appropriate, consistent, and respectful.

Final thoughts

A digital 恋みくじ may be powered by random selection, but randomness should be the final step—not the entire product model.

Structured content provides several benefits:

  • More coherent results.
  • Safer contextual selection.
  • Easier localization.
  • Automated validation.
  • Better accessibility.
  • Less dependence on user tracking.
  • Clearer separation between content and presentation.

This approach can also apply to quizzes, recommendation tools, reflection prompts, educational feedback, and other products in which short pieces of text need to feel relevant without pretending to know everything about the user.

If you were modeling content for an emotionally sensitive application, which rule would you enforce in code—and which decision would you always leave to a human editor?

Top comments (4)

Collapse
 
koikuji profile image
helha

One additional challenge I encountered after writing this article is versioning.

Fortune content behaves more like product data than ordinary copy. Once a result has been translated, tested, shared, or included in analytics, changing its meaning while keeping the same ID can create inconsistencies.

A better model may include a content version and separate review status:

type FortuneStatus = "draft" | "review" | "published" | "archived";

type FortuneMetadata = {
  version: number;
  status: FortuneStatus;
  reviewedAt?: string;
};
Enter fullscreen mode Exit fullscreen mode

This would make editorial changes easier to audit without treating every wording improvement as a completely new fortune.

I’m curious how others manage versioning for structured content. Do you keep it in Git, a CMS, or a separate content repository?

Collapse
 
koikuji profile image
helha

Disclosure: I’m also involved in testing this project.

What stood out to me is the distinction between randomness and eligibility. In many small apps, every result is placed in one array and randomness is expected to create variety. But variety is not the same as relevance.

For 恋みくじ, a technically valid result can still feel emotionally wrong if it assumes an existing relationship when the user is asking about a new encounter. Filtering by situation before applying randomness seems like a small architectural decision, but it has a major effect on the quality of the experience.

I would also be interested in a follow-up about content testing—especially how to detect contradictory combinations of headline, interpretation, and suggested action without trying to automate all editorial judgment.

Collapse
 
doichizen profile image
doichizen

The strongest idea here is separating eligibility from randomness. A result should first match the user’s situation, language, and content rules; only then should it be selected randomly. Treating each 恋みくじ result as structured data also makes localization, validation, and future expansion much easier.

Collapse
 
devtome profile image
devtome

Filtering by situation before applying randomness seems like a small architectural decision, but it has a major effect on the quality of the experience.