microverse · storage/tuition-convince-sudden · brainstorm, nothing built

Sixteen Places, One Decision

Adding one actor kind to a microverse game touches sixteen sites across one file. Fourteen of them are the same decision restated. Three of them fail silently.

16 sites per kind 3 fail silently 14 boilerplate, 2 gameplay 23 lines replace ~79

The engine's Actor carries no behaviour. What an actor does lives in a Pool of the game's own, in the game's root, holding a Handle<Actor> back at the actor it drives. That design is settled and argued out in docs/actors.md — it is not what this page is about.

This page is about what it costs to use. jb55, looking at the demo after the actor work landed: “we're basically asking rust devs to create structs in a new way (no references, etc) … this is probably too much boilerplate.”

So: count it, find what already fails when a developer forgets, and price the ways out.

Correcting the brief

It is not six sites, it is sixteen

The card that prompted this listed six touchpoints. Grepping one representative kind through crates/microverse_shell/examples/demo.rs, and grouping contiguous lines into one edit, gives sixteen outside the tests. Two of the sixteen are the behaviour’s own loops — gameplay somebody was always going to write. The other fourteen are plumbing. Three corrections to the brief matter:

The kind's number is hand-written three times, not once — and the three must agree with each other, with nothing checking that they do.

demo.rs:106 SPINNER_POOL = game(2) BOBBER_POOL = game(3) CHASER_POOL = game(4) offset by 2 demo.rs:537 Spinner = 1, Bobber = 2, Chaser = 3, offset by 1 demo.rs:552 1 => Some(Spinner), 2 => Some(Bobber), 3 => Some(Chaser), and back again ? ? Three sequences, three different offsets, written by hand. Nothing anywhere compares them. A fourth kind extends all three, correctly, from memory.
The numbers do not even start in the same place. Store names begin at game(2) because game(0) and game(1) were already spent on the field; the tags begin at 1 so ActorTag::NONE stays distinguishable. A reader has to hold both offsets to check one kind.

reset_stats() has no per-kind entry, and deliberately so — the cast is spawned once at boot, so clearing its peak would erase what the area load spends. That touchpoint does not exist.

ARCHITECTURE.md is gone. It became docs/, one file per subject, at 92f8d9ee4eec — one commit after the actor work landed.

Try it

What a fourth kind costs

Every site in demo.rs that a fourth actor kind touches, at its real line number. Switch between what exists today and each option on the table.

Sixteen sites: fourteen plumbing, two gameplay.

14sites you edit by hand

Three of them fail silently if you get them wrong.

The mechanism

What already fails, and what does not

Most of the fourteen are safe: the arena's own machinery catches them. The interesting ones are the three it does not.

If you forget…What fails
the Demo field, or the boot lineE0063 — struct literal
a Behaviours fieldE0063 — both sites
a pod! field that is not Podtype error, at the field
the behave parameterE0061 — arity
a colliding StoreName::game(n)nothing
an entry in stats()nothing
a Kind arm out of step with from_tagnothing

stats() returns [Stats; 10] — a hand-typed number. Ten entries in a ten-wide array still compiles, whichever ten they are.

Settled by running it, not by reasoning about it

Two storages, one name

A game names its own storage with StoreName::game(n), which is just GAME_BASE + n — 1024 + n. There is no registry. Each storage carries its own name in its own header, and StoreName::from_u16 accepts everything at or above the base unconditionally, because “above the base a value is some caller's own name, and there is nothing here to compare it against.”

So what happens if two of a game's pools take the same number? I wrote it and ran it rather than reasoning about it:

let a = Pool::<u32>::with_capacity(&arena, StoreName::game(2), 4, Reject); let b = Pool::<u32>::with_capacity(&arena, StoreName::game(2), 4, Reject); first reserved: true second reserved: true first reports as: StoreName("game 2") second reports as: StoreName("game 2")

Both reserve. Both work. Both print a budget line under the same name. The report now has two rows nothing can tell apart, and no test, lint or type anywhere notices. This is the one failure on this page that is a live bug rather than an ergonomic complaint.

The spike

One block, and the numbering stops being yours

The strongest option is a macro_rules! that takes the kinds as a list. It is spiked and compiling — no proc macro, which tests/boundary.rs would refuse. This is the whole of what a game writes for three kinds:

cast! { base: 2; /// An actor that turns in place. spinners: Spinner [8] { rate: f32 } /// An actor that rides up and down on the clock. bobbers: Bobber [8] { amplitude: f32, rate: f32, phase: f32 } /// An actor that steers toward another actor. chasers: Chaser [4] { target: Handle<Actor>, speed: f32 } }

Twenty-three lines, replacing roughly seventy-nine: thirty-six one-line-per-kind repetitions across twelve sites, twenty-four lines of pod! bodies, and the nineteen-line Kind enum with its two numbering sequences.

It generates the payload pod!, the store name, the Kind enum with tag() and from_tag(), the pool aggregate, its view, and stats(). The position in the list is the number, so the three hand-written sequences become one and a collision stops being representable.

cast! { … } one block pod! payload per kind StoreName, numbered by position Kind + tag() + from_tag() Cast — one Pool per kind CastView, exhaustively destructured stats() -> [Stats; N], N counted Not generated, and correctly so: the update loop and the spawn loop. Those are gameplay.
All fourteen plumbing sites collapse. The two that remain are the behaviour's update loop and its spawn loop — the only two sites on the list that say something a macro could not know.

Per AGENTS.md, every new check gets broken on purpose before it is trusted. Three sabotages:

Add a kind caught

// a 4th kind, no other edit pulsers: Pulser [4] { rate: f32 } error[E0308]: mismatched types expected an array with a size of 3, found one with a size of 4

It compiles, takes game(5) on its own, and the one stale caller fails. The count is in the return type.

Reorder the block needs a pin

// swap two kinds, nothing else assertion `left == right` failed left: StoreName("game 3") right: StoreName("game 2")

Reordering silently renumbers every storage — a save-format break. A pinning test is what catches it, and it is mandatory rather than optional.

That hazard is not new. docs/storage.md already names it for the engine's own names — “renaming a variant in place free and reordering the list not” — pinned by arena::tests::the_engines_own_names_do_not_move. The game tier needs the same pin one level down.

The other complaint

Four of the nine Options are real

The second half of jb55's objection is handles instead of references: get() returning Option at every access. behave is 43 code lines, and 24 of them are eight three-line let … else { continue; } guards. But the nine Options are not alike.

GENUINELY STALE-ABLE · 4 actors.get(chase.target) actors.get(chase.actor) actors.get(spin.actor) actors.get(bob.actor) a despawned actor — what handles are for MECHANICAL · 5 graph.world(target) graph.world(from.node()) graph.local_mut(…) × 2 actors.get_mut(chase.actor) a live actor's node; one borrow-checker re-lookup A helper removes the right-hand box. Nothing fails if a developer does not use it.
The four on the left are irreducible without giving up handle validity — a stale handle is a chaser that stops, not a crash. The five on the right are a node that came out of a live Actor, plus one get_mut re-lookup of a handle resolved three lines earlier, forced by the borrow checker.

AGENTS.md: “a principle that matters here is enforced by something that fails.” “The developer writes fewer let-else” is not something that fails, which makes this the weakest item on the page. The measurement is worth recording; the helper is not worth adding yet.

Costed

Six ways out

C · the collision check do this first

A test asserting a game's storage names are distinct. No API change, roughly eight lines against existing surface — StoreName already has is_game(), Stats already carries name.

Buys the one live silent failure on this page, independent of everything else. Costs one caveat: it must cover the whole report chain, since the field carve is an Arena reported separately — and must filter to game names, because the engine's graph reports two storages under one name deliberately.

A · cast! recommended

Spiked and compiling. Collapses all fourteen plumbing sites; makes the collision unrepresentable; puts the stats count in the return type.

Costs a mandatory pinning test, and rustfmt not reaching inside macro bodies — already paid once by trip-glue-page for 25 declarations and measured there. Unsettled: whether the macro is exported from the core, which would have the core take a position on what a behaviour is.

B · hand-written aggregate fallback

The Graph pattern applied game-side: a no_padding! struct of pools, a view, an exhaustive destructure. A strict subset of A.

Buys 7 of the 14 and makes a forgotten stats() line E0027. Leaves all three hand-numbered sequences. Costs ~40 lines written once, no macro, no core commitment.

D · an Option helper measure only

Collapses actor → node → matrix into one Option, removing five of nine guards.

Costs core API surface with no enforceable check behind it. Record the measurement; add nothing.

E · do nothing partly right

Capacity, name, fields and policy are real decisions the arena model makes explicit where another engine would hide them.

But 14 of the 16 are one decision restated, and one collides silently. “Nothing beyond C” is defensible; “nothing at all” is not.

F · a proc-macro derive no

Worth flagging that the constraint is narrower than the brief said: tests/boundary.rs text-scans only the core's Cargo.toml, so a derive in a new crate would not be caught by it.

But macro_rules! already does the whole job including the counting, so a derive buys rustfmt reach and better spans and nothing else. docs/crates.md says shell dependencies are platform libraries; a proc macro is not one.

How this goes wrong

Where the model drifts

The line counts are one file, not a law

Every number here comes from demo.rs at bb0507351639 — one game, three kinds, 2,715 lines of which half are tests. A game with fifteen kinds and no Behaviours struct has a different shape, and the sixteen could be higher or lower. The grouping is a judgement call too: :362 and :366 are one edit region here, and counted apart they would be two. “Fourteen of sixteen” is a measurement of this file, not a property of the API.

Auto-numbering trades one silent failure for another

A collision becomes unrepresentable, but a reorder becomes a save-format break where today it is impossible — today the numbers are written down, so moving a block does nothing. The pinning test is what makes the trade net-positive, and without it option A is a regression, not an improvement.

The macro's home is genuinely unsettled

cast! encodes a convention — every kind has an actor: Handle<Actor> field. Exported from the core, that is the core taking a position on what a behaviour is, against docs/actors.md's “adding one is a file in a game rather than an edit to the core”. Kept in the demo, it is a pattern to copy rather than an answer. Both readings go in the card; the session that picks it up settles it.

Unreviewed: this page's own layout

Written without a browser to check it in. Tags balance, every figure carries an aria-label, and every colour comes from a token declared on bare :root — but the rendering is unverified.