Skip to content

M6.2 — Rarity & Affixes

What you'll learn

  • The rarity tiers (common → magic → rare → epic → legendary), their color convention, and how rarity gates how many affixes an item can have.
  • What an affix is — a prefix or suffix drawn from a pool — and how to roll an affix's value within its range.
  • How to build affix pools as data (Resources) with weights and level requirements.
  • How to read affix generation as a combinatorial-coverage problem (a QA bridge): the space of possible items is large, and you test the generator, not every output.

How it applies

  • Rarity is the loot loop's signal. The color of a drop tells the player, at a glance, whether to care. The whole reward rhythm — the flash of a yellow vs the shrug at a white — rides on a consistent, readable rarity convention. Get the colors and gating right and the player feels loot; get them muddy and every drop reads the same.
  • Affixes are where build variety is born. "+12 armor" and "8% increased attack speed" rolled onto the same base make two different items. The affix system is the genre's content multiplier: a small pool of affixes times a few slots produces an enormous item space from little authored data.
  • Level requirements pace power. Affixes gated by item/area level keep early drops modest and let strong rolls appear only deeper. This is the loot side of the same increasing-cost curve M5.3 used for XP — the dungeon doles out bigger numbers as you go deeper.
  • Combinatorial testing, named. You cannot test every possible rolled item — the combinations are astronomical. You test the generator's rules: each rarity yields the right affix count, no affix below its level requirement appears, values land in range, no duplicate affix on one item. That is combinatorial coverage of the rules, not the outputs — a QA discipline the genre demands.

Concepts

Rarity tiers

Rarity is an ordered set of tiers, each with a color and an affix-count rule. The Diablo/Torchlight lineage convention this book uses:

Tier Color Affixes
Common grey 0 (base type only)
Magic blue 1–2
Rare yellow 3–4
Epic purple 5–6
Legendary orange a fixed, hand-designed set (unique)

Common items are just their base type. Magic through Epic roll an increasing number of affixes from the pools. Legendary is special: rather than random affixes, a legendary is a designed item with chosen modifiers and often a unique effect — out of scope to author many of, but the tier exists in the enum.

Example

Rarity as data the rest of the game reads — color for display, affix count for rolling:

# res://scripts/rarity.gd
class_name Rarity
extends RefCounted

enum Tier { COMMON, MAGIC, RARE, EPIC, LEGENDARY }

const COLORS := {
    Tier.COMMON:    Color("c8c8c8"),
    Tier.MAGIC:     Color("6f8fff"),
    Tier.RARE:      Color("ffd54a"),
    Tier.EPIC:      Color("b061ff"),
    Tier.LEGENDARY: Color("ff7a1a"),
}

const AFFIX_COUNT := {            # [min, max] affixes per tier
    Tier.COMMON:    [0, 0],
    Tier.MAGIC:     [1, 2],
    Tier.RARE:      [3, 4],
    Tier.EPIC:      [5, 6],
    Tier.LEGENDARY: [0, 0],       # legendaries use a designed set, not rolls
}

One place defines what each tier means — its color (for the M6.4 beam and M7 tooltip) and its affix budget (for rolling). Changing the convention is editing this table, not hunting colors across the UI.

What an affix is

An affix is a single rolled modifier with a value range. "Of the Bear: +(8–15) armor" is a suffix; "Heavy: +(5–10) flat damage" is a prefix. Mechanically each affix is one (or a few) StatModifiers (M5.4) whose amount is rolled within the affix's range when the item drops. Prefixes and suffixes are conventionally separate pools, and an item draws at most one (or a bounded number) from each — which is why "rare" items read as "two prefixes and two suffixes."

Affix pools as data

An affix definition is a Resource: which stat and modifier type it grants, the value range to roll, a weight (how common it is), a minimum item level, and whether it's a prefix or suffix.

Example

An affix definition:

# res://scripts/affix.gd
class_name Affix
extends Resource

enum Kind { PREFIX, SUFFIX }

