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-accurateDraw 3: the line is 88 long and the roll lands at 29.7, on Sharpened Edge. That is three different cards.
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:
- The line is 138 long. The roll lands at 68.93, inside Bulwark. Bulwark leaves the pool.
- The line is now 108 long. The roll lands at 77.21, inside Heavy Soul. It leaves too.
- 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:
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 pickedIt 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:
# 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
- Godot docs: RandomNumberGenerator.rand_weighted(), Array (remove_at, and shuffle using the global seed), Random number generation
- Pavlos S. Efraimidis, Weighted Random Sampling over Data Streams (arXiv), building on Efraimidis and Spirakis, "Weighted random sampling with a reservoir", Information Processing Letters (2006), doi:10.1016/j.ipl.2005.11.003
- Developer questions this answers: Weighted random probability with no reoccurrence when needed more than 1 outcome on the Godot Forum, Weighted random sample of array items without replacement on Stack Overflow
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.

