Start with the enum. Element :: enum { Fire, Ice, Lightning, Physical } defines four named labels — and that's a type with exactly four legal values, not a flavour of integer. A bit_set built on it, bit_set[Element], is then one small integer where each member is exactly one bit. Click a member to flip its bit and watch the underlying number recompute.
Level 1 was what it is. Now: why one integer beats a fistful of booleans. Because membership is bits, a question about a whole set — combine these, what's shared, what's left over — is a single operation on the whole integer at once.
Take two sets of resistances: a := {.Fire, .Ice} and b := {.Fire, .Physical}. Each is just a byte (3 and 9). Every set question below is the matching bitwise op on those bytes — no loop over members, no per-member if. Pick one and read it bit-for-bit:
The subset check — the pattern that earns its keep. A door needs some keys; the player carries some keys. "Does the player hold all the keys the door asks for?" is the subset test door <= player — one comparison, whatever the key count. With player = {.Brass, .Silver, .Gold, .Crystal} and door = {.Silver, .Gold}, that's verified true. Permission checks, status-effect cleansing (effects -= debuffs), collision layers — all the same shape.
The counterfactual: if you only ever asked about one member at a time — "is Fire set?" — you wouldn't need a set at all; a single bool would do. The set earns its keep the moment you start combining and comparing memberships wholesale.
Four separate bool fields (fire, ice, lightning, physical): a union becomes four assignments, a subset check becomes four &&s, and every one is written out by hand — more code, more places to forget one. The compiler can't tell you that you meant to test all four.
An array of bools ([Element]bool): tidier, and you can loop — but a "union" is now a loop you write, a byte per member instead of a bit per member, and you've turned a single-instruction question into iteration.
The bit_set folds all of that into one typed value where the combine is the operator: a | b, a & b, door <= player — the bits do the work, and the type stops you from comparing apples to a different enum's set.
Level 2 sold you on it. Level 3 is the bill — and it's a sharp one: a bit_set's size is governed by the enum's value RANGE, not its member count. Members written 0, 1, 2, … are cheap; members with big explicit values are not.
Bit position is the member's integer value. Declared plainly, the values are 0..3, so the bits land in one byte. Push the count up and the range grows with it — toggle the enum size and watch the backing integer widen:
The cliff: the range, not the count, sets the width — and there's a hard ceiling of 128 bits. Give an enum sparse explicit values and a bit_set can be impossible to build. Status codes spell out { OK = 200, Not_Found = 404, Server_Error = 500 } — three members, but the bits would have to reach value 500:
Http :: enum { OK = 200, Not_Found = 404, Server_Error = 500 } s: bit_set[Http] // won't build Error: bit_set range is greater than 128 bits, 301 bits are required
The fix: keep the enum dense from zero — the plain enum { Fire, Ice, … } form — whenever you intend to make sets of it. Explicit values are for protocols and on-the-wire codes, where you want a lookup on the member, not a set of members. The same "your storage is sized for the worst case in the range" rule shows up anywhere a layout is indexed by value rather than by count.
Error: Cannot convert untyped value '1' to 'Element' from 'untyped integer'
The integer is the backing for storage and bit positions; it is not something you do arithmetic on. Cross to and from int only with an explicit cast — int(e) out, Element(2) in — and that inbound cast is unchecked, so Element(99) builds, runs, and prints %!(BAD ENUM VALUE=99). Validate enum values that come from outside (files, network, input); trust the ones your own code wrote.
The last level isn't bits — it's what happens over the life of a codebase. The enum is a single source of truth, and everything built on it re-derives from it the moment you change it.
Add one member — say .Holy — to Element, and three things move at once, with no edit on your part:
Element :: enum { Fire, Ice, Lightning, Physical, Holy } // bit_set[Element] gains a 5th bit automatically — still one byte (5 of 8 used) // [Element]f32 lookup tables (lesson 05) grow a 5th slot automatically // every exhaustive switch over Element now FAILS TO BUILD until you handle .Holy: Error: Unhandled switch case: Physical
The emergent payoff: the set storage and the lookup tables grow silently and correctly — but the decisions you make on the enum don't get to drift. A plain switch over an enum is exhaustive by default, so the compiler turns "did I update every place that branches on Element?" from a runtime bug hunt into a compile-time to-do list. (The verified error above is from dropping a single case; a new member triggers the same check at every dispatch site.) When you genuinely mean to handle only some cases, you say so out loud with #partial switch — the safe behaviour is the default, and being partial is the thing you opt into.
That's the arc: L1 an enum is named labels; a bit_set is one integer, one bit per member → L2 so combining whole memberships is a single bitwise op — union, intersection, subset → L3 but the width is set by the value range, with a 128-bit ceiling, so keep enums dense → L4 the enum is one source of truth: sets and tables grow free, and exhaustive switches make every decision re-check itself.