@export var name: String = "Affix"          # the displayed prefix/suffix word
@export var kind: Kind = Kind.PREFIX
@export var stat: StringName = &"damage"
@export var mod_type: StatModifier.Type = StatModifier.Type.FLAT
@export var value_min: float = 1.0
@export var value_max: float = 5.0
@export var weight: int = 100               # relative likelihood within its pool
@export var min_item_level: int = 1         # gated by item/area level

func roll(rng: RandomNumberGenerator) -> StatModifier:
    var m := StatModifier.new()
    m.stat = stat
    m.type = mod_type
    if mod_type == StatModifier.Type.FLAT:
        m.amount = float(rng.randi_range(int(value_min), int(value_max)))
    else:
        m.amount = rng.randf_range(value_min, value_max)
    return m

roll produces a concrete StatModifier with the value sampled in range. A pool is just an Array[Affix] (authored .tres files); the roller filters it by item level and kind, then samples by weight (the weighted-pick technique is the same one M6.3 uses for drop tables).

Rolling an item's affixes

Given a base item, a rarity, and an item level, the roller: looks up the affix count for the tier, picks that many affixes from the eligible pool (eligible = min_item_level <= item_level, not already chosen, respecting prefix/suffix balance), rolls each affix's value, and assembles them into the item's affixes list (M6.1). The result is a finished drop.

Example

The shape of rolling (weighted selection detailed in M6.3):

static func roll_affixes(pool: Array[Affix], tier: int, item_level: int,
                         rng: RandomNumberGenerator) -> Array[StatModifier]:
    var budget := Rarity.AFFIX_COUNT[tier]
    var count := rng.randi_range(budget[0], budget[1])
    var eligible := pool.filter(func(a): return a.min_item_level <= item_level)
    var chosen: Array[Affix] = []
    var mods: Array[StatModifier] = []
    for i in count:
        if eligible.is_empty():
            break
        var pick := _weighted_pick(eligible, rng)   # M6.3 technique
        eligible.erase(pick)                          # no duplicate affix on one item
        chosen.append(pick)
        mods.append(pick.roll(rng))
    return mods

eligible.erase(pick) prevents the same affix appearing twice on one item. count is drawn from the tier's budget. Each chosen affix rolls its own value. The output is the StatModifier list M6.1's roll_from_base puts on the drop.

Reading it as combinatorial coverage

The number of possible items — base types × rarities × affix combinations × rolled values — is enormous; you cannot enumerate outputs. You test the generator's invariants, which is combinatorial coverage of the rules:

  • Each tier produces an affix count in its [min, max] budget (boundary: exactly min, exactly max).
  • No affix with min_item_level > item_level ever appears (boundary: an affix gated one level above).
  • No affix appears twice on one item.
  • Every rolled value lands within [value_min, value_max] inclusive.
  • Prefix/suffix balance holds.

Seeded RNG (M5.2) makes each of these reproducible: a tester pins a seed and asserts the exact item, turning "loot is random" into "loot is deterministic given a seed," which is testable.

Walkthrough

  1. Create res://scripts/rarity.gd (tiers, colors, affix-count table) and res://scripts/affix.gd (the Affix Resource with roll).
  2. Author a handful of affix .tres files in res://resources/affixes/: e.g., "Heavy" (prefix, flat damage 5–10), "Sharp" (prefix, increased damage 0.10–0.20), "of the Bear" (suffix, flat armor 8–15), "of Haste" (suffix, increased move speed 0.05–0.10). Set weights and min_item_level.
  3. Create the roller (roll_affixes + the weighted pick, or stub the pick until M6.3) in an ItemFactory helper that also calls M6.1's roll_from_base.
  4. (Sanity, temporary) With a fixed GameState.rng.seed, roll a RARE item from the Iron Sword base at item level 5 and print its affixes. Re-run: the same seed yields the same affixes. Change the seed: a different set.
  5. Confirm the invariants by inspection: a rare has 3–4 affixes, none below your set min_item_level, no duplicates, values in range. (M6.3 supplies the weighted pick so rarer affixes appear less often.)
  6. Nothing is on screen yet — M6.3 decides when an item drops and at what rarity; M6.4 spawns the pickup with the rarity color from Rarity.COLORS.

