Skip to main content
Coding Quests
The Scroll Library
Visual Explainers

How to Offer Several Random Rewards Without Duplicates

October 31, 20267 min read
Part 2 of 4: Loot, rewards and upgrades, explained

How weighted drops pick an item, how to offer several rewards without repeats, and how stacking and cursed upgrades change a run.

  1. 1.Weighted loot tables
  2. 2.Several rewards, no repeats
  3. 3.Additive vs multiplicative upgrades
  4. 4.Risk-reward upgrades

You clear a room and the game offers three upgrades to choose from: Swift Boots, Swift Boots and Bulwark. A choice between two identical cards is not a choice. Duplicates in an offer look like a bug, because they are one.

Offering several different random rewards is a small change to a weighted pick, and it changes the odds in a way worth understanding. This is part 2 of a series on loot and upgrades. Part 1 covered weighted loot tables.

Why repeats happen

Three separate weighted picks are three separate draws on the same line. Nothing stops two of them landing in the same slice. With the six cards below, three independent draws repeat a card about half the time: 49%, worked out from the weights.

Removing each winner fixes that, because the winner's slice leaves the line before the next draw.

Three draws on a shrinking line

The pool below uses the 2D roguelike course's upgrade names. The weights are this article's own: Sharpened Edge, Swift Boots and Bulwark at 30, Heavy Soul and Wider Cleave at 20, and the cursed Glass Blade at 8. That makes a line 138 long.

Three cards, no repeats

Interactive, engine-accurate
3 / 3

Draw 3: the line is 88 long and the roll lands at 29.7, on Sharpened Edge. That is three different cards.

Without removal, 49% of three-card offers would show a card twice.

Each draw is an ordinary weighted pick. The difference is what happens after it: the winner is removed, and the next draw is made on the shorter line that is left. So the offer can never show the same card twice. Offered so far: Bulwark, Heavy Soul, Sharpened Edge.

This runs the GDScript from this article, ported to the browser. The same seed gives the same result in Godot 4.7.2.

Here is the first offer on seed 3, draw by draw:

  1. The line is 138 long. The roll lands at 68.93, inside Bulwark. Bulwark leaves the pool.
  2. The line is now 108 long. The roll lands at 77.21, inside Heavy Soul. It leaves too.
  3. The line is 88 long. The roll lands at 29.66, inside Sharpened Edge.

Three cards, three different cards. Each draw is the plain weighted pick from part 1. The only new step is the removal.

Removal changes the odds

Here is the part that surprises people. Glass Blade owns 8 of 138 on the line, about 6%. So does it appear in 6% of offers?

No. Run 10,000 offers in the demo and it appears in about 21% of them. Two things lift it. An offer makes three draws, so Glass Blade gets three chances, not one. And every time a common card leaves, Glass Blade's 8 becomes a bigger share of the shorter line that remains.

The common cards climb too, from 22% of the line to about 62% of offers. Nothing is wrong. "Weight" means the chance on one draw, and players see offers, not draws. Tune your weights by watching offers. The demo's simulation is the honest number to check.

Three ways to get the same result

Remove and redraw, as above. Simple, and it works with any weights.

Redraw on a repeat. Draw from the full pool, and if the card is already in the offer, throw the draw away and roll again. This gives the same odds as removal. With a small pool it can loop many times before finding something new, and if the pool has fewer cards than the offer needs, it never stops. Removal has neither problem.

Give every card a random key. For each card, roll a number between 0 and 1, raise it to the power of 1 divided by the card's weight, and keep the cards with the biggest keys. Efraimidis and Spirakis showed this matches drawing one at a time without replacement, and it needs only one pass. It is useful when a pool is too large to copy. Pavlos Efraimidis describes it in Weighted Random Sampling over Data Streams.

Where offers still go wrong

Fewer cards than slots. Late in a run, a player may own every upgrade that can still be offered. Decide what happens: offer fewer cards, fill the gap with gold or healing, or allow repeats that stack. The function below simply stops when the pool runs out.

Offering upgrades that do nothing. If an upgrade is maxed out, or meaningless for this build, filter it out before drawing, not after. Filtering after can leave a two-card offer when three were possible.

Repeats across offers. Removal only prevents repeats within one offer. Whether a card can return in the next room is a design choice. In the 2D roguelike course, taking an upgrade again stacks its bonus, so it stays in the pool.

