microverse · storage/tuition-convince-sudden · brainstorm, nothing built
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.
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
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.
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
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.
Three of them fail silently if you get them wrong.
The mechanism
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 line | E0063 — struct literal |
a Behaviours field | E0063 — both sites |
a pod! field that is not Pod | type error, at the field |
the behave parameter | E0061 — arity |
a colliding StoreName::game(n) | nothing |
an entry in stats() | nothing |
a Kind arm out of step with from_tag | nothing |
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
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")
The spike
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 } }
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.
Per AGENTS.md, every new check gets broken on purpose before it is trusted. Three sabotages:
// 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.
// 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
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.
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
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.
cast! recommendedSpiked 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.
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.
Option helper measure onlyCollapses 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.
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.
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
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.
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.
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.
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.