Optional sanity check

Set one affix's min_item_level to 99 and roll many items at item level 5: that affix should never appear. Then set it to 1 and confirm it can appear. This is the level-gate boundary test. Separately, roll 100 items at a fixed seed sequence and assert every rolled value is within its affix's [value_min, value_max] — the range invariant. These two checks cover the generator's most failure-prone rules without enumerating the (astronomical) output space.

Self-check quiz

Q1 — What does an item's rarity tier primarily determine, mechanically?

A. The item's icon. B. How many affixes the item rolls (its affix-count budget), plus its display color; common rolls none, magic 1–2, rare 3–4, epic 5–6. C. The item's base damage. D. Which equipment slot it uses.

Reveal answer

B. Rarity gates the affix count and signals value via color; more affixes = more power and a rarer drop. A (icon) comes from the base type. C (base stats) is the base type's implicit. D (slot) is the base type. Rarity is specifically the affix budget + color.

Q2 — Why test the affix generator's invariants (count in budget, no sub-level affixes, values in range, no duplicates) rather than testing specific generated items?

A. Specific items are too small to test. B. The space of possible items (base × rarity × affix combos × values) is astronomically large and can't be enumerated; verifying the generation rules (combinatorial coverage) with seeded RNG covers correctness without checking every output. C. Generated items can't be inspected. D. The generator has no rules to test.

Reveal answer

B. You can't enumerate the item space, so you assert the rules that constrain every output — affix count within the tier budget, level gating, value ranges, uniqueness — which is combinatorial coverage of the generator. Seeded RNG makes each assertion reproducible. A, C, D are false.

Q3 — Why does the roller erase(pick) from the eligible list after choosing an affix?

A. To free memory. B. To prevent the same affix appearing twice on one item (an invariant); removing it from the eligible pool for that roll guarantees distinct affixes. C. Because affixes can only be used once ever. D. To make the roll faster.

Reveal answer

B. Erasing the picked affix from the per-roll eligible list ensures a single item can't roll "+5 damage" twice; it's the uniqueness invariant. C overstates it (the affix is fine on other items; the erase is scoped to this roll's local copy). A and D are not the reason.

Integration question

Q4 — open

Rarity gates affix count, affixes roll StatModifiers (M5.4) within ranges, and the whole result is an ItemData (M6.1). Explain how this layered design turns a small amount of authored content (a few base types, a couple dozen affixes) into a vast item space, and describe the full path of a single rare drop from generation to the player's stats changing — naming where rarity color, affix rolls, and the M5.4 recompute each enter.

Reveal expected answer

The design multiplies content because each layer is independent and composable: a few authored base types (slot, icon, implicit bonus) times the rarity budgets times the combinations of affixes drawn from a shared pool times the rolled values of each affix yields an item space whose size is the product of those factors — so a couple dozen affixes and a handful of bases generate effectively unlimited distinct rares, none individually authored. The path of one rare drop: M6.3 decides an item drops and rolls its rarity as RARE; the ItemFactory deep-duplicate()s a base type (M6.1) and calls roll_affixes, which reads RARE's [3,4] budget from Rarity.AFFIX_COUNT, filters the affix pool by item level, weight-picks distinct affixes, and rolls each one's value in range to produce a StatModifier list (M5.4 types); those become the item's affixes, and its rarity is set so Rarity.COLORS[RARE] (yellow) drives the M6.4 drop beam and the M7 tooltip. When the player picks it up (M6.4) and equips it (M7), item.all_modifiers() (implicit + rolled affixes) is appended to the player's modifiers and recompute_stats() (M5.4) rebuilds the derived StatBlock via the flat/increased/more formula, updating Health and emitting stats_recomputed so the HUD reflects the new numbers — and the next hit (M5.2) rolls against the new damage. Rarity color enters at display, affix rolls at generation, and the recompute at equip; each layer plugs into systems already built, which is why so little authored content produces so much.