A reroll that cheats. If a reroll button draws from the full pool, a rerolled offer can contain the card you just turned down. Decide whether rerolls exclude the last offer, and do it on purpose.

Seeds. Array.shuffle() and pick_random() use Godot's global random seed, so they ignore any RandomNumberGenerator you seeded. Draw with your own generator if offers must repeat for a seed.

In Godot: pick several, no repeats

pick_unique() keeps two lists side by side: which cards are still in the pool, and their weights. Each round, it draws a slot, records the card, and removes that slot from both lists:

GDScript
func pick_unique(weights: Array[float], count: int, rng: RandomNumberGenerator) -> Array[int]:
var pool_ids: Array[int] = []
var pool_weights := PackedFloat32Array()
for i in weights.size():
if weights[i] > 0.0:
pool_ids.append(i)
pool_weights.append(weights[i])
var picked: Array[int] = []
while picked.size() < count and not pool_ids.is_empty():
var slot := rng.rand_weighted(pool_weights)
picked.append(pool_ids[slot])
pool_ids.remove_at(slot)
pool_weights.remove_at(slot)
return picked

It returns indices into your original list, so cards[i] gives you each card. Cards with weight 0 never enter the pool, which is also how you filter out owned or maxed upgrades: set their weight to 0 for this offer. rand_weighted() needs Godot 4.3 or later. Before that, use the by-hand pick_weighted() from part 1.

Without weights, the course's own version is three lines. duplicate() copies the list so the shuffle does not reorder your master list:

GDScript
# Quoted from the 2D roguelike course, lesson 4 (get_choices):
var pool := _defs.duplicate()
pool.shuffle()
return pool.slice(0, mini(count, pool.size()))

That block is quoted from the course rather than run for this article. shuffle() uses Godot's global seed.

Build it next

2D Roguelike: Loot & Corruption

A quest on CodingQuests: 8 interactive lessons that build one part of a game in Godot, in your browser, checked as you go.

The 2D Roguelike's Loot & Corruption quest builds this exact moment. Lesson 3, free, turns an upgrade into data. Lesson 4 writes get_choices(), the shuffle-and-slice version above, with a check that an offer never repeats a card. Lesson 7 offers three cards every time you clear a combat room.

Lessons 4 and 7 are member lessons. The course's offers are unweighted. Add rarity weights by swapping in pick_unique() from this article.

  • Lesson 3: An Upgrade Is DataFree
  • Lesson 4: The UpgradeManagerMembers
  • Lesson 7: The Room RewardMembers

Lessons 1 to 3 are free to open, no account needed. Guild Hall, the membership, opens the other 5.

FAQ

How do I pick several random items without duplicates?

Pick one, remove it from the pool, then pick the next from what is left. Repeat until you have enough. Without weights, shuffle a copy of the list and take the first few.

How do I do weighted random selection without replacement in Godot?

Keep the weights in a PackedFloat32Array. Call rng.rand_weighted() to get an index, record that item, then remove_at() the index from both the weights and your list of items. Repeat for each slot.

Why does a rare item show up more often than its weight suggests?

Because an offer makes several draws, and each removal makes the remaining items a bigger share of a shorter line. An item with 6% of the weight can appear in about 20% of three-card offers. Measure the odds per offer, not per draw.

Sources and testing

How this was tested. pick_unique() was run in Godot 4.7.2 (headless, on Windows) on 31 October 2026, for five offers on each of 40 seeds. The demo's draws come from a port of the same function and of Godot's random number generator, checked offer for offer against that run, and an automated check confirms no offer repeats a card. The course's three-line shuffle is quoted from the course, not run here. The 49% repeat figure is worked out exactly from the weights, and the per-offer percentages come from the demo's 10,000-offer simulation.

lootrandomnessupgradesroguelikegodot

Stop reading. Start building.

Build a roguelike in Godot from the first lesson. The first quest, the player controller, is free from start to finish. Later quests in the same game add combat, enemies, rooms, loot and upgrades.

Free, and no card needed. Built by a real person, with new quests every month.

Get the next Godot build in your inbox

New quests, project breakdowns, and game-dev tips.

You'll get emails from CodingQuests with Godot tips, new tutorials, course updates, and occasional offers. Unsubscribe anytime. Privacy Policy

Written by Omar

We teach Godot 4 by making you build complete systems: inventories, save systems, action roguelike controllers, enemy AI. The scrolls are free. The quests are where it sticks.