Skip to content

Achievement System

Build a full achievement system — when players hit specific milestones (gold over 100, 5+ combat wins, discovering a hidden area...), a golden achievement notification pops up on screen. Use boolean variables to track which achievements are unlocked, and a message renderer to display an achievement panel.


What you'll build

An achievement system embedded right in the chat:

  • Golden popup notifications — the instant a player hits a milestone, a golden achievement toast appears on screen (using the achievement style), e.g. "Achievement Unlocked: Big Spender"
  • Automatic detection — the engine monitors variable changes in the background and triggers automatically when conditions are met, no player action required
  • Fire-once guarantee — each achievement unlocks exactly once and never pops again. maxFireCount and boolean variables provide a double safety net
  • Achievement panel — a mini panel below the last message lists all achievements and their unlock status (unlocked = golden icon, locked = grey lock)

How it works

The core loop is: variable changes → engine detects the variable crossing a threshold → behavior fires → notification pops up + boolean variable set to true.

Player accumulates 101 gold during the adventure
  → Engine detects gold crossed above 100
  → "Big Spender" behavior fires
  → Actions execute: achievement_rich set to true, golden notification "Achievement Unlocked: Big Spender"
  → maxFireCount: 1 ensures this behavior never fires again
  → Message renderer reads achievement_rich = true, panel shows golden trophy icon

There's an important design decision here: why use variable-crossed instead of state-change?

  • state-change means "check whenever any variable changes" — very broad. If you use state-change + condition gold gt 100, then every time gold goes from 101 to 102, 102 to 103... the condition gets re-evaluated. While maxFireCount: 1 prevents re-firing, the engine still does a pointless evaluation each time.
  • variable-crossed means "fire only at the instant gold goes from <= 100 to > 100" — precise and efficient. Combined with maxFireCount: 1, you get a double safety net.

Step by step

Step 1: Create variables

You need 5 variables — 2 number variables to track progress, and 3 boolean variables to track whether each achievement is unlocked.

Editor → left sidebar → Variables tab → click "Add Variable" for each one

Variable 1: Gold

FieldValueWhy
Display NameGoldFor your own reference in the variable list
IDgoldBehaviors and renderer code use this ID to read/write
TypeNumberGold is numeric and needs arithmetic
Default Value0New sessions start at 0 gold
CategoryStatsGroups it with character attributes
Behavior RulesCurrent gold count. The AI can modify this via directives when the narrative calls for it.Tells the AI what this is and how to use it

Variable 2: Combat wins

FieldValueWhy
Display NameCombat WinsEasy to identify
IDcombat_winsReferenced by behaviors
TypeNumberIt's a counter
Default Value0Start from 0
CategoryStatsCharacter attribute
Behavior RulesCumulative number of battles the player has won. The AI can +1 this via directive when the player wins a fight.Tells the AI when to increment

Variable 3: Achievement — Big Spender

FieldValueWhy
Display NameAchievement: Big SpenderEasy to identify
IDachievement_richAll achievement variables use the achievement_ prefix
TypeBooleanOnly two states: unlocked or locked
Default ValuefalseLocked at the start
CategoryAchievementsGroup all achievement variables under one category for easy management
Behavior RulesDo not modify this variable directly — achievements are unlocked automatically by behavior rules when conditions are met, which also triggers a notification. Modifying it manually bypasses the notification system.Achievements must fire through behaviors to show the notification correctly

Variable 4: Achievement — First Blood

FieldValueWhy
Display NameAchievement: First BloodEasy to identify
IDachievement_warriorSame prefix convention
TypeBooleanSame as above
Default ValuefalseLocked at the start
CategoryAchievementsSame as above
Behavior RulesDo not modify this variable directly — achievements are unlocked automatically by behavior rules when conditions are met, which also triggers a notification. Modifying it manually bypasses the notification system.Same reason

Variable 5: Achievement — Trailblazer

FieldValueWhy
Display NameAchievement: TrailblazerEasy to identify
IDachievement_explorerSame prefix convention
TypeBooleanSame as above
Default ValuefalseLocked at the start
CategoryAchievementsSame as above
Behavior RulesDo not modify this variable directly — achievements are unlocked automatically by behavior rules when conditions are met, which also triggers a notification. Modifying it manually bypasses the notification system.Same reason

Why use separate boolean variables for achievements?

Because the message renderer needs to read each achievement's state to display the panel. If you only relied on maxFireCount to prevent re-firing, the renderer would have no way to know "is this achievement unlocked or not?" — it can't see a behavior's fire count. Boolean variables are the public-facing state that the renderer and other behaviors can read.


Step 2: Create behaviors

You need 3 behaviors — one for each achievement.

Editor → left sidebar → Behaviors tab → click "Add Behavior" for each one

Behavior 1: Big Spender (gold > 100)

Basic info:

FieldValueWhy
NameAchievement: Big SpenderFor your own reference
Max Fire Count1Achievements unlock once — after firing, this behavior never runs again

Trigger (WHEN):

FieldValueWhy
Trigger TypeVariable Crossed Threshold (variable-crossed)We want to detect the instant gold crosses 100
Variable IDgoldMonitor the gold variable
DirectionRises Above (rises-above)Fire when gold goes from <= 100 to > 100
Threshold100The milestone value

Actions (DO):

Action TypeSettingPurpose
Set Variableachievement_rich set to trueMark the achievement as unlocked, for the renderer to read
Show NotificationMessage Achievement Unlocked: Big Spender, style achievementPop up the golden achievement toast

About maxFireCount: 1. This field is set on the behavior itself (not the trigger). It means "this behavior may execute at most 1 time ever." Once it's fired, no matter how gold changes afterward, this behavior will never run again. This is the core safeguard of the achievement system — nobody wants to see the same achievement pop twice.

Behavior 2: First Blood (combat wins > 5)

Basic info:

FieldValueWhy
NameAchievement: First BloodFor your own reference
Max Fire Count1Same as above

Trigger (WHEN):

FieldValueWhy
Trigger TypeVariable Crossed Threshold (variable-crossed)Detect the instant combat_wins crosses 5
Variable IDcombat_winsMonitor combat win count
DirectionRises Above (rises-above)Fire when combat_wins goes from <= 5 to > 5
Threshold5The milestone value

Actions (DO):

Action TypeSettingPurpose
Set Variableachievement_warrior set to trueMark the achievement as unlocked
Show NotificationMessage Achievement Unlocked: First Blood, style achievementPop up the golden achievement toast

Behavior 3: Trailblazer (keyword trigger)

This achievement is different from the first two — instead of monitoring a numeric threshold, it monitors message content. When the player says "explore" or the AI says "discover", and the achievement isn't already unlocked, it fires.

Basic info:

FieldValueWhy
NameAchievement: TrailblazerFor your own reference
Max Fire Count1Same as above

Trigger (WHEN):

This achievement needs to monitor two sources — player messages and AI messages. In Yumina, a behavior can only have one trigger, so you need to create two behaviors to cover both sources.

The simplest approach is to create two behaviors:

Behavior 3a: Trailblazer (player keyword)

FieldValueWhy
Trigger TypePlayer Said Keyword (keyword)Monitor player messages
KeywordexploreMatches when the player says "I want to explore"
Max Fire Count1Fire once only

Condition (ONLY IF):

Variable IDOperatorValueWhy
achievement_explorerEquals (eq)falseOnly fire if the achievement hasn't been unlocked yet

Actions (DO):

Action TypeSettingPurpose
Set Variableachievement_explorer set to trueMark the achievement as unlocked
Show NotificationMessage Achievement Unlocked: Trailblazer, style achievementPop up the golden achievement toast

Behavior 3b: Trailblazer (AI keyword)

FieldValueWhy
Trigger TypeAI Said Keyword (ai-keyword)Monitor AI replies
KeyworddiscoverMatches when the AI mentions "discover"
Max Fire Count1Fire once only

Conditions and actions are identical to Behavior 3a.

Why do we need the condition achievement_explorer eq false? Because two behaviors (3a and 3b) can both unlock the same achievement. Suppose Behavior 3a fires first — it sets achievement_explorer to true and uses up its own maxFireCount. But Behavior 3b's maxFireCount is still unused! Without the condition, Behavior 3b would still fire the next time it matches a keyword, and the player would see two notifications. With the condition in place, Behavior 3b checks that achievement_explorer is already true, the condition fails, and it doesn't fire.


Step 3: Build the achievement panel in the message renderer

This is the key step to get the achievement panel showing up in the chat. The panel only appears below the last message.

Editor → Message Renderer tab → select "Custom TSX" → paste the following:

tsx
export default function Renderer({ content, renderMarkdown, messageIndex }) {
  const api = useYumina();
  const msgs = api.messages || [];
  const isLastMsg = messageIndex === msgs.length - 1;

  // Achievement list definition
  const achievements = [
    {
      id: "achievement_rich",
      name: "Big Spender",
      desc: "Accumulate over 100 gold",
      icon: "💰",
    },
    {
      id: "achievement_warrior",
      name: "First Blood",
      desc: "Win more than 5 battles",
      icon: "⚔️",
    },
    {
      id: "achievement_explorer",
      name: "Trailblazer",
      desc: "Discover a hidden area or secret",
      icon: "🗺️",
    },
  ];

  // Count unlocked achievements
  const unlockedCount = achievements.filter(
    (a) => api.variables[a.id] === true
  ).length;

  return (
    <div>
      {/* Render message text normally */}
      <div
        style={{ color: "#e2e8f0", lineHeight: 1.7 }}
        dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
      />

      {/* Achievement panel — only below the last message */}
      {isLastMsg && (
        <div
          style={{
            marginTop: "16px",
            padding: "12px 16px",
            background: "linear-gradient(135deg, #1c1917, #292524)",
            border: "1px solid #44403c",
            borderRadius: "10px",
          }}
        >
          {/* Panel header */}
          <div
            style={{
              display: "flex",
              justifyContent: "space-between",
              alignItems: "center",
              marginBottom: "10px",
            }}
          >
            <span
              style={{
                fontSize: "13px",
                fontWeight: "bold",
                color: "#fbbf24",
                letterSpacing: "0.05em",
              }}
            >
              🏆 Achievements
            </span>
            <span style={{ fontSize: "12px", color: "#a8a29e" }}>
              {unlockedCount} / {achievements.length}
            </span>
          </div>

          {/* Achievement list */}
          <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
            {achievements.map((a) => {
              const unlocked = api.variables[a.id] === true;
              return (
                <div
                  key={a.id}
                  style={{
                    display: "flex",
                    alignItems: "center",
                    gap: "10px",
                    padding: "6px 8px",
                    borderRadius: "6px",
                    background: unlocked
                      ? "rgba(251, 191, 36, 0.08)"
                      : "rgba(120, 113, 108, 0.08)",
                  }}
                >
                  {/* Icon */}
                  <span style={{ fontSize: "18px", opacity: unlocked ? 1 : 0.3 }}>
                    {unlocked ? a.icon : "🔒"}
                  </span>

                  {/* Text */}
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div
                      style={{
                        fontSize: "13px",
                        fontWeight: "600",
                        color: unlocked ? "#fbbf24" : "#78716c",
                      }}
                    >
                      {a.name}
                    </div>
                    <div
                      style={{
                        fontSize: "11px",
                        color: unlocked ? "#a8a29e" : "#57534e",
                        marginTop: "1px",
                      }}
                    >
                      {a.desc}
                    </div>
                  </div>

                  {/* Status badge */}
                  {unlocked && (
                    <span style={{ fontSize: "11px", color: "#fbbf24" }}>
                      ✓ Unlocked
                    </span>
                  )}
                </div>
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}

Line-by-line breakdown:

  • const api = useYumina() — get the Yumina API to read variable state
  • isLastMsg — only show the panel on the last message, so it doesn't repeat on every message
  • achievements array — defines all achievement metadata (ID, name, description, icon) right in the renderer. Want to add a new achievement? Just add another entry to this array
  • api.variables[a.id] === true — reads the boolean variable's value to check if the achievement is unlocked
  • unlockedCount — tallies how many are unlocked, displayed in the header (e.g. "2 / 3")
  • Locked achievements show a grey lock icon, unlocked ones show their golden icon plus a "Unlocked" badge

Don't want to write code yourself? Use Studio AI

Editor top bar → click "Enter Studio" → AI Assistant panel → describe what you want in plain English, and the AI will generate the code for you.


Step 4: Save and test

  1. Click Save at the top of the editor
  2. Click Start Game or go back to the home page and start a new session
  3. Below the last message you should see the achievement panel — all 3 achievements greyed out with lock icons
  4. Test the gold achievement: chat with the AI and have your character earn more than 100 gold. When gold goes from <= 100 to > 100, a golden notification pops up: "Achievement Unlocked: Big Spender", and the first achievement on the panel turns golden
  5. Test the combat achievement: have your character win 6 battles. When combat_wins goes from 5 to 6, the notification pops: "Achievement Unlocked: First Blood"
  6. Test the exploration achievement: send a message containing "explore" (e.g. "I want to explore this cave"). If the keyword matches, the notification pops: "Achievement Unlocked: Trailblazer"

If something goes wrong:

SymptomLikely CauseFix
Can't see the achievement panelMessage renderer code wasn't saved or has a syntax errorCheck the compile status at the bottom of the message renderer — it should show a green "OK"
Gold passed 100 but no notificationThe variable didn't "cross" from <= 100 to > 100 — it was set directly to 200Make sure gold changes incrementally (AI adds/subtracts via directives), not in a single jump to a large number
Achievement popped twiceThe behavior's maxFireCount isn't set to 1Go back to the editor and check the behavior settings
Exploration achievement popped twiceBoth behaviors 3a and 3b fired, and the condition check is missingConfirm both behaviors have the condition achievement_explorer eq false
Panel status didn't updateVariable ID is misspelled in the renderer codeConfirm api.variables[a.id]'s a.id matches the variable ID exactly

Deep dive: variable-crossed vs state-change

This is the most important conceptual distinction in the achievement system — worth expanding on.

variable-crossed (Variable Crossed Threshold)

Detects an instantaneous event: "the variable crossed from one side of the threshold to the other."

gold: 80 → 95 → 101   ← fires on the 95→101 step (crossed above 100)
gold: 101 → 150 → 200  ← does NOT fire (already above the threshold)
gold: 200 → 50 → 120   ← fires on the 50→120 step (crossed above 100 again)

Key characteristics:

  • Only fires at the instant of crossing, not "fires continuously while above the threshold"
  • If the value drops below the threshold and rises back, it fires again (unless maxFireCount prevents it)
  • Good for: achievement unlocks, milestone notifications, HP-hits-zero death checks

state-change (Variable Changed)

Detects an ongoing event: "any variable changed at all."

gold: 80 → 95   ← fires (gold changed)
gold: 95 → 101  ← fires (gold changed again)
gold: 101 → 150 ← fires (gold still changing)
hp: 100 → 90    ← also fires (hp changed)

Key characteristics:

  • Any change to any variable triggers it
  • Needs conditions (ONLY IF) to filter
  • Good for: general state monitoring, switching world context based on current state

Why variable-crossed is right for achievements

Because achievements are milestones — you only care about the instant the line is crossed. If you used state-change + condition gold gt 100:

  1. gold goes from 95 to 101 → triggers → condition met → executes (correct)
  2. gold goes from 101 to 102 → triggers → condition met → tries to execute again (wrong! maxFireCount blocks it, but the engine still did a pointless evaluation)
  3. gold goes from 102 to 103 → triggers again → checks condition again...

With variable-crossed:

  1. gold goes from 95 to 101 → crossing detected above 100 → fires → executes (correct)
  2. gold goes from 101 to 102 → no crossing event → doesn't fire at all (efficient)

Bottom line: precise triggers = fewer wasted evaluations = better performance and cleaner logic.


Extension ideas

Once you've built the basic 3 achievements, you can extend with more using the same pattern:

Achievement NameVariable IDTrigger MethodCondition
Chatterboxachievement_talkativeCreate a message_count variable, +1 each turn, fire when it crosses 50variable-crossed, message_count rises above 50
Hoarderachievement_hoarderFire when gold crosses 500variable-crossed, gold rises above 500
Socialiteachievement_socialAI says keyword "become friends" or "trusts you"ai-keyword, condition achievement_social eq false
Back from the Deadachievement_survivorHP crosses below 10 (near-death), then later crosses above 50 (recovery)Two linked behaviors

For each new achievement, you only need to:

  1. Add a boolean variable (achievement_xxx, default false)
  2. Add a behavior (trigger + actions + maxFireCount: 1)
  3. Add an entry to the achievements array in the message renderer

Quick reference

What you want to doHow to do it
Unlock achievement when a number hits a targetBehavior trigger: "Variable Crossed Threshold" (variable-crossed), direction: rises above, set threshold
Trigger achievement on a keywordBehavior trigger: "Player Said Keyword" (keyword) or "AI Said Keyword" (ai-keyword)
Ensure achievement fires only onceSet maxFireCount: 1 on the behavior; for keyword triggers, also add condition achievement_xxx eq false
Pop up a golden achievement notificationBehavior action: Show Notification, style achievement
Show an achievement panel in the chatMessage renderer reads boolean variables and renders unlocked/locked states
Add a new achievementAdd boolean variable + add behavior + add entry to renderer array

Try it yourself — importable demo world

Download this JSON and import it to see everything in action:

recipe-13-demo.json

How to import:

  1. Go to Yumina → My WorldsCreate New World
  2. In the editor, click More ActionsImport Package
  3. Select the downloaded .json file
  4. A new world is created with all variables, behaviors, and renderer pre-configured
  5. Start a new session and try it out

What's included:

  • 5 variables (gold, combat_wins, achievement_rich, achievement_warrior, achievement_explorer)
  • 4 behaviors (Big Spender, First Blood, Trailblazer x2)
  • A message renderer with the achievement panel

This is Recipe #13

The achievement system combines freely with other recipes — pair it with the combat system to track battle wins, the shop system to track gold accumulation, or the quest tracker to track completed quests. Variables are universal, and behaviors don't interfere with each other.